From 0f917695dd570f83efc59fe24e70956ecd59dad8 Mon Sep 17 00:00:00 2001 From: Fahri Can Date: Thu, 16 Apr 2026 15:20:42 +0300 Subject: [PATCH 1/3] chore: add workflow --- .gitea/workflows/docker_check.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .gitea/workflows/docker_check.yml diff --git a/.gitea/workflows/docker_check.yml b/.gitea/workflows/docker_check.yml new file mode 100644 index 0000000..5034412 --- /dev/null +++ b/.gitea/workflows/docker_check.yml @@ -0,0 +1,26 @@ +name: Check Docker Pi + +on: + push: + branches: [check-docker] + +jobs: + check-docker: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Get Docker Info + run: | + date > docker_info.txt + echo "==== DOCKER PS ====" >> docker_info.txt + docker ps -a >> docker_info.txt + echo "==== DOCKER STATS ====" >> docker_info.txt + docker stats --no-stream >> docker_info.txt + + git config --global user.name "Gitea Actions" + git config --global user.email "actions@gitea.local" + git add docker_info.txt + git commit -m "chore: add docker info" || true + git push origin check-docker From c8fa4c442d4ad6e0eb2f8e8d56da2d57d36d98e0 Mon Sep 17 00:00:00 2001 From: Gitea Actions Date: Thu, 16 Apr 2026 12:20:56 +0000 Subject: [PATCH 2/3] chore: add docker info --- docker_info.txt | 62 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docker_info.txt diff --git a/docker_info.txt b/docker_info.txt new file mode 100644 index 0000000..70ff0a1 --- /dev/null +++ b/docker_info.txt @@ -0,0 +1,62 @@ +Thu Apr 16 12:20:54 UTC 2026 +==== DOCKER PS ==== +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +78aab6872b85 gitea/runner-images:ubuntu-latest "/bin/sleep 10800" 2 seconds ago Up 1 second GITEA-ACTIONS-TASK-185_WORKFLOW-Check-Docker-Pi_JOB-check-docker +784ca4842e79 iddaai-be:latest "docker-entrypoint.s…" 6 minutes ago Up 6 minutes 3000/tcp, 127.0.0.1:1810->3005/tcp iddaai-be +48f495d45025 iddaai-fe:latest "docker-entrypoint.s…" 2 hours ago Up 2 hours 127.0.0.1:1510->3000/tcp iddaai-fe +a60b07c52d7a gitea/act_runner:latest "/sbin/tini -- run.sh" 22 hours ago Up 22 hours gitea_runner +436552af4199 iddaai-ai-engine "uvicorn main:app --…" 23 hours ago Up 23 hours (healthy) 8000/tcp iddaai-ai-engine +696050fc89de postgres:17-alpine "docker-entrypoint.s…" 23 hours ago Up 23 hours (healthy) 5432/tcp iddaai-postgres +abcc43242dbb redis:7-alpine "docker-entrypoint.s…" 23 hours ago Up 23 hours (healthy) 6379/tcp iddaai-redis +da0f2d5bc898 temporalio/auto-setup:latest "/etc/temporal/entry…" 3 weeks ago Up 8 days 6933-6935/tcp, 6939/tcp, 7233-7235/tcp, 7239/tcp temporal +4768eec66926 ghcr.io/gitroomhq/postiz-app:latest "docker-entrypoint.s…" 3 weeks ago Up 8 days 0.0.0.0:4007->5000/tcp, [::]:4007->5000/tcp postiz +5cfb55782d8b postgres:16 "docker-entrypoint.s…" 3 weeks ago Up 8 days 5432/tcp temporal-postgresql +cf8591458662 redis:7.2 "docker-entrypoint.s…" 3 weeks ago Up 8 days (healthy) 6379/tcp postiz-redis +0108dc0b875d postgres:17-alpine "docker-entrypoint.s…" 3 weeks ago Up 8 days (healthy) 5432/tcp postiz-postgres +c88a569ddb22 elasticsearch:8.16.2 "/bin/tini -- /usr/l…" 3 weeks ago Up 8 days 9200/tcp, 9300/tcp temporal-elasticsearch +208bbf92c2d8 temporalio/ui:latest "./start-ui-server.sh" 3 weeks ago Up 8 days 0.0.0.0:8085->8080/tcp, [::]:8085->8080/tcp temporal-ui +a0555f255857 haruncan-studio-fe:latest "/docker-entrypoint.…" 3 weeks ago Up 8 days 0.0.0.0:1509->80/tcp, [::]:1509->80/tcp haruncan-studio-fe-container +7591abf68bf5 backend-haruncan-studio:latest "docker-entrypoint.s…" 3 weeks ago Up 8 days 0.0.0.0:1809->3000/tcp, [::]:1809->3000/tcp backend-haruncan-studio-container +96d02609b108 ui-indir:latest "docker-entrypoint.s…" 5 weeks ago Up 8 days 0.0.0.0:1507->3000/tcp, [::]:1507->3000/tcp ui-indir-container +f67335b1625f ghcr.io/open-webui/open-webui:main "bash start.sh" 6 weeks ago Up 8 days (healthy) 0.0.0.0:3001->8080/tcp, [::]:3001->8080/tcp openclaw +24b3c6e32817 gitea/gitea:latest "/usr/bin/entrypoint…" 6 weeks ago Up 8 days 0.0.0.0:222->22/tcp, [::]:222->22/tcp, 0.0.0.0:1224->3000/tcp, [::]:1224->3000/tcp gitea +4e64e3199178 postgres:14 "docker-entrypoint.s…" 6 weeks ago Up 8 days 5432/tcp gitea_db +cb7fdcbcd79f postgres:16-alpine "docker-entrypoint.s…" 6 weeks ago Up 8 days 5432/tcp backend_db +f0784aedcadf redis:alpine "docker-entrypoint.s…" 6 weeks ago Up 8 days 6379/tcp apps_redis +fdc89d4a236a portainer/portainer-ce:latest "/portainer" 6 weeks ago Up 8 days 8000/tcp, 9443/tcp, 0.0.0.0:9000->9000/tcp, [::]:9000->9000/tcp portainer +2de41ca39c1f backend-proje:latest "docker-entrypoint.s…" 2 months ago Restarting (1) 35 seconds ago backend-container +89268da2ab86 skript-ui "docker-entrypoint.s…" 2 months ago Up 8 days 0.0.0.0:1506->3000/tcp, [::]:1506->3000/tcp ui-skript-container +8fced773c984 skript-be "docker-entrypoint.s…" 2 months ago Exited (1) 8 days ago backend-skript-container +ec90982f14b6 backend-digicraft "docker-entrypoint.s…" 2 months ago Up 8 days 0.0.0.0:1805->3001/tcp, [::]:1805->3001/tcp backend-digicraft-container +4eec58a7f453 ui-digicraft "/docker-entrypoint.…" 2 months ago Up 8 days 0.0.0.0:1505->80/tcp, [::]:1505->80/tcp ui-digicraft-container +37f844a6cd20 frontend-proje:latest "docker-entrypoint.s…" 2 months ago Up 8 days 0.0.0.0:1800->3000/tcp, [::]:1800->3000/tcp frontend-container +==== DOCKER STATS ==== +CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS +78aab6872b85 GITEA-ACTIONS-TASK-185_WORKFLOW-Check-Docker-Pi_JOB-check-docker 1.07% 0B / 0B 0.00% 1.12MB / 13.6kB 135kB / 0B 12 +784ca4842e79 iddaai-be 0.00% 0B / 0B 0.00% 12.9MB / 1.04MB 250kB / 0B 18 +48f495d45025 iddaai-fe 0.00% 0B / 0B 0.00% 491kB / 221kB 1.84MB / 0B 26 +a60b07c52d7a gitea_runner 0.10% 0B / 0B 0.00% 25.6MB / 24.8MB 4MB / 0B 11 +436552af4199 iddaai-ai-engine 0.14% 0B / 0B 0.00% 881kB / 840kB 175MB / 0B 20 +696050fc89de iddaai-postgres 0.00% 0B / 0B 0.00% 130MB / 311MB 685MB / 0B 13 +abcc43242dbb iddaai-redis 2.39% 0B / 0B 0.00% 222kB / 126B 3.95MB / 0B 6 +da0f2d5bc898 temporal 1.49% 0B / 0B 0.00% 1.75GB / 1.88GB 300MB / 0B 15 +4768eec66926 postiz 0.54% 0B / 0B 0.00% 8.09MB / 4.33MB 474MB / 0B 152 +5cfb55782d8b temporal-postgresql 0.03% 0B / 0B 0.00% 1.88GB / 1.75GB 57.1MB / 0B 39 +cf8591458662 postiz-redis 0.17% 0B / 0B 0.00% 1.17MB / 545kB 23.4MB / 0B 6 +0108dc0b875d postiz-postgres 0.00% 0B / 0B 0.00% 945kB / 176kB 46.1MB / 0B 8 +c88a569ddb22 temporal-elasticsearch 0.18% 0B / 0B 0.00% 763kB / 96.1kB 288MB / 0B 94 +208bbf92c2d8 temporal-ui 0.00% 0B / 0B 0.00% 655kB / 22.7kB 66.6MB / 0B 8 +a0555f255857 haruncan-studio-fe-container 0.00% 0B / 0B 0.00% 2.13MB / 7.98MB 4.78MB / 0B 5 +7591abf68bf5 backend-haruncan-studio-container 0.00% 0B / 0B 0.00% 776kB / 724kB 127MB / 0B 17 +96d02609b108 ui-indir-container 0.00% 0B / 0B 0.00% 118MB / 27.3MB 139MB / 0B 11 +f67335b1625f openclaw 0.11% 0B / 0B 0.00% 652kB / 16.4kB 1GB / 0B 19 +24b3c6e32817 gitea 2.44% 0B / 0B 0.00% 1.69GB / 1.24GB 200MB / 0B 20 +4e64e3199178 gitea_db 0.74% 0B / 0B 0.00% 1.03GB / 1.25GB 69.1MB / 0B 10 +cb7fdcbcd79f backend_db 0.00% 0B / 0B 0.00% 677kB / 126B 41.4MB / 0B 6 +f0784aedcadf apps_redis 0.23% 0B / 0B 0.00% 677kB / 126B 31MB / 0B 6 +fdc89d4a236a portainer 0.00% 0B / 0B 0.00% 4.08MB / 20.8MB 126MB / 0B 7 +2de41ca39c1f backend-container 0.00% 0B / 0B 0.00% 0B / 0B 0B / 0B 0 +89268da2ab86 ui-skript-container 0.00% 0B / 0B 0.00% 1.71MB / 11.3kB 51.3MB / 0B 11 +ec90982f14b6 backend-digicraft-container 0.06% 0B / 0B 0.00% 2.41MB / 436kB 142MB / 0B 38 +4eec58a7f453 ui-digicraft-container 0.00% 0B / 0B 0.00% 3.95MB / 27.3MB 5.8MB / 0B 5 +37f844a6cd20 frontend-container 0.01% 0B / 0B 0.00% 1.93MB / 3.11MB 13.6MB / 0B 11 From c8e7e4e92731d5f27fbee083860e4e7a6bdf13c1 Mon Sep 17 00:00:00 2001 From: Fahri Can Date: Thu, 16 Apr 2026 17:21:48 +0300 Subject: [PATCH 3/3] cr --- .gitea/workflows/docker_check.yml | 26 - src/app.controller.spec.ts | 12 +- src/app.controller.ts | 4 +- src/app.module.ts | 108 +- src/app.service.ts | 4 +- src/common/base/base.controller.ts | 58 +- src/common/base/base.service.ts | 8 +- src/common/base/index.ts | 4 +- src/common/decorators/index.ts | 10 +- src/common/dto/pagination.dto.ts | 30 +- src/common/filters/global-exception.filter.ts | 30 +- .../interceptors/response.interceptor.ts | 52 +- .../interceptors/sanitize.interceptor.ts | 12 +- src/common/queues/queue.module.ts | 14 +- src/common/types/api-response.type.ts | 4 +- src/common/utils/image.util.ts | 18 +- src/config/configuration.ts | 58 +- src/config/env.validation.ts | 40 +- src/database/database.module.ts | 4 +- src/database/prisma.service.ts | 18 +- src/main.ts | 84 +- src/modules/admin/admin.controller.ts | 114 +- src/modules/admin/admin.module.ts | 4 +- src/modules/admin/dto/admin.dto.ts | 2 +- src/modules/analysis/analysis.controller.ts | 38 +- src/modules/analysis/analysis.module.ts | 10 +- src/modules/analysis/analysis.service.ts | 14 +- .../analysis/dto/analysis-request.dto.ts | 8 +- src/modules/auth/auth.controller.ts | 50 +- src/modules/auth/auth.module.ts | 22 +- src/modules/auth/auth.service.ts | 42 +- src/modules/auth/dto/auth.dto.ts | 16 +- src/modules/auth/guards/auth.guards.ts | 28 +- src/modules/auth/guards/index.ts | 2 +- src/modules/auth/strategies/jwt.strategy.ts | 14 +- src/modules/coupons/coupons.controller.ts | 72 +- src/modules/coupons/coupons.module.ts | 16 +- src/modules/coupons/coupons.service.ts | 8 +- .../coupons/dto/coupons-request.dto.ts | 28 +- .../coupons/services/smart-coupon.service.ts | 36 +- .../coupons/services/user-coupon.service.ts | 38 +- .../feeder/feeder-persistence.service.ts | 78 +- src/modules/feeder/feeder-scraper.service.ts | 210 +- .../feeder/feeder-transformer.service.ts | 64 +- src/modules/feeder/feeder.module.ts | 12 +- src/modules/feeder/feeder.service.ts | 202 +- src/modules/feeder/feeder.types.ts | 176 +- src/modules/gemini/gemini.config.ts | 8 +- src/modules/gemini/gemini.module.ts | 8 +- src/modules/gemini/gemini.service.ts | 54 +- src/modules/gemini/index.ts | 6 +- src/modules/health/health.controller.ts | 28 +- src/modules/health/health.module.ts | 8 +- src/modules/leagues/leagues.controller.ts | 100 +- src/modules/leagues/leagues.module.ts | 8 +- src/modules/leagues/leagues.service.ts | 24 +- src/modules/matches/dto/index.ts | 24 +- src/modules/matches/matches.controller.ts | 64 +- src/modules/matches/matches.module.ts | 8 +- src/modules/matches/matches.service.ts | 140 +- src/modules/predictions/dto/index.ts | 69 +- .../dto/predictions-request.dto.ts | 24 +- .../predictions/dto/smart-coupon.dto.ts | 14 +- .../predictions/predictions.controller.ts | 66 +- src/modules/predictions/predictions.module.ts | 26 +- .../predictions/predictions.service.ts | 370 +-- .../queues/predictions.processor.spec.ts | 42 +- .../queues/predictions.processor.ts | 24 +- .../predictions/queues/predictions.queue.ts | 8 +- .../predictions/queues/predictions.types.ts | 6 +- .../services/ai-feature-store.service.ts | 8 +- .../caption-generator.service.ts | 28 +- .../social-poster/dto/prediction-card.dto.ts | 2 +- .../social-poster/image-renderer.service.ts | 224 +- src/modules/social-poster/meta.service.ts | 36 +- .../social-poster/social-poster.controller.ts | 24 +- .../social-poster/social-poster.module.ts | 18 +- .../social-poster/social-poster.service.ts | 124 +- src/modules/social-poster/twitter.service.ts | 26 +- src/modules/spor-toto/dto/spor-toto.dto.ts | 82 +- .../services/toto-analytics.service.ts | 8 +- .../services/toto-combinatorics.service.ts | 8 +- .../services/toto-fetcher.service.ts | 38 +- .../services/toto-prediction.service.ts | 148 +- src/modules/spor-toto/spor-toto.controller.ts | 142 +- src/modules/spor-toto/spor-toto.module.ts | 20 +- src/modules/spor-toto/spor-toto.service.ts | 62 +- src/modules/users/dto/user.dto.ts | 26 +- src/modules/users/users.controller.ts | 54 +- src/modules/users/users.module.ts | 6 +- src/modules/users/users.service.ts | 20 +- src/scripts/backtest-accuracy.ts | 152 +- src/scripts/batch-predict.ts | 24 +- src/scripts/check-duplicate-matches.ts | 22 +- src/scripts/cleanup-live-matches.ts | 40 +- src/scripts/compute-elo-ratings.ts | 48 +- src/scripts/export-postman-collection.ts | 335 +-- .../export-swagger-endpoints-summary.ts | 176 +- src/scripts/populate-feature-store.ts | 82 +- src/scripts/run-all-fe-compatible.ts | 7 +- src/scripts/run-feeder-basketball.ts | 16 +- src/scripts/run-feeder-filtered.ts | 30 +- src/scripts/run-feeder.ts | 18 +- src/scripts/run-full-stack.ts | 159 +- src/scripts/run-live-feeder.ts | 36 +- src/services/ai.service.ts | 104 +- src/services/match-analysis.service.ts | 42 +- src/services/scraper.service.ts | 30 +- src/services/services.module.ts | 18 +- src/tasks/data-fetcher.task.ts | 2151 +++++++---------- .../historical-results-sync.task.spec.ts | 12 +- src/tasks/historical-results-sync.task.ts | 16 +- src/tasks/limit-resetter.task.ts | 34 +- src/tasks/live-updater.task.ts | 177 -- src/tasks/tasks.module.ts | 35 +- test/app.e2e-spec.ts | 18 +- 116 files changed, 3720 insertions(+), 4197 deletions(-) delete mode 100644 .gitea/workflows/docker_check.yml delete mode 100755 src/tasks/live-updater.task.ts diff --git a/.gitea/workflows/docker_check.yml b/.gitea/workflows/docker_check.yml deleted file mode 100644 index 5034412..0000000 --- a/.gitea/workflows/docker_check.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Check Docker Pi - -on: - push: - branches: [check-docker] - -jobs: - check-docker: - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Get Docker Info - run: | - date > docker_info.txt - echo "==== DOCKER PS ====" >> docker_info.txt - docker ps -a >> docker_info.txt - echo "==== DOCKER STATS ====" >> docker_info.txt - docker stats --no-stream >> docker_info.txt - - git config --global user.name "Gitea Actions" - git config --global user.email "actions@gitea.local" - git add docker_info.txt - git commit -m "chore: add docker info" || true - git push origin check-docker diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts index d22f389..f643a70 100755 --- a/src/app.controller.spec.ts +++ b/src/app.controller.spec.ts @@ -1,8 +1,8 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { AppController } from "./app.controller"; +import { AppService } from "./app.service"; -describe('AppController', () => { +describe("AppController", () => { let appController: AppController; beforeEach(async () => { @@ -14,9 +14,9 @@ describe('AppController', () => { appController = app.get(AppController); }); - describe('root', () => { + describe("root", () => { it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); + expect(appController.getHello()).toBe("Hello World!"); }); }); }); diff --git a/src/app.controller.ts b/src/app.controller.ts index cce879e..1e7e628 100755 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Get } from '@nestjs/common'; -import { AppService } from './app.service'; +import { Controller, Get } from "@nestjs/common"; +import { AppService } from "./app.service"; @Controller() export class AppController { diff --git a/src/app.module.ts b/src/app.module.ts index 115bdb0..3b66951 100755 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,19 +1,19 @@ -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 { 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'; +} from "nestjs-i18n"; +import { ServeStaticModule } from "@nestjs/serve-static"; +import * as path from "path"; // Config import { @@ -24,52 +24,52 @@ import { i18nConfig, featuresConfig, throttleConfig, -} from './config/configuration'; -import { geminiConfig } from './modules/gemini/gemini.config'; -import { validateEnv } from './config/env.validation'; +} 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'; +import { GlobalExceptionFilter } from "./common/filters/global-exception.filter"; +import { ResponseInterceptor } from "./common/interceptors/response.interceptor"; // Database -import { DatabaseModule } from './database/database.module'; +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'; +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'; +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'; +import { ServicesModule } from "./services/services.module"; +import { TasksModule } from "./tasks/tasks.module"; // Feeder Module (Historical Data Scraping) -import { FeederModule } from './modules/feeder/feeder.module'; +import { FeederModule } from "./modules/feeder/feeder.module"; // Guards import { JwtAuthGuard, RolesGuard, PermissionsGuard, -} from './modules/auth/guards'; +} from "./modules/auth/guards"; // Queue -import { QueueModule } from './common/queues/queue.module'; +import { QueueModule } from "./common/queues/queue.module"; -const redisEnabled = process.env.REDIS_ENABLED === 'true'; -const historicalFeederMode = process.env.FEEDER_MODE === 'historical'; +const redisEnabled = process.env.REDIS_ENABLED === "true"; +const historicalFeederMode = process.env.FEEDER_MODE === "historical"; @Module({ imports: [ @@ -94,8 +94,8 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical'; // Static Assets (Images, Uploads) ServeStaticModule.forRoot({ - rootPath: path.join(__dirname, '..', 'public'), - serveRoot: '/', // This means public/uploads/x.png -> /uploads/x.png + rootPath: path.join(__dirname, "..", "public"), + serveRoot: "/", // This means public/uploads/x.png -> /uploads/x.png }), // Logger (Structured Logging with Pino) @@ -105,10 +105,10 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical'; useFactory: (configService: ConfigService) => { return { pinoHttp: { - level: configService.get('app.isDevelopment') ? 'debug' : 'info', - transport: configService.get('app.isDevelopment') + level: configService.get("app.isDevelopment") ? "debug" : "info", + transport: configService.get("app.isDevelopment") ? { - target: 'pino-pretty', + target: "pino-pretty", options: { singleLine: true, }, @@ -122,15 +122,15 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical'; // i18n I18nModule.forRootAsync({ useFactory: (configService: ConfigService) => ({ - fallbackLanguage: configService.get('i18n.fallbackLanguage', 'en'), + fallbackLanguage: configService.get("i18n.fallbackLanguage", "en"), loaderOptions: { - path: path.join(__dirname, '../i18n/'), - watch: configService.get('app.isDevelopment', true), + path: path.join(__dirname, "../i18n/"), + watch: configService.get("app.isDevelopment", true), }, }), resolvers: [ - new HeaderResolver(['x-lang']), - new QueryResolver(['lang']), + new HeaderResolver(["x-lang"]), + new QueryResolver(["lang"]), AcceptLanguageResolver, ], inject: [ConfigService], @@ -141,8 +141,8 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical'; inject: [ConfigService], useFactory: (configService: ConfigService) => [ { - ttl: configService.get('throttle.ttl', 60000), - limit: configService.get('throttle.limit', 100), + ttl: configService.get("throttle.ttl", 60000), + limit: configService.get("throttle.limit", 100), }, ], }), @@ -153,29 +153,29 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical'; imports: [ConfigModule], useFactory: async (configService: ConfigService) => { // FORCE DISABLE REDIS if user doesn't want it - const useRedis = configService.get('redis.enabled', false); + 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), + host: configService.get("redis.host", "localhost"), + port: configService.get("redis.port", 6379), }, ttl: 60 * 1000, // 1 minute default }); - console.log('✅ Redis cache connected'); + 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'); + console.warn("⚠️ Redis connection failed, using in-memory cache"); } } // Fallback to in-memory cache - console.log('📦 Using in-memory cache'); + console.log("📦 Using in-memory cache"); return { ttl: 60 * 1000, }; diff --git a/src/app.service.ts b/src/app.service.ts index 927d7cc..b00a667 100755 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -1,8 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable } from "@nestjs/common"; @Injectable() export class AppService { getHello(): string { - return 'Hello World!'; + return "Hello World!"; } } diff --git a/src/common/base/base.controller.ts b/src/common/base/base.controller.ts index a1377d6..4c72b7e 100755 --- a/src/common/base/base.controller.ts +++ b/src/common/base/base.controller.ts @@ -8,20 +8,20 @@ import { Body, HttpCode, ParseUUIDPipe, -} from '@nestjs/common'; +} from "@nestjs/common"; import { ApiOperation, ApiOkResponse, ApiNotFoundResponse, ApiBadRequestResponse, -} from '@nestjs/swagger'; -import { BaseService } from './base.service'; -import { PaginationDto } from '../dto/pagination.dto'; +} from "@nestjs/swagger"; +import { BaseService } from "./base.service"; +import { PaginationDto } from "../dto/pagination.dto"; import { ApiResponse, createSuccessResponse, createPaginatedResponse, -} from '../types/api-response.type'; +} from "../types/api-response.type"; /** * Generic base controller with common CRUD endpoints @@ -37,8 +37,8 @@ export abstract class BaseController { @Get() @HttpCode(200) - @ApiOperation({ summary: 'Get all records with pagination' }) - @ApiOkResponse({ description: 'Records retrieved successfully' }) + @ApiOperation({ summary: "Get all records with pagination" }) + @ApiOkResponse({ description: "Records retrieved successfully" }) async findAll( @Query() pagination: PaginationDto, ): Promise> { @@ -52,13 +52,13 @@ export abstract class BaseController { ); } - @Get(':id') + @Get(":id") @HttpCode(200) - @ApiOperation({ summary: 'Get a record by ID' }) - @ApiOkResponse({ description: 'Record retrieved successfully' }) - @ApiNotFoundResponse({ description: 'Record not found' }) + @ApiOperation({ summary: "Get a record by ID" }) + @ApiOkResponse({ description: "Record retrieved successfully" }) + @ApiNotFoundResponse({ description: "Record not found" }) async findOne( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, ): Promise> { const result = await this.service.findOne(id); return createSuccessResponse( @@ -69,9 +69,9 @@ export abstract class BaseController { @Post() @HttpCode(200) - @ApiOperation({ summary: 'Create a new record' }) - @ApiOkResponse({ description: 'Record created successfully' }) - @ApiBadRequestResponse({ description: 'Validation failed' }) + @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( @@ -81,13 +81,13 @@ export abstract class BaseController { ); } - @Put(':id') + @Put(":id") @HttpCode(200) - @ApiOperation({ summary: 'Update an existing record' }) - @ApiOkResponse({ description: 'Record updated successfully' }) - @ApiNotFoundResponse({ description: 'Record not found' }) + @ApiOperation({ summary: "Update an existing record" }) + @ApiOkResponse({ description: "Record updated successfully" }) + @ApiNotFoundResponse({ description: "Record not found" }) async update( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() updateDto: UpdateDto, ): Promise> { const result = await this.service.update(id, updateDto); @@ -97,13 +97,13 @@ export abstract class BaseController { ); } - @Delete(':id') + @Delete(":id") @HttpCode(200) - @ApiOperation({ summary: 'Delete a record (soft delete)' }) - @ApiOkResponse({ description: 'Record deleted successfully' }) - @ApiNotFoundResponse({ description: 'Record not found' }) + @ApiOperation({ summary: "Delete a record (soft delete)" }) + @ApiOkResponse({ description: "Record deleted successfully" }) + @ApiNotFoundResponse({ description: "Record not found" }) async delete( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, ): Promise> { const result = await this.service.delete(id); return createSuccessResponse( @@ -112,12 +112,12 @@ export abstract class BaseController { ); } - @Post(':id/restore') + @Post(":id/restore") @HttpCode(200) - @ApiOperation({ summary: 'Restore a soft-deleted record' }) - @ApiOkResponse({ description: 'Record restored successfully' }) + @ApiOperation({ summary: "Restore a soft-deleted record" }) + @ApiOkResponse({ description: "Record restored successfully" }) async restore( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, ): Promise> { const result = await this.service.restore(id); return createSuccessResponse( diff --git a/src/common/base/base.service.ts b/src/common/base/base.service.ts index 210baa1..717fa0e 100755 --- a/src/common/base/base.service.ts +++ b/src/common/base/base.service.ts @@ -1,7 +1,7 @@ -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'; +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 diff --git a/src/common/base/index.ts b/src/common/base/index.ts index 7c648aa..33c17f0 100755 --- a/src/common/base/index.ts +++ b/src/common/base/index.ts @@ -1,2 +1,2 @@ -export * from './base.service'; -export * from './base.controller'; +export * from "./base.service"; +export * from "./base.controller"; diff --git a/src/common/decorators/index.ts b/src/common/decorators/index.ts index 24df2f1..ba90227 100755 --- a/src/common/decorators/index.ts +++ b/src/common/decorators/index.ts @@ -2,7 +2,7 @@ import { createParamDecorator, ExecutionContext, SetMetadata, -} from '@nestjs/common'; +} from "@nestjs/common"; /** * Get the current authenticated user from request @@ -23,19 +23,19 @@ export const CurrentUser = createParamDecorator( /** * Mark a route as public (no authentication required) */ -export const IS_PUBLIC_KEY = 'isPublic'; +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_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 PERMISSIONS_KEY = "permissions"; export const RequirePermissions = (...permissions: string[]) => SetMetadata(PERMISSIONS_KEY, permissions); @@ -55,6 +55,6 @@ export const CurrentTenant = createParamDecorator( export const CurrentLang = createParamDecorator( (data: unknown, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); - return request.headers['accept-language'] || 'en'; + return request.headers["accept-language"] || "en"; }, ); diff --git a/src/common/dto/pagination.dto.ts b/src/common/dto/pagination.dto.ts index 951b3c3..38f308f 100755 --- a/src/common/dto/pagination.dto.ts +++ b/src/common/dto/pagination.dto.ts @@ -1,9 +1,9 @@ -import { IsOptional, IsInt, Min, Max, IsString, IsIn } from 'class-validator'; -import { Transform } from 'class-transformer'; -import { ApiPropertyOptional } from '@nestjs/swagger'; +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' }) + @ApiPropertyOptional({ default: 1, minimum: 1, description: "Page number" }) @IsOptional() @Transform(({ value }) => parseInt(value, 10)) @IsInt() @@ -14,7 +14,7 @@ export class PaginationDto { default: 10, minimum: 1, maximum: 100, - description: 'Items per page', + description: "Items per page", }) @IsOptional() @Transform(({ value }) => parseInt(value, 10)) @@ -23,21 +23,21 @@ export class PaginationDto { @Max(100) limit?: number = 10; - @ApiPropertyOptional({ description: 'Field to sort by' }) + @ApiPropertyOptional({ description: "Field to sort by" }) @IsOptional() @IsString() - sortBy?: string = 'createdAt'; + sortBy?: string = "createdAt"; @ApiPropertyOptional({ - enum: ['asc', 'desc'], - default: 'desc', - description: 'Sort order', + enum: ["asc", "desc"], + default: "desc", + description: "Sort order", }) @IsOptional() - @IsIn(['asc', 'desc']) - sortOrder?: 'asc' | 'desc' = 'desc'; + @IsIn(["asc", "desc"]) + sortOrder?: "asc" | "desc" = "desc"; - @ApiPropertyOptional({ description: 'Search query' }) + @ApiPropertyOptional({ description: "Search query" }) @IsOptional() @IsString() search?: string; @@ -59,7 +59,7 @@ export class PaginationDto { /** * Get orderBy object for Prisma */ - get orderBy(): Record { - return { [this.sortBy || 'createdAt']: this.sortOrder || 'desc' }; + 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 index fdfa24b..a9a331f 100755 --- a/src/common/filters/global-exception.filter.ts +++ b/src/common/filters/global-exception.filter.ts @@ -5,10 +5,10 @@ import { 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'; +} 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 @@ -27,23 +27,23 @@ export class GlobalExceptionFilter implements ExceptionFilter { // Determine status and message let status = HttpStatus.INTERNAL_SERVER_ERROR; - let message = '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') { + if (typeof exceptionResponse === "string") { message = exceptionResponse; - } else if (typeof exceptionResponse === 'object') { + } 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'; + message = "VALIDATION_FAILED"; } } } else if (exception instanceof Error) { @@ -57,22 +57,22 @@ export class GlobalExceptionFilter implements ExceptionFilter { let lang = i18nContext?.lang; if (!lang) { - const acceptLanguage = request.headers['accept-language']; - const xLang = request.headers['x-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 = acceptLanguage.split(",")[0].split(";")[0].split("-")[0]; } } - lang = lang || 'en'; + lang = lang || "en"; // Translate validation error specially - if (message === 'VALIDATION_FAILED') { - message = this.i18n.translate('errors.VALIDATION_FAILED', { lang }); + if (message === "VALIDATION_FAILED") { + message = this.i18n.translate("errors.VALIDATION_FAILED", { lang }); } else { // Try dynamic translation const translatedMessage = this.i18n.translate(`errors.${message}`, { @@ -95,7 +95,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { ); // Build response - const isDevelopment = process.env.NODE_ENV === 'development'; + const isDevelopment = process.env.NODE_ENV === "development"; const errorResponse: ApiResponse = createErrorResponse( message, status, diff --git a/src/common/interceptors/response.interceptor.ts b/src/common/interceptors/response.interceptor.ts index 9be3060..0d611d7 100755 --- a/src/common/interceptors/response.interceptor.ts +++ b/src/common/interceptors/response.interceptor.ts @@ -3,16 +3,16 @@ import { NestInterceptor, ExecutionContext, CallHandler, -} from '@nestjs/common'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { ApiResponse, createSuccessResponse } from '../types/api-response.type'; +} 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'; +import { I18nService, I18nContext } from "nestjs-i18n"; @Injectable() export class ResponseInterceptor implements NestInterceptor< @@ -34,17 +34,17 @@ export class ResponseInterceptor implements NestInterceptor< let lang = i18nContext?.lang; if (!lang) { - const acceptLanguage = request.headers['accept-language']; - const xLang = request.headers['x-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 = acceptLanguage.split(",")[0].split(";")[0].split("-")[0]; } } - lang = lang || 'en'; + 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 @@ -68,7 +68,7 @@ export class ResponseInterceptor implements NestInterceptor< } } - const message = this.i18n.translate('common.success', { + const message = this.i18n.translate("common.success", { lang, }); @@ -79,7 +79,7 @@ export class ResponseInterceptor implements NestInterceptor< } private translateReasons(data: any, lang: string) { - if (!data || typeof data !== 'object') { + if (!data || typeof data !== "object") { return; } @@ -91,44 +91,44 @@ export class ResponseInterceptor implements NestInterceptor< Object.keys(data).forEach((key) => { const val = data[key]; if ( - (key === 'reasons' || - key === 'decision_reasons' || - key === 'reasoning_factors') && + (key === "reasons" || + key === "decision_reasons" || + key === "reasoning_factors") && Array.isArray(val) ) { data[key] = val.map((r: any) => { - if (typeof r !== 'string') return r; + 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') { + } 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)) { + } else if (key === "flags" && Array.isArray(val)) { data[key] = val.map((r: any) => { - if (typeof r !== 'string') return r; + 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)) { + } else if (key === "warnings" && Array.isArray(val)) { data[key] = val.map((r: any) => { - if (typeof r !== 'string') return r; + 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) { + } else if (typeof val === "object" && val !== null) { this.translateReasons(val, lang); } }); @@ -137,11 +137,11 @@ export class ResponseInterceptor implements NestInterceptor< private isApiResponse(data: unknown): boolean { return ( data !== null && - typeof data === 'object' && - 'success' in data && - 'status' in data && - 'message' in data && - 'data' in data + 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 index 4090568..38635a9 100644 --- a/src/common/interceptors/sanitize.interceptor.ts +++ b/src/common/interceptors/sanitize.interceptor.ts @@ -3,8 +3,8 @@ import { NestInterceptor, ExecutionContext, CallHandler, -} from '@nestjs/common'; -import { Observable } from 'rxjs'; +} from "@nestjs/common"; +import { Observable } from "rxjs"; /** * Strips HTML/script tags from all string values in the request body. @@ -15,7 +15,7 @@ export class SanitizeInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); - if (request.body && typeof request.body === 'object') { + if (request.body && typeof request.body === "object") { request.body = this.sanitize(request.body); } @@ -23,7 +23,7 @@ export class SanitizeInterceptor implements NestInterceptor { } private sanitize(value: unknown): unknown { - if (typeof value === 'string') { + if (typeof value === "string") { return this.stripTags(value); } @@ -31,7 +31,7 @@ export class SanitizeInterceptor implements NestInterceptor { return value.map((item) => this.sanitize(item)); } - if (value !== null && typeof value === 'object') { + if (value !== null && typeof value === "object") { const sanitized: Record = {}; for (const [key, val] of Object.entries(value)) { sanitized[key] = this.sanitize(val); @@ -43,6 +43,6 @@ export class SanitizeInterceptor implements NestInterceptor { } private stripTags(input: string): string { - return input.replace(/<[^>]*>/g, ''); + return input.replace(/<[^>]*>/g, ""); } } diff --git a/src/common/queues/queue.module.ts b/src/common/queues/queue.module.ts index dc766a7..c98e1f9 100755 --- a/src/common/queues/queue.module.ts +++ b/src/common/queues/queue.module.ts @@ -1,6 +1,6 @@ -import { Module, Global } from '@nestjs/common'; -import { BullModule } from '@nestjs/bullmq'; -import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Module, Global } from "@nestjs/common"; +import { BullModule } from "@nestjs/bullmq"; +import { ConfigModule, ConfigService } from "@nestjs/config"; @Global() @Module({ @@ -9,14 +9,14 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ connection: { - host: configService.get('redis.host', 'localhost'), - port: configService.get('redis.port', 6379), - password: configService.get('redis.password'), + host: configService.get("redis.host", "localhost"), + port: configService.get("redis.port", 6379), + password: configService.get("redis.password"), }, defaultJobOptions: { attempts: 3, backoff: { - type: 'exponential', + type: "exponential", delay: 1000, }, removeOnComplete: true, diff --git a/src/common/types/api-response.type.ts b/src/common/types/api-response.type.ts index dd7aa90..dc7ae3b 100755 --- a/src/common/types/api-response.type.ts +++ b/src/common/types/api-response.type.ts @@ -33,7 +33,7 @@ export interface PaginationMeta { */ export function createSuccessResponse( data: T, - message = 'Success', + message = "Success", status = 200, ): ApiResponse { return { @@ -72,7 +72,7 @@ export function createPaginatedResponse( total: number, page: number, limit: number, - message = 'Success', + message = "Success", ): ApiResponse> { const totalPages = Math.ceil(total / limit); diff --git a/src/common/utils/image.util.ts b/src/common/utils/image.util.ts index 23a1e1e..eec1bb6 100755 --- a/src/common/utils/image.util.ts +++ b/src/common/utils/image.util.ts @@ -1,10 +1,10 @@ -import { existsSync, createWriteStream, mkdirSync } from 'fs'; -import { dirname } from 'path'; -import axios from 'axios'; -import { Logger } from '@nestjs/common'; +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'); + private static readonly logger = new Logger("ImageUtils"); /** * Downloads an image from a URL and saves it to a local path. @@ -26,8 +26,8 @@ export class ImageUtils { // Download const response = await axios({ url, - method: 'GET', - responseType: 'stream', + method: "GET", + responseType: "stream", timeout: 5000, validateStatus: (status) => status === 200, // Only save if 200 OK }); @@ -37,8 +37,8 @@ export class ImageUtils { response.data.pipe(writer); return new Promise((resolve, reject) => { - writer.on('finish', () => resolve(true)); - writer.on('error', (err) => { + writer.on("finish", () => resolve(true)); + writer.on("error", (err) => { this.logger.warn( `Failed to write image to ${localPath}: ${err.message}`, ); diff --git a/src/config/configuration.ts b/src/config/configuration.ts index f798719..db12752 100755 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,58 +1,58 @@ -import { registerAs } from '@nestjs/config'; +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 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', () => ({ +export const databaseConfig = registerAs("database", () => ({ url: process.env.DATABASE_URL, })); -export const jwtConfig = registerAs('jwt', () => ({ +export const jwtConfig = registerAs("jwt", () => ({ secret: process.env.JWT_SECRET, - accessExpiration: process.env.JWT_ACCESS_EXPIRATION || '15m', - refreshExpiration: process.env.JWT_REFRESH_EXPIRATION || '7d', + 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), +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 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 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', () => ({ +export const mailConfig = registerAs("mail", () => ({ host: process.env.MAIL_HOST, - port: parseInt(process.env.MAIL_PORT || '587', 10), + 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', () => ({ +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', + 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), +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 index 275a6c3..52f46cd 100755 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from "zod"; /** * Helper to parse boolean from string @@ -6,8 +6,8 @@ import { z } from 'zod'; const booleanString = z .string() .optional() - .default('false') - .transform((val) => val === 'true'); + .default("false") + .transform((val) => val === "true"); /** * Environment variables schema validation using Zod @@ -15,46 +15,46 @@ const booleanString = z export const envSchema = z.object({ // Environment NODE_ENV: z - .enum(['development', 'production', 'test']) - .default('development'), + .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'), + 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'), + 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'), + .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'), + 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), + .transform((val) => val === "true") + .default("false" as any), GOOGLE_API_KEY: z.string().optional(), - GEMINI_DEFAULT_MODEL: z.string().default('gemini-2.5-flash'), + 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), + .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(), @@ -98,9 +98,9 @@ export function validateEnv(config: Record): EnvConfig { if (!result.success) { const errors = result.error.issues.map( - (err) => `${err.path.join('.')}: ${err.message}`, + (err) => `${err.path.join(".")}: ${err.message}`, ); - throw new Error(`Environment validation failed:\n${errors.join('\n')}`); + 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 index e8a9179..fb9adf7 100755 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -1,5 +1,5 @@ -import { Global, Module } from '@nestjs/common'; -import { PrismaService } from './prisma.service'; +import { Global, Module } from "@nestjs/common"; +import { PrismaService } from "./prisma.service"; @Global() @Module({ diff --git a/src/database/prisma.service.ts b/src/database/prisma.service.ts index 0a70a3a..73810b8 100755 --- a/src/database/prisma.service.ts +++ b/src/database/prisma.service.ts @@ -3,11 +3,11 @@ import { OnModuleInit, OnModuleDestroy, Logger, -} from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; +} from "@nestjs/common"; +import { PrismaClient } from "@prisma/client"; // Models that support soft delete -const SOFT_DELETE_MODELS = ['user', 'role', 'tenant']; +const SOFT_DELETE_MODELS = ["user", "role", "tenant"]; // Type for Prisma model delegate with common operations interface PrismaDelegate { @@ -29,20 +29,20 @@ export class PrismaService constructor() { super({ log: [ - { emit: 'event', level: 'query' }, - { emit: 'event', level: 'error' }, - { emit: 'event', level: 'warn' }, + { 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]}`, + `Connecting to database... URL: ${process.env.DATABASE_URL?.split("@")[1]}`, ); // Mask password try { await this.$connect(); - this.logger.log('✅ Database connected successfully'); + this.logger.log("✅ Database connected successfully"); } catch (error) { this.logger.error( `❌ Database connection failed: ${error.message}`, @@ -54,7 +54,7 @@ export class PrismaService async onModuleDestroy() { await this.$disconnect(); - this.logger.log('🔌 Database disconnected'); + this.logger.log("🔌 Database disconnected"); } /** diff --git a/src/main.ts b/src/main.ts index 6f61308..f3c5a6f 100755 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,12 @@ -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'; +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 () { @@ -14,9 +14,9 @@ import { SanitizeInterceptor } from './common/interceptors/sanitize.interceptor' }; async function bootstrap() { - const logger = new NestLogger('Bootstrap'); + const logger = new NestLogger("Bootstrap"); - logger.log('🔄 Starting application...'); + logger.log("🔄 Starting application..."); const app = await NestFactory.create(AppModule, { bufferLogs: false }); @@ -31,33 +31,33 @@ async function bootstrap() { app.use(helmet()); // Request payload size limit - app.use(express.json({ limit: '1mb' })); - app.use(express.urlencoded({ extended: true, limit: '1mb' })); + 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'); + const port = configService.get("PORT", 3005); + const nodeEnv = configService.get("NODE_ENV", "development"); // Enable CORS app.enableCors({ origin: - nodeEnv === 'production' + nodeEnv === "production" ? [ - 'https://ui-suggestbet.bilgich.com', - 'https://suggestbet.bilgich.com', - 'https://iddaai.com', - 'https://www.iddaai.com', + "https://ui-suggestbet.bilgich.com", + "https://suggestbet.bilgich.com", + "https://iddaai.com", + "https://www.iddaai.com", ] : true, credentials: true, }); // Global prefix - app.setGlobalPrefix('api'); + app.setGlobalPrefix("api"); // Validation pipe (Strict) app.useGlobalPipes( @@ -72,47 +72,47 @@ async function bootstrap() { ); // Swagger setup — hidden in production - if (nodeEnv !== 'production') { + if (nodeEnv !== "production") { const swaggerConfig = new DocumentBuilder() - .setTitle('Suggest-Bet API') + .setTitle("Suggest-Bet API") .setDescription( - 'AI-driven sports betting prediction engine with smart coupon generation', + "AI-driven sports betting prediction engine with smart coupon generation", ) - .setVersion('1.0') + .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') + .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...'); + logger.log("Initializing Swagger..."); const document = SwaggerModule.createDocument(app, swaggerConfig); - SwaggerModule.setup('api/docs', app, document, { + SwaggerModule.setup("api/docs", app, document, { swaggerOptions: { persistAuthorization: true, }, }); - logger.log('Swagger initialized'); + logger.log("Swagger initialized"); } logger.log(`Attempting to listen on port ${port}...`); - await app.listen(port, '0.0.0.0'); + await app.listen(port, "0.0.0.0"); - logger.log('═══════════════════════════════════════════════════════════'); + 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('═══════════════════════════════════════════════════════════'); + logger.log("═══════════════════════════════════════════════════════════"); - if (nodeEnv === 'development') { - logger.warn('⚠️ Running in development mode'); + if (nodeEnv === "development") { + logger.warn("⚠️ Running in development mode"); } } diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index 4d66638..767f84f 100755 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -10,32 +10,32 @@ import { UseInterceptors, Inject, NotFoundException, -} from '@nestjs/common'; +} 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'; +} 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'; +} 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') +@ApiTags("Admin") @ApiBearerAuth() -@Controller('admin') -@Roles('superadmin') +@Controller("admin") +@Roles("superadmin") export class AdminController { constructor( private readonly prisma: PrismaService, @@ -44,8 +44,8 @@ export class AdminController { // ================== Users Management ================== - @Get('users') - @ApiOperation({ summary: 'Get all users (admin)' }) + @Get("users") + @ApiOperation({ summary: "Get all users (admin)" }) async getAllUsers( @Query() pagination: PaginationDto, ): Promise>> { @@ -73,10 +73,10 @@ export class AdminController { ); } - @Get('users/:id') - @ApiOperation({ summary: 'Get user by ID' }) + @Get("users/:id") + @ApiOperation({ summary: "Get user by ID" }) async getUserById( - @Param('id') id: string, + @Param("id") id: string, ): Promise> { const user = await this.prisma.user.findUnique({ where: { id }, @@ -84,27 +84,27 @@ export class AdminController { usageLimit: true, analyses: { take: 5, - orderBy: { createdAt: 'desc' }, + orderBy: { createdAt: "desc" }, }, }, }); if (!user) { - throw new NotFoundException('User not found'); + throw new NotFoundException("User not found"); } return createSuccessResponse(plainToInstance(UserResponseDto, user)); } - @Put('users/:id/toggle-active') - @ApiOperation({ summary: 'Toggle user active status' }) + @Put("users/:id/toggle-active") + @ApiOperation({ summary: "Toggle user active status" }) async toggleUserActive( - @Param('id') id: string, + @Param("id") id: string, ): Promise> { const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) { - throw new NotFoundException('User not found'); + throw new NotFoundException("User not found"); } const updated = await this.prisma.user.update({ @@ -114,14 +114,14 @@ export class AdminController { return createSuccessResponse( plainToInstance(UserResponseDto, updated), - 'User status updated', + "User status updated", ); } - @Put('users/:id/role') - @ApiOperation({ summary: 'Update user role' }) + @Put("users/:id/role") + @ApiOperation({ summary: "Update user role" }) async updateUserRole( - @Param('id') id: string, + @Param("id") id: string, @Body() data: { role: UserRole }, ): Promise> { const user = await this.prisma.user.update({ @@ -131,14 +131,14 @@ export class AdminController { return createSuccessResponse( plainToInstance(UserResponseDto, user), - 'User role updated', + "User role updated", ); } - @Put('users/:id/subscription') - @ApiOperation({ summary: 'Update user subscription' }) + @Put("users/:id/subscription") + @ApiOperation({ summary: "Update user subscription" }) async updateUserSubscription( - @Param('id') id: string, + @Param("id") id: string, @Body() data: { subscriptionStatus: string; subscriptionExpiresAt?: string }, ): Promise> { @@ -154,40 +154,40 @@ export class AdminController { return createSuccessResponse( plainToInstance(UserResponseDto, user), - 'User subscription updated', + "User subscription updated", ); } - @Delete('users/:id') - @ApiOperation({ summary: 'Soft delete a user' }) - async deleteUser(@Param('id') id: string): Promise> { + @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'); + return createSuccessResponse(null, "User deleted"); } // ================== App Settings ================== - @Get('settings') + @Get("settings") @UseInterceptors(CacheInterceptor) - @CacheKey('app_settings') + @CacheKey("app_settings") @CacheTTL(60 * 1000) - @ApiOperation({ summary: 'Get all app settings' }) + @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 || ''; + settingsMap[s.key] = s.value || ""; } return createSuccessResponse(settingsMap); } - @Put('settings/:key') - @ApiOperation({ summary: 'Update an app setting' }) + @Put("settings/:key") + @ApiOperation({ summary: "Update an app setting" }) async updateSetting( - @Param('key') key: string, + @Param("key") key: string, @Body() data: { value: string }, ): Promise> { const setting = await this.prisma.appSetting.upsert({ @@ -195,17 +195,17 @@ export class AdminController { update: { value: data.value }, create: { key, value: data.value }, }); - await this.cacheManager.del('app_settings'); + await this.cacheManager.del("app_settings"); return createSuccessResponse( - { key: setting.key, value: setting.value || '' }, - 'Setting updated', + { key: setting.key, value: setting.value || "" }, + "Setting updated", ); } // ================== Usage Limits ================== - @Get('usage-limits') - @ApiOperation({ summary: 'Get all usage limits' }) + @Get("usage-limits") + @ApiOperation({ summary: "Get all usage limits" }) async getAllUsageLimits(@Query() pagination: PaginationDto) { const { skip, take } = pagination; @@ -218,7 +218,7 @@ export class AdminController { select: { id: true, email: true, firstName: true, lastName: true }, }, }, - orderBy: { lastResetDate: 'desc' }, + orderBy: { lastResetDate: "desc" }, }), this.prisma.usageLimit.count(), ]); @@ -231,8 +231,8 @@ export class AdminController { ); } - @Post('usage-limits/reset-all') - @ApiOperation({ summary: 'Reset all usage limits' }) + @Post("usage-limits/reset-all") + @ApiOperation({ summary: "Reset all usage limits" }) async resetAllUsageLimits(): Promise> { const result = await this.prisma.usageLimit.updateMany({ data: { @@ -244,14 +244,14 @@ export class AdminController { return createSuccessResponse( { count: result.count }, - 'All usage limits reset', + "All usage limits reset", ); } // ================== Analytics ================== - @Get('analytics/overview') - @ApiOperation({ summary: 'Get system analytics overview' }) + @Get("analytics/overview") + @ApiOperation({ summary: "Get system analytics overview" }) async getAnalyticsOverview() { const [ totalUsers, @@ -262,7 +262,7 @@ export class AdminController { ] = await Promise.all([ this.prisma.user.count(), this.prisma.user.count({ where: { isActive: true } }), - this.prisma.user.count({ where: { subscriptionStatus: 'active' } }), + this.prisma.user.count({ where: { subscriptionStatus: "active" } }), this.prisma.match.count(), this.prisma.prediction.count(), ]); diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 361a281..c51f718 100755 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -1,5 +1,5 @@ -import { Module } from '@nestjs/common'; -import { AdminController } from './admin.controller'; +import { Module } from "@nestjs/common"; +import { AdminController } from "./admin.controller"; @Module({ controllers: [AdminController], diff --git a/src/modules/admin/dto/admin.dto.ts b/src/modules/admin/dto/admin.dto.ts index 9ecb5df..c193c74 100755 --- a/src/modules/admin/dto/admin.dto.ts +++ b/src/modules/admin/dto/admin.dto.ts @@ -1,4 +1,4 @@ -import { Exclude, Expose, Type } from 'class-transformer'; +import { Exclude, Expose, Type } from "class-transformer"; @Exclude() export class PermissionResponseDto { diff --git a/src/modules/analysis/analysis.controller.ts b/src/modules/analysis/analysis.controller.ts index 35821d0..819edd4 100755 --- a/src/modules/analysis/analysis.controller.ts +++ b/src/modules/analysis/analysis.controller.ts @@ -6,20 +6,20 @@ import { HttpCode, HttpStatus, ForbiddenException, -} from '@nestjs/common'; +} 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'; +} from "@nestjs/swagger"; +import { AnalysisService } from "./analysis.service"; +import { AnalyzeMatchesDto } from "./dto/analysis-request.dto"; +import { CurrentUser } from "../../common/decorators"; -@ApiTags('Analysis') +@ApiTags("Analysis") @ApiBearerAuth() -@Controller('analysis') +@Controller("analysis") export class AnalysisController { constructor(private readonly analysisService: AnalysisService) {} @@ -27,12 +27,12 @@ export class AnalysisController { * POST /analysis/analyze-matches * Analyze multiple matches (coupon generation) */ - @Post('analyze-matches') + @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' }) + @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, @@ -48,7 +48,7 @@ export class AnalysisController { ); if (!canProceed) { - throw new ForbiddenException('You have exceeded your daily usage limit'); + throw new ForbiddenException("You have exceeded your daily usage limit"); } // Run analysis @@ -57,7 +57,7 @@ export class AnalysisController { if (!result) { return { success: false, - message: 'None of the provided matches could be analyzed successfully', + message: "None of the provided matches could be analyzed successfully", }; } @@ -73,10 +73,10 @@ export class AnalysisController { /** * POST /analysis/analyze (alias for /analyze-matches - frontend compatibility) */ - @Post('analyze') + @Post("analyze") @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'Analyze multiple matches for coupon (alias)', + summary: "Analyze multiple matches for coupon (alias)", deprecated: true, }) async analyzeMatchesAlias( @@ -90,9 +90,9 @@ export class AnalysisController { * GET /analysis/history * Get user's analysis history */ - @Get('history') - @ApiOperation({ summary: 'Get analysis history' }) - @ApiResponse({ status: 200, description: 'History retrieved' }) + @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 index 003644d..a329ffa 100755 --- a/src/modules/analysis/analysis.module.ts +++ b/src/modules/analysis/analysis.module.ts @@ -1,8 +1,8 @@ -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'; +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], diff --git a/src/modules/analysis/analysis.service.ts b/src/modules/analysis/analysis.service.ts index 1224f4a..513ea85 100755 --- a/src/modules/analysis/analysis.service.ts +++ b/src/modules/analysis/analysis.service.ts @@ -1,9 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { PrismaService } from '../../database/prisma.service'; +import { Injectable, Logger } from "@nestjs/common"; +import { PrismaService } from "../../database/prisma.service"; import { MatchAnalysisService, AnalysisResult, -} from '../../services/match-analysis.service'; +} from "../../services/match-analysis.service"; @Injectable() export class AnalysisService { @@ -50,9 +50,9 @@ export class AnalysisService { } // Build URL for analysis - const sport = (targetMatch as any).sport || 'football'; + 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}`; + const url = `https://www.mackolik.com/${sport === "basketball" ? "basketbol/mac" : "mac"}/${slug}/${matchId}`; // Run analysis const result = await this.matchAnalysisService.analyzeMatch( @@ -110,7 +110,7 @@ export class AnalysisService { // 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 isPremium = user?.subscriptionStatus === "active"; const maxAnalyses = isPremium ? 50 : 10; const maxCoupons = isPremium ? 10 : 3; @@ -145,7 +145,7 @@ export class AnalysisService { async getAnalysisHistory(userId: string, limit: number = 20) { return this.prisma.analysis.findMany({ where: { userId }, - orderBy: { createdAt: 'desc' }, + 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 index d1a0c10..931e788 100644 --- a/src/modules/analysis/dto/analysis-request.dto.ts +++ b/src/modules/analysis/dto/analysis-request.dto.ts @@ -1,10 +1,10 @@ -import { IsArray, IsString, ArrayMinSize, ArrayMaxSize } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; +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'], + description: "List of match IDs to analyze", + example: ["match-1", "match-2"], minItems: 1, maxItems: 20, }) diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 046302d..933da53 100755 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,30 +1,30 @@ -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 { 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'; +} from "./dto/auth.dto"; +import { Public } from "../../common/decorators"; import { ApiResponse, createSuccessResponse, -} from '../../common/types/api-response.type'; +} from "../../common/types/api-response.type"; -@ApiTags('Auth') -@Controller('auth') +@ApiTags("Auth") +@Controller("auth") export class AuthController { constructor(private readonly authService: AuthService) {} - @Post('register') + @Post("register") @Public() @HttpCode(200) - @ApiOperation({ summary: 'Register a new user' }) + @ApiOperation({ summary: "Register a new user" }) @ApiOkResponse({ - description: 'User registered successfully', + description: "User registered successfully", type: TokenResponseDto, }) async register( @@ -32,28 +32,28 @@ export class AuthController { @I18n() i18n: I18nContext, ): Promise> { const result = await this.authService.register(dto); - return createSuccessResponse(result, i18n.t('auth.registered'), 201); + return createSuccessResponse(result, i18n.t("auth.registered"), 201); } - @Post('login') + @Post("login") @Public() @HttpCode(200) - @ApiOperation({ summary: 'Login with email and password' }) - @ApiOkResponse({ description: 'Login successful', type: TokenResponseDto }) + @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')); + return createSuccessResponse(result, i18n.t("auth.login_success")); } - @Post('refresh') + @Post("refresh") @Public() @HttpCode(200) - @ApiOperation({ summary: 'Refresh access token' }) + @ApiOperation({ summary: "Refresh access token" }) @ApiOkResponse({ - description: 'Token refreshed successfully', + description: "Token refreshed successfully", type: TokenResponseDto, }) async refreshToken( @@ -61,18 +61,18 @@ export class AuthController { @I18n() i18n: I18nContext, ): Promise> { const result = await this.authService.refreshToken(dto.refreshToken); - return createSuccessResponse(result, i18n.t('auth.refresh_success')); + return createSuccessResponse(result, i18n.t("auth.refresh_success")); } - @Post('logout') + @Post("logout") @HttpCode(200) - @ApiOperation({ summary: 'Logout and invalidate refresh token' }) - @ApiOkResponse({ description: 'Logout successful' }) + @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')); + return createSuccessResponse(null, i18n.t("auth.logout_success")); } } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index a573328..2839396 100755 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,22 +1,22 @@ -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'; +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' }), + PassportModule.register({ defaultStrategy: "jwt" }), JwtModule.registerAsync({ inject: [ConfigService], useFactory: (configService: ConfigService): JwtModuleOptions => { const expiresIn = - configService.get('JWT_ACCESS_EXPIRATION') || '15m'; + configService.get("JWT_ACCESS_EXPIRATION") || "15m"; return { - secret: configService.get('JWT_SECRET'), + secret: configService.get("JWT_SECRET"), signOptions: { expiresIn: expiresIn as any, }, diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 2f398e3..82ef74a 100755 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -2,14 +2,14 @@ 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'; +} 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; @@ -36,7 +36,7 @@ export class AuthService { }); if (existingUser) { - throw new ConflictException('EMAIL_ALREADY_EXISTS'); + throw new ConflictException("EMAIL_ALREADY_EXISTS"); } // Hash password @@ -76,7 +76,7 @@ export class AuthService { }); if (!user) { - throw new UnauthorizedException('INVALID_CREDENTIALS'); + throw new UnauthorizedException("INVALID_CREDENTIALS"); } // Verify password @@ -86,11 +86,11 @@ export class AuthService { ); if (!isPasswordValid) { - throw new UnauthorizedException('INVALID_CREDENTIALS'); + throw new UnauthorizedException("INVALID_CREDENTIALS"); } if (!user.isActive) { - throw new UnauthorizedException('ACCOUNT_DISABLED'); + throw new UnauthorizedException("ACCOUNT_DISABLED"); } return this.generateTokens(user); @@ -109,7 +109,7 @@ export class AuthService { }); if (!storedToken) { - throw new UnauthorizedException('INVALID_REFRESH_TOKEN'); + throw new UnauthorizedException("INVALID_REFRESH_TOKEN"); } if (storedToken.expiresAt < new Date()) { @@ -117,7 +117,7 @@ export class AuthService { await this.prisma.refreshToken.delete({ where: { id: storedToken.id }, }); - throw new UnauthorizedException('INVALID_REFRESH_TOKEN'); + throw new UnauthorizedException("INVALID_REFRESH_TOKEN"); } // Delete old refresh token @@ -167,13 +167,13 @@ export class AuthService { // Generate access token const accessToken = this.jwtService.sign(payload, { - expiresIn: this.configService.get('JWT_ACCESS_EXPIRATION', '15m'), + 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'), + this.configService.get("JWT_REFRESH_EXPIRATION", "7d"), ); // Store refresh token @@ -190,7 +190,7 @@ export class AuthService { refreshToken: refreshTokenValue, expiresIn: this.parseExpiration( - this.configService.get('JWT_ACCESS_EXPIRATION', '15m'), + this.configService.get("JWT_ACCESS_EXPIRATION", "15m"), ) / 1000, // Convert to seconds user: { id: user.id, @@ -233,13 +233,13 @@ export class AuthService { const unit = match[2]; switch (unit) { - case 's': + case "s": return value * 1000; - case 'm': + case "m": return value * 60 * 1000; - case 'h': + case "h": return value * 60 * 60 * 1000; - case 'd': + 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 index b11504d..bb7cf80 100755 --- a/src/modules/auth/dto/auth.dto.ts +++ b/src/modules/auth/dto/auth.dto.ts @@ -1,33 +1,33 @@ -import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsString, MinLength, IsOptional } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; export class RegisterDto { - @ApiProperty({ example: 'user@example.com' }) + @ApiProperty({ example: "user@example.com" }) @IsEmail() email: string; - @ApiProperty({ example: 'password123', minLength: 8 }) + @ApiProperty({ example: "password123", minLength: 8 }) @IsString() @MinLength(8) password: string; - @ApiPropertyOptional({ example: 'John' }) + @ApiPropertyOptional({ example: "John" }) @IsOptional() @IsString() firstName?: string; - @ApiPropertyOptional({ example: 'Doe' }) + @ApiPropertyOptional({ example: "Doe" }) @IsOptional() @IsString() lastName?: string; } export class LoginDto { - @ApiProperty({ example: 'user@example.com' }) + @ApiProperty({ example: "user@example.com" }) @IsEmail() email: string; - @ApiProperty({ example: 'password123' }) + @ApiProperty({ example: "password123" }) @IsString() password: string; } diff --git a/src/modules/auth/guards/auth.guards.ts b/src/modules/auth/guards/auth.guards.ts index bdb001f..434ccda 100755 --- a/src/modules/auth/guards/auth.guards.ts +++ b/src/modules/auth/guards/auth.guards.ts @@ -4,15 +4,15 @@ import { ExecutionContext, UnauthorizedException, ForbiddenException, -} from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { AuthGuard } from '@nestjs/passport'; -import { Request } from 'express'; +} 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'; +} from "../../../common/decorators"; interface AuthenticatedUser { id: string; @@ -25,14 +25,14 @@ interface AuthenticatedUser { * JWT Auth Guard - Validates JWT token */ @Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') { +export class JwtAuthGuard extends AuthGuard("jwt") { constructor(private reflector: Reflector) { super(); } canActivate(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); - if (request?.method === 'OPTIONS') { + if (request?.method === "OPTIONS") { return true; } @@ -55,10 +55,10 @@ export class JwtAuthGuard extends AuthGuard('jwt') { info: any, ): TUser { if (err || !user) { - if (info?.name === 'TokenExpiredError') { - throw new UnauthorizedException('TOKEN_EXPIRED'); + if (info?.name === "TokenExpiredError") { + throw new UnauthorizedException("TOKEN_EXPIRED"); } - throw err || new UnauthorizedException('AUTH_REQUIRED'); + throw err || new UnauthorizedException("AUTH_REQUIRED"); } return user; } @@ -73,7 +73,7 @@ export class RolesGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const req = context.switchToHttp().getRequest(); - if (req?.method === 'OPTIONS') { + if (req?.method === "OPTIONS") { return true; } @@ -94,7 +94,7 @@ export class RolesGuard implements CanActivate { const hasRole = requiredRoles.some((role) => user.roles.includes(role)); if (!hasRole) { - throw new ForbiddenException('PERMISSION_DENIED'); + throw new ForbiddenException("PERMISSION_DENIED"); } return true; @@ -110,7 +110,7 @@ export class PermissionsGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const req = context.switchToHttp().getRequest(); - if (req?.method === 'OPTIONS') { + if (req?.method === "OPTIONS") { return true; } @@ -134,7 +134,7 @@ export class PermissionsGuard implements CanActivate { ); if (!hasPermission) { - throw new ForbiddenException('PERMISSION_DENIED'); + throw new ForbiddenException("PERMISSION_DENIED"); } return true; diff --git a/src/modules/auth/guards/index.ts b/src/modules/auth/guards/index.ts index 4916ad7..79d18ce 100755 --- a/src/modules/auth/guards/index.ts +++ b/src/modules/auth/guards/index.ts @@ -1 +1 @@ -export * from './auth.guards'; +export * from "./auth.guards"; diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts index a873612..b44b7d6 100755 --- a/src/modules/auth/strategies/jwt.strategy.ts +++ b/src/modules/auth/strategies/jwt.strategy.ts @@ -1,8 +1,8 @@ -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'; +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) { @@ -10,9 +10,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) { private readonly configService: ConfigService, private readonly authService: AuthService, ) { - const secret = configService.get('JWT_SECRET'); + const secret = configService.get("JWT_SECRET"); if (!secret) { - throw new Error('JWT_SECRET is not defined'); + throw new Error("JWT_SECRET is not defined"); } super({ diff --git a/src/modules/coupons/coupons.controller.ts b/src/modules/coupons/coupons.controller.ts index f81d45a..e77bb5e 100755 --- a/src/modules/coupons/coupons.controller.ts +++ b/src/modules/coupons/coupons.controller.ts @@ -9,31 +9,31 @@ import { UseGuards, Req, Logger, -} from '@nestjs/common'; +} 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'; +} 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'; +} 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'; +} 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') +@ApiTags("Coupon") +@Controller("coupon") export class CouponsController { private readonly logger = new Logger(CouponsController.name); @@ -48,15 +48,15 @@ export class CouponsController { * POST /coupon/analyze-match * Analyze a single match with V20+ single-match package */ - @Post('analyze-match') + @Post("analyze-match") @Public() @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Analyze single match with V20 model' }) - @ApiResponse({ status: 200, description: 'Match analysis' }) + @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: false, message: "Analiz yapılamadı." }; } return { success: true, data: analysis }; } @@ -64,11 +64,11 @@ export class CouponsController { /** * POST /coupon/analyze (alias for /analyze-match - frontend compatibility) */ - @Post('analyze') + @Post("analyze") @Public() @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'Analyze single match with V20 model (alias)', + summary: "Analyze single match with V20 model (alias)", deprecated: true, }) async analyzeMatchAlias(@Body() dto: AnalyzeMatchDto) { @@ -83,7 +83,7 @@ export class CouponsController { @UseGuards(JwtAuthGuard) @ApiBearerAuth() @HttpCode(HttpStatus.CREATED) - @ApiOperation({ summary: 'Create and save a user coupon (alias)' }) + @ApiOperation({ summary: "Create and save a user coupon (alias)" }) async createCouponAlias(@Body() dto: CreateCouponDto, @Req() req: any) { return this.createCoupon(dto, req); } @@ -92,11 +92,11 @@ export class CouponsController { * POST /coupon/daily-banko * Generate a high-confidence banko combo (2 matches) */ - @Post('daily-banko') + @Post("daily-banko") @Public() @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'Generate a high-confidence banko combo (2 matches)', + 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) @@ -122,7 +122,7 @@ export class CouponsController { if (candidateMatches.length === 0) { return { success: false, - message: 'Kupon için uygun, henüz baÅŸlamamış maç bulunamadı.', + message: "Kupon için uygun, henüz baÅŸlamamış maç bulunamadı.", }; } @@ -131,7 +131,7 @@ export class CouponsController { if (!coupon) { return { success: false, - message: 'Kriterlere uygun (80%+ güvenli) yeterli maç bulunamadı.', + message: "Kriterlere uygun (80%+ güvenli) yeterli maç bulunamadı.", }; } return { success: true, data: coupon }; @@ -141,11 +141,11 @@ export class CouponsController { * POST /coupon/suggest * Generate Smart Coupon */ - @Post('suggest') + @Post("suggest") @Public() @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Suggest Smart Coupon' }) - @ApiResponse({ status: 200, description: 'Smart Coupon generated' }) + @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 || []; @@ -170,7 +170,7 @@ export class CouponsController { if (candidateMatches.length === 0) { return { success: false, - message: 'Tahmin için uygun, henüz baÅŸlamamış maç bulunamadı.', + message: "Tahmin için uygun, henüz baÅŸlamamış maç bulunamadı.", }; } @@ -183,7 +183,7 @@ export class CouponsController { }, ); if (!coupon) { - return { success: false, message: 'Kupon oluşturulamadı.' }; + return { success: false, message: "Kupon oluşturulamadı." }; } return { success: true, data: coupon }; } @@ -196,11 +196,11 @@ export class CouponsController { * POST /coupon/create * Save a user generated coupon */ - @Post('create') + @Post("create") @UseGuards(JwtAuthGuard) @ApiBearerAuth() @HttpCode(HttpStatus.CREATED) - @ApiOperation({ summary: 'Create and save a user coupon' }) + @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); @@ -211,10 +211,10 @@ export class CouponsController { * GET /coupon/my-stats * Get user betting statistics (ROI, Win Rate) */ - @Get('my-stats') + @Get("my-stats") @UseGuards(JwtAuthGuard) @ApiBearerAuth() - @ApiOperation({ summary: 'Get user betting statistics' }) + @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 }; @@ -224,11 +224,11 @@ export class CouponsController { * GET /coupon/history * Get coupon history (Public/System coupons) */ - @Get('history') + @Get("history") @ApiBearerAuth() - @ApiOperation({ summary: 'Get coupon history' }) - @ApiResponse({ status: 200, description: 'History retrieved' }) - async getHistory(@Query('limit') limit?: string) { + @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, diff --git a/src/modules/coupons/coupons.module.ts b/src/modules/coupons/coupons.module.ts index 82738f5..4670bbc 100755 --- a/src/modules/coupons/coupons.module.ts +++ b/src/modules/coupons/coupons.module.ts @@ -1,11 +1,11 @@ -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'; +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], diff --git a/src/modules/coupons/coupons.service.ts b/src/modules/coupons/coupons.service.ts index c934960..ba7cbb8 100755 --- a/src/modules/coupons/coupons.service.ts +++ b/src/modules/coupons/coupons.service.ts @@ -1,9 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { PrismaService } from '../../database/prisma.service'; -import { AiService } from '../../services/ai.service'; +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 type RiskLevel = "banko" | "safe" | "value"; export interface CouponMatch { matchId: string; diff --git a/src/modules/coupons/dto/coupons-request.dto.ts b/src/modules/coupons/dto/coupons-request.dto.ts index 20c3ea6..dc86c9e 100644 --- a/src/modules/coupons/dto/coupons-request.dto.ts +++ b/src/modules/coupons/dto/coupons-request.dto.ts @@ -8,19 +8,19 @@ import { ArrayMaxSize, Min, Max, -} from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; export enum CouponStrategyEnum { - SAFE = 'SAFE', - BALANCED = 'BALANCED', - AGGRESSIVE = 'AGGRESSIVE', - VALUE = 'VALUE', - MIRACLE = 'MIRACLE', + SAFE = "SAFE", + BALANCED = "BALANCED", + AGGRESSIVE = "AGGRESSIVE", + VALUE = "VALUE", + MIRACLE = "MIRACLE", } export class AnalyzeMatchDto { - @ApiProperty({ description: 'Match ID to analyze' }) + @ApiProperty({ description: "Match ID to analyze" }) @IsString() @IsNotEmpty() matchId: string; @@ -28,8 +28,8 @@ export class AnalyzeMatchDto { export class DailyBankoDto { @ApiPropertyOptional({ - description: 'Optional match IDs — system fetches if empty', - example: ['match-1', 'match-2'], + description: "Optional match IDs — system fetches if empty", + example: ["match-1", "match-2"], }) @IsOptional() @IsArray() @@ -40,8 +40,8 @@ export class DailyBankoDto { export class SuggestCouponDto { @ApiPropertyOptional({ - description: 'Match IDs — system fetches if empty', - example: ['match-1', 'match-2'], + description: "Match IDs — system fetches if empty", + example: ["match-1", "match-2"], }) @IsOptional() @IsArray() @@ -57,7 +57,7 @@ export class SuggestCouponDto { @IsEnum(CouponStrategyEnum) strategy?: CouponStrategyEnum; - @ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 }) + @ApiPropertyOptional({ description: "Maximum matches in coupon", example: 5 }) @IsOptional() @IsNumber() @Min(1) @@ -65,7 +65,7 @@ export class SuggestCouponDto { maxMatches?: number; @ApiPropertyOptional({ - description: 'Minimum confidence threshold (0-100)', + description: "Minimum confidence threshold (0-100)", example: 60, }) @IsOptional() diff --git a/src/modules/coupons/services/smart-coupon.service.ts b/src/modules/coupons/services/smart-coupon.service.ts index 240cf8b..d515630 100755 --- a/src/modules/coupons/services/smart-coupon.service.ts +++ b/src/modules/coupons/services/smart-coupon.service.ts @@ -1,10 +1,10 @@ -import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; -import axios from 'axios'; -import { GeminiService } from '../../gemini/gemini.service'; +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 type PredictionRiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME"; +export type PredictionDataQuality = "HIGH" | "MEDIUM" | "LOW"; +export type BetGrade = "A" | "B" | "C" | "PASS"; export interface PredictionPickRow { market: string; @@ -128,7 +128,7 @@ export class SmartCouponService { private readonly aiEngineUrl: string; constructor(private readonly geminiService: GeminiService) { - this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000'; + this.aiEngineUrl = process.env.AI_ENGINE_URL || "http://ai-engine:8000"; } async analyzeMatch(matchId: string): Promise { @@ -147,7 +147,7 @@ export class SmartCouponService { ); } throw new HttpException( - 'AI analyze failed', + "AI analyze failed", HttpStatus.SERVICE_UNAVAILABLE, ); } @@ -168,7 +168,7 @@ export class SmartCouponService { const result = await this.geminiService.generateText( JSON.stringify(prediction, null, 2), { - model: 'gemini-2.0-flash', + model: "gemini-2.0-flash", temperature: 0.7, maxTokens: 600, systemPrompt: MATCH_COMMENTARY_SYSTEM_PROMPT, @@ -176,7 +176,7 @@ export class SmartCouponService { ); return result.text || null; } catch (error) { - this.logger.warn('AI commentary generation failed, skipping', error); + this.logger.warn("AI commentary generation failed, skipping", error); return null; } } @@ -188,7 +188,7 @@ export class SmartCouponService { return null; } - return this.getSmartCoupon(matchIds, 'SAFE', { + return this.getSmartCoupon(matchIds, "SAFE", { maxMatches: 2, minConfidence: 78, }); @@ -197,11 +197,11 @@ export class SmartCouponService { async getSmartCoupon( matchIds: string[], strategy: - | 'SAFE' - | 'BALANCED' - | 'AGGRESSIVE' - | 'VALUE' - | 'MIRACLE' = 'BALANCED', + | "SAFE" + | "BALANCED" + | "AGGRESSIVE" + | "VALUE" + | "MIRACLE" = "BALANCED", options: { maxMatches?: number; minConfidence?: number } = {}, ): Promise { try { @@ -216,7 +216,7 @@ export class SmartCouponService { ); return response.data; } catch (error) { - this.logger.error('Failed to generate smart coupon', 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( @@ -225,7 +225,7 @@ export class SmartCouponService { ); } throw new HttpException( - 'Coupon generation failed', + "Coupon generation failed", HttpStatus.SERVICE_UNAVAILABLE, ); } diff --git a/src/modules/coupons/services/user-coupon.service.ts b/src/modules/coupons/services/user-coupon.service.ts index 0eb4be2..4c9a975 100755 --- a/src/modules/coupons/services/user-coupon.service.ts +++ b/src/modules/coupons/services/user-coupon.service.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { PrismaService } from '../../../database/prisma.service'; -import { User, UserCoupon, Match } from '@prisma/client'; +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' @@ -39,7 +39,7 @@ export class UserCouponService { strategy: dto.strategy, totalOdds: parseFloat(totalOdds.toFixed(2)), isPublic: dto.isPublic || false, - status: 'PENDING', + status: "PENDING", couponItems: { create: dto.items.map((item) => ({ matchId: item.matchId, @@ -66,7 +66,7 @@ export class UserCouponService { async updatePendingCoupons(): Promise { // Sadece bitmiş (FT) maçları içeren PENDING kuponları çek const pendingCoupons = await this.prisma.userCoupon.findMany({ - where: { status: 'PENDING' }, + where: { status: "PENDING" }, include: { couponItems: { include: { match: true }, @@ -80,7 +80,7 @@ export class UserCouponService { let allMatchesFinished = true; for (const item of coupon.couponItems) { - if (item.match.status !== 'FT') { + if (item.match.status !== "FT") { allMatchesFinished = false; break; // Henüz bitmemiş maç var, kuponu güncelleme } @@ -104,12 +104,12 @@ export class UserCouponService { if (isCouponLost) { await this.prisma.userCoupon.update({ where: { id: coupon.id }, - data: { status: 'LOST' }, + data: { status: "LOST" }, }); } else if (allMatchesFinished && isCouponWon) { await this.prisma.userCoupon.update({ where: { id: coupon.id }, - data: { status: 'WON' }, + data: { status: "WON" }, }); } } @@ -125,23 +125,23 @@ export class UserCouponService { const total = home + away; switch (selection) { - case 'MS 1': + case "MS 1": return home > away; - case 'MS X': + case "MS X": return home === away; - case 'MS 2': + case "MS 2": return away > home; - case '1.5 UST': + case "1.5 UST": return total > 1.5; - case '2.5 UST': + case "2.5 UST": return total > 2.5; - case '3.5 UST': + case "3.5 UST": return total > 3.5; - case '2.5 ALT': + case "2.5 ALT": return total < 2.5; - case 'KG VAR': + case "KG VAR": return home > 0 && away > 0; - case 'KG YOK': + case "KG YOK": return home === 0 || away === 0; default: return false; // Bilinmeyen market @@ -155,7 +155,7 @@ export class UserCouponService { const coupons = await this.prisma.userCoupon.findMany({ where: { userId, - status: { in: ['WON', 'LOST'] }, + status: { in: ["WON", "LOST"] }, }, }); @@ -171,7 +171,7 @@ export class UserCouponService { }; } - const wonCoupons = coupons.filter((c) => c.status === 'WON'); + 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; diff --git a/src/modules/feeder/feeder-persistence.service.ts b/src/modules/feeder/feeder-persistence.service.ts index 8a12ad3..0a707ac 100755 --- a/src/modules/feeder/feeder-persistence.service.ts +++ b/src/modules/feeder/feeder-persistence.service.ts @@ -5,8 +5,8 @@ * Database operations using Prisma */ -import { Injectable, Logger } from '@nestjs/common'; -import { PrismaService } from '../../database/prisma.service'; +import { Injectable, Logger } from "@nestjs/common"; +import { PrismaService } from "../../database/prisma.service"; import { Sport, MatchSummary, @@ -20,8 +20,8 @@ import { DbEventPayload, DbMarketPayload, BasketballTeamStats, -} from './feeder.types'; -import { ImageUtils } from '../../common/utils/image.util'; +} from "./feeder.types"; +import { ImageUtils } from "../../common/utils/image.util"; @Injectable() export class FeederPersistenceService { @@ -33,7 +33,7 @@ export class FeederPersistenceService { // HELPER FUNCTIONS // ============================================ private safeString(value: any): string | null { - return value === null || value === undefined || value === '' + return value === null || value === undefined || value === "" ? null : String(value); } @@ -51,12 +51,12 @@ export class FeederPersistenceService { 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'; + 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; } @@ -93,7 +93,7 @@ export class FeederPersistenceService { } for (const s of market.selectionCollection) { - if (!s || s.odd === '-' || s.odd === '') continue; + if (!s || s.odd === "-" || s.odd === "") continue; const sName = this.safeString(s.name); const sValue = this.safeString(s.odd); @@ -107,7 +107,7 @@ export class FeederPersistenceService { if (existingSel) { if (existingSel.oddValue !== sValue) { - const oldVal = parseFloat(existingSel.oddValue || '0'); + const oldVal = parseFloat(existingSel.oddValue || "0"); const newVal = parseFloat(sValue); if (!isNaN(oldVal) && !isNaN(newVal)) { @@ -182,13 +182,13 @@ export class FeederPersistenceService { const teamsToUpsert = [ { id: homeTeamId, - name: matchSummary.homeTeam?.name || 'Unknown', + name: matchSummary.homeTeam?.name || "Unknown", slug: matchSummary.homeTeam?.slug || homeTeamId, sport: sport, }, { id: awayTeamId, - name: matchSummary.awayTeam?.name || 'Unknown', + name: matchSummary.awayTeam?.name || "Unknown", slug: matchSummary.awayTeam?.slug || awayTeamId, sport: sport, }, @@ -221,18 +221,18 @@ export class FeederPersistenceService { update: {}, create: { id: countryId, - name: league.country.name || 'Unknown', + name: league.country.name || "Unknown", }, }); } catch (error: any) { - if (error.code !== 'P2002') throw error; + 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'; + const leagueName = league.name || "Unknown"; // Check if league exists by unique constraint (name + country + sport) const existingLeague = await tx.league.findUnique({ @@ -311,32 +311,32 @@ export class FeederPersistenceService { headerData?.htScoreAway ?? this.safeInt(matchSummary.score?.ht?.away); - let status = 'NS'; + let status = "NS"; if (headerData?.matchStatus) { if ( - headerData.matchStatus === 'postGame' || - headerData.matchStatus === 'post' + headerData.matchStatus === "postGame" || + headerData.matchStatus === "post" ) { - status = 'FT'; + status = "FT"; } else if ( - headerData.matchStatus === 'live' || - headerData.matchStatus === 'liveGame' + headerData.matchStatus === "live" || + headerData.matchStatus === "liveGame" ) { - status = 'LIVE'; + status = "LIVE"; } } // Handle Postponed Matches (ERT) - if (matchSummary.statusBoxContent === 'ERT') { - status = 'POSTPONED'; + if (matchSummary.statusBoxContent === "ERT") { + status = "POSTPONED"; } if ( - status === 'NS' && + status === "NS" && finalScoreHome !== null && finalScoreAway !== null ) { - status = 'FT'; + status = "FT"; } await tx.match.upsert({ @@ -455,7 +455,7 @@ export class FeederPersistenceService { } // 8. Save Team Stats (Football) - if (stats && sport === 'football') { + if (stats && sport === "football") { const statsRows = [ { matchId, @@ -499,7 +499,7 @@ export class FeederPersistenceService { } // 8b. Save Team Stats (Basketball) - if (basketballTeamStats && sport === 'basketball') { + if (basketballTeamStats && sport === "basketball") { const teams = [ { id: homeTeamId, data: basketballTeamStats.home }, { id: awayTeamId, data: basketballTeamStats.away }, @@ -558,7 +558,7 @@ export class FeederPersistenceService { } // 8c. Save Player Stats (Basketball) - if (basketballPlayerStats.length > 0 && sport === 'basketball') { + if (basketballPlayerStats.length > 0 && sport === "basketball") { await tx.basketballPlayerStats.deleteMany({ where: { matchId } }); for (const p of basketballPlayerStats) { @@ -592,12 +592,12 @@ export class FeederPersistenceService { await this.saveOddsInTransaction(tx, matchId, oddsArray); // 10. Save Officials - if (sport === 'football' && officialsData.length > 0) { + 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 roleName = o.role || "Referee"; const uniqueKey = `${o.name}_${roleName}`; if (processedOfficials.has(uniqueKey)) continue; @@ -798,10 +798,10 @@ export class FeederPersistenceService { const history = await this.prisma.match.findMany({ where: { OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }], - status: 'FT', + status: "FT", mstUtc: { lt: match.mstUtc }, }, - orderBy: { mstUtc: 'desc' }, + orderBy: { mstUtc: "desc" }, take: 5, }); @@ -840,8 +840,8 @@ export class FeederPersistenceService { return { match_id: match.id, - home_team: match.homeTeam?.name || 'Unknown', - away_team: match.awayTeam?.name || 'Unknown', + 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, @@ -934,7 +934,7 @@ export class FeederPersistenceService { scoreHome: liveMatch.scoreHome, scoreAway: liveMatch.scoreAway, mstUtc: liveMatch.mstUtc, - sport: liveMatch.sport || 'football', + sport: liveMatch.sport || "football", }; } diff --git a/src/modules/feeder/feeder-scraper.service.ts b/src/modules/feeder/feeder-scraper.service.ts index a72ba5f..9505cb2 100755 --- a/src/modules/feeder/feeder-scraper.service.ts +++ b/src/modules/feeder/feeder-scraper.service.ts @@ -3,9 +3,9 @@ * 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 { Injectable, Logger } from "@nestjs/common"; +import axios, { AxiosInstance } from "axios"; +import * as cheerio from "cheerio"; import { Sport, SPORTS_CONFIG, @@ -25,7 +25,7 @@ import { SidelinedResponse, SidelinedTeamData, SidelinedPlayer, -} from './feeder.types'; +} from "./feeder.types"; @Injectable() export class FeederScraperService { @@ -43,13 +43,13 @@ export class FeederScraperService { this.axios.interceptors.response.use( (response) => { this.logger.debug( - `✅ [${response.config.url?.split('?')[0]}] Status: ${response.status}`, + `✅ [${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'; + 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; }, @@ -72,7 +72,7 @@ export class FeederScraperService { const response = await this.axios.get(url, { params: { - 'sports[]': sportParam, + "sports[]": sportParam, matchDate: dateString, }, }); @@ -80,11 +80,11 @@ export class FeederScraperService { const payload = response.data as unknown; if ( !payload || - typeof payload !== 'object' || - !('status' in payload) || - !('data' in payload) + typeof payload !== "object" || + !("status" in payload) || + !("data" in payload) ) { - throw new Error('Historical source payload has invalid shape'); + throw new Error("Historical source payload has invalid shape"); } return payload as LivescoresApiResponse; @@ -101,14 +101,14 @@ export class FeederScraperService { const response = await this.axios.get(url, { params: { matchId, - sdapiLanguageCode: 'tr-mk', - ajaxViewName: 'match-details', - ajaxPartialViewName: 'match-details-status', - displayMode: 'all', + sdapiLanguageCode: "tr-mk", + ajaxViewName: "match-details", + ajaxPartialViewName: "match-details-status", + displayMode: "all", }, }); - return this.parseMatchHeader(response.data.data?.html || ''); + return this.parseMatchHeader(response.data.data?.html || ""); } private parseMatchHeader(html: string): ParsedMatchHeader { @@ -116,7 +116,7 @@ export class FeederScraperService { // Extract match-status from data attribute const matchStatus = - ($('[data-match-status]').attr('data-match-status') as any) || 'postGame'; + ($("[data-match-status]").attr("data-match-status") as any) || "postGame"; // Extract scores const scoreHome = this.safeInt($('[data-slot="score-home"]').text().trim()); @@ -126,7 +126,7 @@ export class FeederScraperService { let htScoreHome: number | null = null; let htScoreAway: number | null = null; - const detailedScore = $('.p0c-soccer-match-details-header__detailed-score') + const detailedScore = $(".p0c-soccer-match-details-header__detailed-score") .text() .trim(); const htMatch = detailedScore.match(/\(İY\s*(\d+)\s*-\s*(\d+)\)/); @@ -143,7 +143,7 @@ export class FeederScraperService { // ============================================ async fetchKeyEvents( matchId: string, - ): Promise { + ): Promise { const url = `https://www.mackolik.com/ajax/football/key-events`; this.logger.debug(`📡 [${matchId}] Fetching key events`); @@ -151,7 +151,7 @@ export class FeederScraperService { try { const response = await this.axios.get(url, { params: { - ajaxViewName: 'events', + ajaxViewName: "events", matchId, seasonId: matchId, // Same as matchId }, @@ -172,7 +172,7 @@ export class FeederScraperService { // ============================================ async fetchStartingFormation( matchId: string, - ): Promise { + ): Promise { const url = `https://www.mackolik.com/ajax/football/match-stats`; this.logger.debug(`📡 [${matchId}] Fetching starting formation`); @@ -180,7 +180,7 @@ export class FeederScraperService { try { const response = await this.axios.get(url, { params: { - ajaxViewName: 'starting-formation', + ajaxViewName: "starting-formation", matchId, seasonId: matchId, }, @@ -201,7 +201,7 @@ export class FeederScraperService { // ============================================ async fetchSubstitutions( matchId: string, - ): Promise { + ): Promise { const url = `https://www.mackolik.com/ajax/football/match-stats`; this.logger.debug(`📡 [${matchId}] Fetching substitutions`); @@ -209,7 +209,7 @@ export class FeederScraperService { try { const response = await this.axios.get(url, { params: { - ajaxViewName: 'substitutions', + ajaxViewName: "substitutions", matchId, seasonId: matchId, }, @@ -230,7 +230,7 @@ export class FeederScraperService { // ============================================ async fetchGameStats( matchId: string, - ): Promise { + ): Promise { const url = `https://www.mackolik.com/ajax/soccer/match/gameStats`; this.logger.debug(`📡 [${matchId}] Fetching game stats`); @@ -253,7 +253,7 @@ export class FeederScraperService { // ============================================ // MANAGER // ============================================ - async fetchManager(matchId: string): Promise { + async fetchManager(matchId: string): Promise { const url = `https://www.mackolik.com/ajax/football/match-stats`; this.logger.debug(`📡 [${matchId}] Fetching manager`); @@ -261,7 +261,7 @@ export class FeederScraperService { try { const response = await this.axios.get(url, { params: { - ajaxViewName: 'manager', + ajaxViewName: "manager", matchId, seasonId: matchId, }, @@ -287,10 +287,10 @@ export class FeederScraperService { try { const response = await this.axios.get(url, { - params: { template: 'all' }, + params: { template: "all" }, }); - return this.parseIddaaMarketsHtml(response.data.data?.html || ''); + return this.parseIddaaMarketsHtml(response.data.data?.html || ""); } catch (error: any) { if (error.response?.status === 404) { this.logger.warn(`[${matchId}] Iddaa markets not found (404)`); @@ -306,30 +306,30 @@ export class FeederScraperService { const $ = cheerio.load(html); const markets: ParsedMarket[] = []; - $('.widget-iddaa-markets__market-item').each((_, marketEl) => { + $(".widget-iddaa-markets__market-item").each((_, marketEl) => { const $market = $(marketEl); - const marketId = $market.attr('data-market') || ''; + const marketId = $market.attr("data-market") || ""; const marketName = $market - .find('.widget-iddaa-markets__header-text') + .find(".widget-iddaa-markets__header-text") .text() .trim(); const iddaaCode = $market - .find('.widget-iddaa-markets__iddaa-code') + .find(".widget-iddaa-markets__iddaa-code") .text() .trim(); - const mbc = $market.find('.widget-iddaa-markets__mbc').text().trim(); + const mbc = $market.find(".widget-iddaa-markets__mbc").text().trim(); const selections: ParsedSelection[] = []; - $market.find('.widget-iddaa-markets__option').each((_, optionEl) => { + $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(), + 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(), }); }); @@ -347,7 +347,7 @@ export class FeederScraperService { // ============================================ async fetchBasketballBoxScore( matchId: string, - ): Promise { + ): Promise { // Updated URL based on user request const url = `https://www.mackolik.com/ajax/basketball/match/box-score`; @@ -357,8 +357,8 @@ export class FeederScraperService { const response = await this.axios.get(url, { params: { matchId }, headers: { - 'X-Requested-With': 'XMLHttpRequest', - 'User-Agent': DEFAULT_HEADERS['User-Agent'], + "X-Requested-With": "XMLHttpRequest", + "User-Agent": DEFAULT_HEADERS["User-Agent"], }, }); @@ -382,25 +382,25 @@ export class FeederScraperService { const players: Partial[] = []; // Parse individual players from widget rows - $('.widget-basketball-match-box-score__row').each((_, elem) => { + $(".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'); + 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'); + 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'); + let playerId = ""; + const link = nameElem.find("a").attr("href"); if (link) { - playerId = this.extractPlayerIdFromUrl(link) || ''; + playerId = this.extractPlayerIdFromUrl(link) || ""; } players.push({ @@ -410,16 +410,16 @@ export class FeederScraperService { 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, + fgMade: this.safeInt(values.eq(5).text().trim().split("/")[0]) || 0, fgAttempted: - this.safeInt(values.eq(5).text().trim().split('/')[1]) || 0, + this.safeInt(values.eq(5).text().trim().split("/")[1]) || 0, threePtMade: - this.safeInt(values.eq(6).text().trim().split('/')[0]) || 0, + 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, + 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, + 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, @@ -428,7 +428,7 @@ export class FeederScraperService { }); // Parse Team Totals from Footer - const footerRow = $('.widget-basketball-match-box-score__footer td'); + const footerRow = $(".widget-basketball-match-box-score__footer td"); let teamTotals: any = {}; if (footerRow.length > 5) { @@ -438,16 +438,16 @@ export class FeederScraperService { 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, + fgMade: this.safeInt(footerRow.eq(5).text().trim().split("/")[0]) || 0, fgAttempted: - this.safeInt(footerRow.eq(5).text().trim().split('/')[1]) || 0, + this.safeInt(footerRow.eq(5).text().trim().split("/")[1]) || 0, threePtMade: - this.safeInt(footerRow.eq(6).text().trim().split('/')[0]) || 0, + 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, + 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, + 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, @@ -474,11 +474,11 @@ export class FeederScraperService { // 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'], + "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', + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", // NO X-Requested-With for HTML pages! }, }); @@ -507,8 +507,8 @@ export class FeederScraperService { const response = await this.axios.get(url, { params: { matchId }, headers: { - 'X-Requested-With': 'XMLHttpRequest', - 'User-Agent': DEFAULT_HEADERS['User-Agent'], + "X-Requested-With": "XMLHttpRequest", + "User-Agent": DEFAULT_HEADERS["User-Agent"], }, }); @@ -532,12 +532,12 @@ export class FeederScraperService { const $ = cheerio.load(html); const rows = $( - '.widget-basketball-match-details-header__score-details tbody tr', + ".widget-basketball-match-details-header__score-details tbody tr", ); if (rows.length < 2) return null; const parseRow = (row: any) => { - const cols = $(row).find('td'); + 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. @@ -545,7 +545,7 @@ export class FeederScraperService { const getScore = (index: number) => { const cell = cols.eq(index); const part = cell.find( - '.widget-basketball-match-details-header__score-part', + ".widget-basketball-match-details-header__score-part", ); const val = part.length ? part.text() : cell.text(); return this.safeInt(val.trim()); @@ -580,10 +580,10 @@ export class FeederScraperService { try { const response = await this.axios.get(url, { - params: { template: 'all' }, + params: { template: "all" }, headers: { - 'X-Requested-With': 'XMLHttpRequest', - 'User-Agent': DEFAULT_HEADERS['User-Agent'], + "X-Requested-With": "XMLHttpRequest", + "User-Agent": DEFAULT_HEADERS["User-Agent"], }, }); @@ -602,7 +602,7 @@ export class FeederScraperService { extractPlayerIdFromUrl(url: string | undefined): string | null { if (!url) return null; - const parts = url.split('/'); + const parts = url.split("/"); return parts[parts.length - 1] || null; } @@ -620,12 +620,12 @@ export class FeederScraperService { 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', + "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', + "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, }); @@ -652,24 +652,24 @@ export class FeederScraperService { $: cheerio.CheerioAPI, teamIndex: number, ): SidelinedTeamData { - const sidelinedWidgets = $('.widget-sidelined-players'); + const sidelinedWidgets = $(".widget-sidelined-players"); if (sidelinedWidgets.length <= teamIndex) { - return { teamName: '', teamId: '', totalSidelined: 0, players: [] }; + 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 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') + .find(".widget-sidelined-players__header-text") .text() .trim(); const players: SidelinedPlayer[] = []; - widget.find('.widget-sidelined-players__item').each((_, element) => { + widget.find(".widget-sidelined-players__item").each((_, element) => { const playerData = this._parsePlayerItem($, $(element)); if (playerData) { players.push(playerData); @@ -689,44 +689,44 @@ export class FeederScraperService { $item: cheerio.Cheerio, ): SidelinedPlayer | null { try { - const nameElem = $item.find('.widget-sidelined-players__name'); + const nameElem = $item.find(".widget-sidelined-players__name"); const playerName = nameElem.text().trim(); - const playerUrl = nameElem.attr('href') || ''; - const playerId = playerUrl.split('/').pop() || ''; + const playerUrl = nameElem.attr("href") || ""; + const playerId = playerUrl.split("/").pop() || ""; - const positionElem = $item.find('.widget-sidelined-players__position'); - const position = positionElem.attr('title') || ''; + 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 reasonImg = $item.find(".widget-sidelined-players__reason img"); + const reasonIcon = reasonImg.attr("src") || ""; - const numbers = $item.find('.widget-sidelined-players__number'); + 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() : ''; + 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 averageText = numbers.length > 1 ? numbers.eq(1).text().trim() : ""; const average = averageText ? parseInt(averageText, 10) : null; const description = $item - .find('.widget-sidelined-players__value') + .find(".widget-sidelined-players__value") .text() .trim(); - const type = reasonIcon.includes('shortage_1.png') - ? 'injury' - : reasonIcon.includes('suspension') - ? 'suspension' - : 'other'; + const type = reasonIcon.includes("shortage_1.png") + ? "injury" + : reasonIcon.includes("suspension") + ? "suspension" + : "other"; return { playerId, playerName, - playerUrl: playerUrl.startsWith('http') + playerUrl: playerUrl.startsWith("http") ? playerUrl : `https://www.mackolik.com${playerUrl}`, position, @@ -735,7 +735,7 @@ export class FeederScraperService { description, matchesMissed: isNaN(matchesMissed as number) ? null : matchesMissed, average: isNaN(average as number) ? null : average, - reasonIcon: reasonIcon.startsWith('http') + reasonIcon: reasonIcon.startsWith("http") ? reasonIcon : `https://www.mackolik.com${reasonIcon}`, // Keep safer URL construction but stick closer to logic }; diff --git a/src/modules/feeder/feeder-transformer.service.ts b/src/modules/feeder/feeder-transformer.service.ts index 864368b..d3e4a69 100755 --- a/src/modules/feeder/feeder-transformer.service.ts +++ b/src/modules/feeder/feeder-transformer.service.ts @@ -3,8 +3,8 @@ * Transforms raw API data into database-ready formats */ -import { Injectable, Logger } from '@nestjs/common'; -import * as cheerio from 'cheerio'; +import { Injectable, Logger } from "@nestjs/common"; +import * as cheerio from "cheerio"; import { RawKeyEvent, TransformedEvent, @@ -18,7 +18,7 @@ import { GameStatsResponse, DbEventPayload, DbMarketPayload, -} from './feeder.types'; +} from "./feeder.types"; @Injectable() export class FeederTransformerService { @@ -28,7 +28,7 @@ export class FeederTransformerService { // HELPER FUNCTIONS // ============================================ private safeString(value: any): string | null { - return value === null || value === undefined || value === '' + return value === null || value === undefined || value === "" ? null : String(value); } @@ -45,7 +45,7 @@ export class FeederTransformerService { private extractPlayerIdFromUrl(url: string | undefined): string | null { if (!url) return null; - const parts = url.split('/'); + const parts = url.split("/"); return parts[parts.length - 1] || null; } @@ -59,7 +59,7 @@ export class FeederTransformerService { matchId: string, ): TransformedEvent[] { return rawEvents.map((e) => { - const playerId = this.extractPlayerIdFromUrl(e.playerUrl) || ''; + const playerId = this.extractPlayerIdFromUrl(e.playerUrl) || ""; const assistPlayerId = e.assistPlayerUrl ? this.extractPlayerIdFromUrl(e.assistPlayerUrl) : null; @@ -68,16 +68,16 @@ export class FeederTransformerService { : 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'; + 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, + teamId: e.position === "home" ? homeTeamId : awayTeamId, eventType, eventSubtype: e.subType || null, timeMinute: e.timeMin, @@ -136,7 +136,7 @@ export class FeederTransformerService { // GAME STATS TRANSFORMER // ============================================ transformGameStats( - data: GameStatsResponse['data'] | null, + data: GameStatsResponse["data"] | null, ): TransformedMatchStats | null { if (!data || !data.home) return null; @@ -173,20 +173,20 @@ export class FeederTransformerService { // MATCH STATE TO STATUS MAPPER // ============================================ mapMatchStateToStatus(state: MatchState | undefined): string { - if (!state) return 'NS'; + if (!state) return "NS"; switch (state) { - case 'postGame': - case 'post': - return 'FT'; - case 'preGame': - case 'pre': - return 'NS'; - case 'live': - case 'liveGame': - return 'LIVE'; + case "postGame": + case "post": + return "FT"; + case "preGame": + case "pre": + return "NS"; + case "live": + case "liveGame": + return "LIVE"; default: - return 'NS'; + return "NS"; } } @@ -200,28 +200,28 @@ export class FeederTransformerService { const officials: MatchOfficial[] = []; // Try standard officials component - $('.p0c-match-officials__official-list-item').each((_, elem) => { + $(".p0c-match-officials__official-list-item").each((_, elem) => { const name = $(elem) - .find('.p0c-match-officials__official-name') + .find(".p0c-match-officials__official-name") .text() .trim(); const role = $(elem) - .find('.p0c-match-officials__official-group-title') + .find(".p0c-match-officials__official-group-title") .text() .trim(); if (name) { - officials.push({ name, role: role || 'Referee' }); + 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) => { + $(".widget-match-info__referee-name, .referee-name").each((_, elem) => { const name = $(elem).text().trim(); if (name) { - officials.push({ name, role: 'Referee' }); + officials.push({ name, role: "Referee" }); } }); } @@ -331,8 +331,8 @@ export class FeederTransformerService { ( e, ): e is TransformedEvent & { - eventType: 'goal' | 'card' | 'substitute'; - } => e.eventType !== 'other' && !!e.playerId, + eventType: "goal" | "card" | "substitute"; + } => e.eventType !== "other" && !!e.playerId, ) .map((e) => ({ match_id: e.matchId, @@ -354,6 +354,6 @@ export class FeederTransformerService { // BASKETBALL PLAYER ID GENERATOR // ============================================ generateBasketballPlayerId(teamId: string, playerName: string): string { - return `${teamId}-${playerName.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}`; + 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 index 466df67..9b908eb 100755 --- a/src/modules/feeder/feeder.module.ts +++ b/src/modules/feeder/feeder.module.ts @@ -2,12 +2,12 @@ * 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'; +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], diff --git a/src/modules/feeder/feeder.service.ts b/src/modules/feeder/feeder.service.ts index e49c705..e62fbca 100755 --- a/src/modules/feeder/feeder.service.ts +++ b/src/modules/feeder/feeder.service.ts @@ -3,10 +3,10 @@ * 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 { 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, @@ -23,7 +23,7 @@ import { ParsedMarket, DbEventPayload, DbMarketPayload, -} from './feeder.types'; +} from "./feeder.types"; interface ProcessDateOptions { onlyCompletedMatches?: boolean; @@ -37,10 +37,10 @@ export class FeederService { // 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 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'; + private readonly DAILY_SYNC_TIME_ZONE = "Europe/Istanbul"; constructor( private readonly scraperService: FeederScraperService, @@ -56,38 +56,38 @@ export class FeederService { } private getYesterdayDateString(timeZone: string): string { - const formatter = new Intl.DateTimeFormat('en-CA', { + const formatter = new Intl.DateTimeFormat("en-CA", { timeZone, - year: 'numeric', - month: '2-digit', - day: '2-digit', + 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 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]; + return tzMidnightUtc.toISOString().split("T")[0]; } private getTimeZoneOffsetMs(date: Date, timeZone: string): number { - const formatter = new Intl.DateTimeFormat('en-US', { + const formatter = new Intl.DateTimeFormat("en-US", { timeZone, - timeZoneName: 'shortOffset', + timeZoneName: "shortOffset", }); const offsetLabel = - formatter.formatToParts(date).find((part) => part.type === 'timeZoneName') - ?.value || 'GMT+0'; + 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'); + 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; } @@ -96,17 +96,14 @@ export class FeederService { dateString: string, timeZone: string, ): { startTs: number; endTs: number } { - const [year, month, day] = dateString.split('-').map(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 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 startMs = Date.UTC(year, month - 1, day, 0, 0, 0) - startOffsetMs; const nextDayStartMs = Date.UTC(year, month - 1, day + 1, 0, 0, 0) - nextDayOffsetMs; @@ -117,35 +114,39 @@ export class FeederService { } private parseScoreValue(value: unknown): number | null { - if (value === null || value === undefined || value === '') return 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; + if (match.statusBoxContent === "ERT") return false; - const normalizedState = String(match.state || '') + const normalizedState = String(match.state || "") .trim() .toLowerCase(); - const normalizedStatus = String(match.status || '') + const normalizedStatus = String(match.status || "") .trim() .toLowerCase(); - const normalizedSubstate = String(match.substate || '') + const normalizedSubstate = String(match.substate || "") .trim() .toLowerCase(); - if (['postgame', 'post'].includes(normalizedState)) return true; + if (["postgame", "post"].includes(normalizedState)) return true; if ( - ['played', 'finished', 'ft', 'afterpenalties', 'penalties'].includes( + ["played", "finished", "ft", "afterpenalties", "penalties"].includes( normalizedStatus, ) ) { return true; } - if (['postgame', 'post', 'played', 'finished', 'ft'].includes(normalizedSubstate)) { + if ( + ["postgame", "post", "played", "finished", "ft"].includes( + normalizedSubstate, + ) + ) { return true; } @@ -167,7 +168,7 @@ export class FeederService { targetLeagueIds: string[] = [], ): Promise { this.logger.log( - `🗓️ STARTING DAILY COMPLETED MATCH SYNC [Date: ${targetDateStr}] [Sports: ${sports.join(', ')}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ''}`, + `🗓️ STARTING DAILY COMPLETED MATCH SYNC [Date: ${targetDateStr}] [Sports: ${sports.join(", ")}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ""}`, ); for (const sport of sports) { @@ -191,7 +192,7 @@ export class FeederService { targetLeagueIds: string[] = [], // NEW: Optional league filter ): Promise { this.logger.log( - `🚀 STARTING HISTORICAL SCAN [Target: ${sports.join(', ')}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ''}`, + `🚀 STARTING HISTORICAL SCAN [Target: ${sports.join(", ")}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ""}`, ); const startDate = new Date(startDateStr); @@ -201,7 +202,7 @@ export class FeederService { // 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`; + const stateKey = `historical_scan_state_${sports.join("_")}${targetLeagueIds.length > 0 ? "_filtered" : ""}_desc`; let currentDate: Date | null = null; // Resume from saved state @@ -215,12 +216,12 @@ export class FeederService { // 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]}`, + `📍 Resuming from: ${currentDate.toISOString().split("T")[0]}`, ); } } } catch { - this.logger.warn('Could not read state, starting from beginning'); + this.logger.warn("Could not read state, starting from beginning"); } // Initialize currentDate to endDate if not resuming (or if resume failed) @@ -231,7 +232,7 @@ export class FeederService { } this.logger.log( - `📊 Scanning (Reverse): ${currentDate.toISOString().split('T')[0]} ← ${startDate.toISOString().split('T')[0]}`, + `📊 Scanning (Reverse): ${currentDate.toISOString().split("T")[0]} ← ${startDate.toISOString().split("T")[0]}`, ); let processedDays = 0; @@ -239,7 +240,7 @@ export class FeederService { // REVERSE LOOP: Iterate while currentDate is greater than or equal to startDate while (currentDate >= startDate) { - const dateString = currentDate.toISOString().split('T')[0]; + const dateString = currentDate.toISOString().split("T")[0]; for (const sport of sports) { await this.processDate(dateString, sport, targetLeagueIds); @@ -278,7 +279,7 @@ export class FeederService { currentDate.setDate(currentDate.getDate() - 1); } - this.logger.log('🎉 HISTORICAL SCAN COMPLETED'); + this.logger.log("🎉 HISTORICAL SCAN COMPLETED"); } // ============================================ @@ -308,9 +309,9 @@ export class FeederService { break; // Success, exit loop } catch (e: any) { const is502 = - e.message?.includes('502') || + e.message?.includes("502") || e.response?.status === 502 || - e.message?.includes('Bad Gateway'); + e.message?.includes("Bad Gateway"); if (is502 && i < 2) { this.logger.warn( @@ -341,10 +342,7 @@ export class FeederService { // 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, - ); + this.getDayBoundsForTimeZone(dateString, this.DAILY_SYNC_TIME_ZONE); const dateFilteredMatches = allMatches.filter((m) => { const matchTs = m.mstUtc; @@ -518,14 +516,14 @@ export class FeederService { // ============================================ async refreshMatch( matchId: string, - scope: 'all' | 'lineups' | 'odds' = 'all', + 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' }; + return { success: false, retryable: false, error: "Match not found" }; } // Construct MatchSummary from DB record @@ -538,13 +536,13 @@ export class FeederService { iddaaCode: matchRecord.iddaaCode, homeTeam: { id: matchRecord.homeTeamId, - name: matchRecord.homeTeam?.name || '', - slug: matchRecord.homeTeam?.slug || '', + name: matchRecord.homeTeam?.name || "", + slug: matchRecord.homeTeam?.slug || "", }, awayTeam: { id: matchRecord.awayTeamId, - name: matchRecord.awayTeam?.name || '', - slug: matchRecord.awayTeam?.slug || '', + name: matchRecord.awayTeam?.name || "", + slug: matchRecord.awayTeam?.slug || "", }, score: { home: matchRecord.scoreHome, @@ -555,9 +553,9 @@ export class FeederService { const dummyCompetitions: Record = { [summary.competitionId]: { id: summary.competitionId, - name: 'Unknown', - competitionSlug: '', - country: { id: '', name: '' }, + name: "Unknown", + competitionSlug: "", + country: { id: "", name: "" }, }, }; @@ -583,7 +581,7 @@ export class FeederService { competitions: Record, sport: Sport, force: boolean = false, - scope: 'all' | 'lineups' | 'odds' = 'all', // Add scope flag + scope: "all" | "lineups" | "odds" = "all", // Add scope flag ): Promise { const matchId = matchSummary.id; const homeTeamId = matchSummary.homeTeam?.id; @@ -595,7 +593,7 @@ export class FeederService { } // Skip postponed matches (ERT = Erteledendi) - if (matchSummary.statusBoxContent === 'ERT') { + if (matchSummary.statusBoxContent === "ERT") { this.logger.debug(`[${matchId}] Skipped: Postponed match (ERT)`); return { success: false, retryable: false }; } @@ -615,9 +613,9 @@ export class FeederService { return await fn(); } catch (e: any) { const is502 = - e.message?.includes('502') || + e.message?.includes("502") || e.response?.status === 502 || - e.message?.includes('Bad Gateway'); + e.message?.includes("Bad Gateway"); if (i === retries - 1) throw e; // Last attempt failed @@ -661,44 +659,44 @@ export class FeederService { // 1. Fetch Match Header (score, status) let headerData: ParsedMatchHeader | null = null; - if (scope === 'all') { + if (scope === "all") { try { - headerData = await fetchResilient('Header', () => + headerData = await fetchResilient("Header", () => this.scraperService.fetchMatchHeader(matchId), ); } catch (e: any) { - if (e.message?.includes('502')) hasCriticalError = true; + if (e.message?.includes("502")) hasCriticalError = true; this.logger.warn(`[${matchId}] Header fetch failed: ${e.message}`); } } // 2. Sport-specific data fetching - if (sport === 'basketball') { + if (sport === "basketball") { // Basketball: Box Score (Always if all or lineups) - if (scope === 'all' || scope === 'lineups') { + if (scope === "all" || scope === "lineups") { try { - const boxData = await fetchResilient('BoxScore', () => + const boxData = await fetchResilient("BoxScore", () => this.scraperService.fetchBasketballBoxScore(matchId), ); if (boxData) { const homeParsed = this.scraperService.parseBasketballBoxScore( - boxData.views?.home?.html || '', + boxData.views?.home?.html || "", ); const awayParsed = this.scraperService.parseBasketballBoxScore( - boxData.views?.away?.html || '', + boxData.views?.away?.html || "", ); basketballTeamStats = - scope === 'all' + scope === "all" ? { home: homeParsed.teamTotals, away: awayParsed.teamTotals, } : null; - if (scope === 'all') { + if (scope === "all") { try { - const details = await fetchResilient('QuarterScores', () => + const details = await fetchResilient("QuarterScores", () => this.scraperService.fetchBasketballDetailsHeader(matchId), ); if (details && basketballTeamStats) { @@ -712,7 +710,7 @@ export class FeederService { }; } } catch (e: any) { - if (e.message?.includes('502')) hasCriticalError = true; + if (e.message?.includes("502")) hasCriticalError = true; this.logger.warn( `[${matchId}] Quarter scores fetch failed: ${e.message}`, ); @@ -748,7 +746,7 @@ export class FeederService { processPlayers(awayParsed, awayTeamId); } } catch (e: any) { - if (e.message?.includes('502')) hasCriticalError = true; + if (e.message?.includes("502")) hasCriticalError = true; this.logger.warn(`[${matchId}] Box score failed: ${e.message}`); } } @@ -756,9 +754,9 @@ export class FeederService { // Football: Events, Lineups, Stats, Officials // Key Events - if (scope === 'all') { + if (scope === "all") { try { - const eventsData = await fetchResilient('Events', () => + const eventsData = await fetchResilient("Events", () => this.scraperService.fetchKeyEvents(matchId), ); if (eventsData?.keyEvents) { @@ -781,7 +779,7 @@ export class FeederService { ); } } catch (e: any) { - if (e.message?.includes('502')) hasCriticalError = true; + if (e.message?.includes("502")) hasCriticalError = true; this.logger.warn(`[${matchId}] Events failed: ${e.message}`); } @@ -850,20 +848,20 @@ export class FeederService { */ // Game Stats & Officials - if (scope === 'all') { + if (scope === "all") { try { - const gameStats = await fetchResilient('Stats', () => + const gameStats = await fetchResilient("Stats", () => this.scraperService.fetchGameStats(matchId), ); stats = this.transformerService.transformGameStats(gameStats); } catch (e: any) { - if (e.message?.includes('502')) hasCriticalError = true; + 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', () => + const matchPageHtml = await fetchResilient("Officials", () => this.scraperService.fetchMatchPage( matchId, matchSummary.matchSlug, @@ -875,7 +873,7 @@ export class FeederService { this.transformerService.parseOfficials(matchPageHtml); } } catch (e: any) { - if (e.message?.includes('502')) hasCriticalError = true; + if (e.message?.includes("502")) hasCriticalError = true; this.logger.warn(`[${matchId}] Officials failed: ${e.message}`); } } @@ -883,31 +881,31 @@ export class FeederService { // 3. Fetch Iddaa Odds (Always if all or odds) let oddsArray: DbMarketPayload[] = []; - if (scope === 'all' || scope === 'odds') { + if (scope === "all" || scope === "odds") { try { let markets: ParsedMarket[] = []; - if (sport === 'basketball') { + if (sport === "basketball") { markets = - ((await fetchResilient('BucketOdds', () => + ((await fetchResilient("BucketOdds", () => this.scraperService.fetchBasketballMarkets(matchId), )) as ParsedMarket[]) || []; } else { markets = - ((await fetchResilient('IddaaOdds', () => + ((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; + 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') { + if (scope === "lineups") { saved = await this.persistenceService.saveLineups( matchId, playersMap, @@ -915,7 +913,7 @@ export class FeederService { homeTeamId, awayTeamId, ); - } else if (scope === 'odds') { + } else if (scope === "odds") { saved = await this.persistenceService.saveOdds(matchId, oddsArray); } else { // Full Update @@ -962,12 +960,12 @@ export class FeederService { 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'); + 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.`, + `[${matchId}] Saved with MISSING DATA (502). Missing: [${missingParts.join(", ")}]. Scheduled for retry.`, ); return { success: false, retryable: true }; } @@ -975,12 +973,12 @@ export class FeederService { 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 + 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`); diff --git a/src/modules/feeder/feeder.types.ts b/src/modules/feeder/feeder.types.ts index 086f3ba..03fff71 100755 --- a/src/modules/feeder/feeder.types.ts +++ b/src/modules/feeder/feeder.types.ts @@ -6,27 +6,27 @@ // ============================================ // SPORT TYPES // ============================================ -export type Sport = 'football' | 'basketball'; +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' }, + football: { sportParam: "Soccer", iddaaUrlPath: "mac" }, + basketball: { sportParam: "Basketball", iddaaUrlPath: "basketbol/mac" }, }; // ============================================ // MATCH STATUS TYPES // ============================================ -export type MatchStatus = 'Cancelled' | 'Played' | 'Playing' | 'Scheduled'; +export type MatchStatus = "Cancelled" | "Played" | "Playing" | "Scheduled"; export type MatchState = - | 'preGame' - | 'postGame' - | 'live' - | 'liveGame' - | 'pre' - | 'post'; + | "preGame" + | "postGame" + | "live" + | "liveGame" + | "pre" + | "post"; // ============================================ // LIVESCORES API RESPONSE @@ -115,9 +115,9 @@ export interface KeyEventsResponse { } export interface RawKeyEvent { - type: 'goal' | 'card' | 'substitute' | 'penalty-missed'; - subType: 'goal' | 'penalty-goal' | 'yc' | 'rc' | 'pm' | 'ps' | null; - position: 'home' | 'away'; + 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; @@ -135,7 +135,7 @@ export interface TransformedEvent { playerId: string; playerName: string; teamId: string; - eventType: 'goal' | 'card' | 'substitute' | 'other'; + eventType: "goal" | "card" | "substitute" | "other"; eventSubtype: string | null; timeMinute: string; timeSeconds: number | null; @@ -145,7 +145,7 @@ export interface TransformedEvent { scoreAfter: string | null; playerOutId: string | null; playerOutName: string | null; - position: 'home' | 'away'; + position: "home" | "away"; } // ============================================ @@ -170,18 +170,18 @@ export interface RawPlayerStats { personId: string; matchName: string; shirtNumber: number | null; - position: 'goalkeeper' | 'defender' | 'midfielder' | 'striker' | 'Coach' | ''; + position: "goalkeeper" | "defender" | "midfielder" | "striker" | "Coach" | ""; events: PlayerEvent[] | null; } export interface PlayerEvent { name: - | 'goal' - | 'yellow-card' - | 'red-card' - | 'sub-off' - | 'sub-on' - | 'penalty-missed'; + | "goal" + | "yellow-card" + | "red-card" + | "sub-off" + | "sub-on" + | "penalty-missed"; timeMin: string; count: number; } @@ -270,7 +270,7 @@ export interface IddaaMarket { export interface IddaaOutcome { outcome: string; // The odds value (e.g., "1.78") handicap: string | null; - state: 'active' | 'suspended'; + state: "active" | "suspended"; label: string; // "1", "X", "2", "Alt", "Üst", etc. } @@ -371,7 +371,7 @@ export interface DbEventPayload { match_id: string; player_id: string; team_id: string; - event_type: 'goal' | 'card' | 'substitute'; + event_type: "goal" | "card" | "substitute"; event_subtype: string | null; time_minute: string; time_seconds: number | null; @@ -379,7 +379,7 @@ export interface DbEventPayload { assist_player_id: string | null; score_after: string | null; player_out_id: string | null; - position: 'home' | 'away'; + position: "home" | "away"; } export interface DbMarketSelectionPayload { @@ -402,74 +402,74 @@ export interface DbMarketPayload { // ============================================ 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', + "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', + "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ı', + "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', + "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', + "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', + "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', + "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', + "40": "Deplasman İki Yarıyı da Kazanır", }; // ============================================ @@ -477,20 +477,20 @@ export const MARKET_MAPPING: Record = { // ============================================ export interface AxiosRequestConfig { headers: { - 'User-Agent': string; + "User-Agent": string; Referer: string; - 'X-Requested-With': string; - 'Accept-Language'?: 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', + "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; @@ -516,7 +516,7 @@ export interface SidelinedPlayer { playerUrl: string; position: string; positionShort: string; - type: 'injury' | 'suspension' | 'other'; + type: "injury" | "suspension" | "other"; description: string; matchesMissed: number | null; average: number | null; diff --git a/src/modules/gemini/gemini.config.ts b/src/modules/gemini/gemini.config.ts index c35e34b..21ae665 100755 --- a/src/modules/gemini/gemini.config.ts +++ b/src/modules/gemini/gemini.config.ts @@ -1,7 +1,7 @@ -import { registerAs } from '@nestjs/config'; +import { registerAs } from "@nestjs/config"; -export const geminiConfig = registerAs('gemini', () => ({ - enabled: process.env.ENABLE_GEMINI === 'true', +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', + 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 index 65ccd5a..6573b8c 100755 --- a/src/modules/gemini/gemini.module.ts +++ b/src/modules/gemini/gemini.module.ts @@ -1,7 +1,7 @@ -import { Module, Global } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { GeminiService } from './gemini.service'; -import { geminiConfig } from './gemini.config'; +import { Module, Global } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { GeminiService } from "./gemini.service"; +import { geminiConfig } from "./gemini.config"; /** * Gemini AI Module diff --git a/src/modules/gemini/gemini.service.ts b/src/modules/gemini/gemini.service.ts index ab53287..b4388ff 100755 --- a/src/modules/gemini/gemini.service.ts +++ b/src/modules/gemini/gemini.service.ts @@ -1,6 +1,6 @@ -import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { GoogleGenAI } from '@google/genai'; +import { Injectable, OnModuleInit, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { GoogleGenAI } from "@google/genai"; export interface GeminiGenerateOptions { model?: string; @@ -10,7 +10,7 @@ export interface GeminiGenerateOptions { } export interface GeminiChatMessage { - role: 'user' | 'model'; + role: "user" | "model"; content: string; } @@ -48,34 +48,34 @@ export class GeminiService implements OnModuleInit { private defaultModel: string; constructor(private readonly configService: ConfigService) { - this.isEnabled = this.configService.get('gemini.enabled', false); + this.isEnabled = this.configService.get("gemini.enabled", false); this.defaultModel = this.configService.get( - 'gemini.defaultModel', - 'gemini-2.5-flash', + "gemini.defaultModel", + "gemini-2.5-flash", ); } onModuleInit() { if (!this.isEnabled) { this.logger.log( - 'Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.', + "Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.", ); return; } - const apiKey = this.configService.get('gemini.apiKey'); + const apiKey = this.configService.get("gemini.apiKey"); if (!apiKey) { this.logger.warn( - 'GOOGLE_API_KEY is not set. Gemini features will not work.', + "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'); + this.logger.log("✅ Gemini AI initialized successfully"); } catch (error) { - this.logger.error('Failed to initialize Gemini AI', error); + this.logger.error("Failed to initialize Gemini AI", error); } } @@ -98,7 +98,7 @@ export class GeminiService implements OnModuleInit { options: GeminiGenerateOptions = {}, ): Promise<{ text: string; usage?: any }> { if (!this.isAvailable()) { - throw new Error('Gemini AI is not available. Check your configuration.'); + throw new Error("Gemini AI is not available. Check your configuration."); } const model = options.model || this.defaultModel; @@ -109,17 +109,17 @@ export class GeminiService implements OnModuleInit { // Add system prompt if provided if (options.systemPrompt) { contents.push({ - role: 'user', + role: "user", parts: [{ text: options.systemPrompt }], }); contents.push({ - role: 'model', - parts: [{ text: 'Understood. I will follow these instructions.' }], + role: "model", + parts: [{ text: "Understood. I will follow these instructions." }], }); } contents.push({ - role: 'user', + role: "user", parts: [{ text: prompt }], }); @@ -133,11 +133,11 @@ export class GeminiService implements OnModuleInit { }); return { - text: (response.text || '').trim(), + text: (response.text || "").trim(), usage: response.usageMetadata, }; } catch (error) { - this.logger.error('Gemini generation failed', error); + this.logger.error("Gemini generation failed", error); throw error; } } @@ -154,7 +154,7 @@ export class GeminiService implements OnModuleInit { options: GeminiGenerateOptions = {}, ): Promise<{ text: string; usage?: any }> { if (!this.isAvailable()) { - throw new Error('Gemini AI is not available. Check your configuration.'); + throw new Error("Gemini AI is not available. Check your configuration."); } const model = options.model || this.defaultModel; @@ -169,12 +169,12 @@ export class GeminiService implements OnModuleInit { if (options.systemPrompt) { contents.unshift( { - role: 'user', + role: "user", parts: [{ text: options.systemPrompt }], }, { - role: 'model', - parts: [{ text: 'Understood. I will follow these instructions.' }], + role: "model", + parts: [{ text: "Understood. I will follow these instructions." }], }, ); } @@ -189,11 +189,11 @@ export class GeminiService implements OnModuleInit { }); return { - text: (response.text || '').trim(), + text: (response.text || "").trim(), usage: response.usageMetadata, }; } catch (error) { - this.logger.error('Gemini chat failed', error); + this.logger.error("Gemini chat failed", error); throw error; } } @@ -233,8 +233,8 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`; 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'); + 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 index bb5f856..edea857 100755 --- a/src/modules/gemini/index.ts +++ b/src/modules/gemini/index.ts @@ -1,3 +1,3 @@ -export * from './gemini.module'; -export * from './gemini.service'; -export * from './gemini.config'; +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 index a7f43ee..3b4e206 100755 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -1,15 +1,15 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +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'; +} from "@nestjs/terminus"; +import { Public } from "../../common/decorators"; +import { PrismaService } from "../../database/prisma.service"; -@ApiTags('Health') -@Controller('health') +@ApiTags("Health") +@Controller("health") export class HealthController { constructor( private health: HealthCheckService, @@ -20,25 +20,25 @@ export class HealthController { @Get() @Public() @HealthCheck() - @ApiOperation({ summary: 'Basic health check' }) + @ApiOperation({ summary: "Basic health check" }) check() { return this.health.check([]); } - @Get('ready') + @Get("ready") @Public() @HealthCheck() - @ApiOperation({ summary: 'Readiness check (includes database)' }) + @ApiOperation({ summary: "Readiness check (includes database)" }) readiness() { return this.health.check([ - () => this.prismaHealth.pingCheck('database', this.prisma), + () => this.prismaHealth.pingCheck("database", this.prisma), ]); } - @Get('live') + @Get("live") @Public() - @ApiOperation({ summary: 'Liveness check' }) + @ApiOperation({ summary: "Liveness check" }) liveness() { - return { status: 'ok', timestamp: new Date().toISOString() }; + return { status: "ok", timestamp: new Date().toISOString() }; } } diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts index 64848b0..0d85f39 100755 --- a/src/modules/health/health.module.ts +++ b/src/modules/health/health.module.ts @@ -1,7 +1,7 @@ -import { Module } from '@nestjs/common'; -import { TerminusModule } from '@nestjs/terminus'; -import { PrismaHealthIndicator } from '@nestjs/terminus'; -import { HealthController } from './health.controller'; +import { Module } from "@nestjs/common"; +import { TerminusModule } from "@nestjs/terminus"; +import { PrismaHealthIndicator } from "@nestjs/terminus"; +import { HealthController } from "./health.controller"; @Module({ imports: [TerminusModule], diff --git a/src/modules/leagues/leagues.controller.ts b/src/modules/leagues/leagues.controller.ts index d83dd34..c93dd13 100755 --- a/src/modules/leagues/leagues.controller.ts +++ b/src/modules/leagues/leagues.controller.ts @@ -4,20 +4,20 @@ import { Param, Query, NotFoundException, -} from '@nestjs/common'; +} 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'; +} from "@nestjs/swagger"; +import { LeaguesService } from "./leagues.service"; +import { Sport } from "@prisma/client"; +import { Public } from "../../common/decorators"; -@ApiTags('Leagues') -@Controller('leagues') +@ApiTags("Leagues") +@Controller("leagues") export class LeaguesController { constructor(private readonly leaguesService: LeaguesService) {} @@ -25,10 +25,10 @@ export class LeaguesController { * GET /leagues/countries * Get all countries */ - @Get('countries') + @Get("countries") @Public() - @ApiOperation({ summary: 'Get all countries' }) - @ApiResponse({ status: 200, description: 'List of countries' }) + @ApiOperation({ summary: "Get all countries" }) + @ApiResponse({ status: 200, description: "List of countries" }) async getCountries() { return this.leaguesService.findAllCountries(); } @@ -37,13 +37,13 @@ export class LeaguesController { * GET /leagues/countries/:id * Get country by ID with leagues */ - @Get('countries/:id') + @Get("countries/:id") @Public() - @ApiOperation({ summary: 'Get country by ID with leagues' }) - @ApiParam({ name: 'id', description: 'Country ID' }) - async getCountryById(@Param('id') id: string) { + @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'); + if (!country) throw new NotFoundException("Country not found"); return country; } @@ -53,13 +53,13 @@ export class LeaguesController { */ @Get() @Public() - @ApiOperation({ summary: 'Get all leagues' }) + @ApiOperation({ summary: "Get all leagues" }) @ApiQuery({ - name: 'sport', + name: "sport", required: false, - enum: ['football', 'basketball'], + enum: ["football", "basketball"], }) - async getLeagues(@Query('sport') sport?: string) { + async getLeagues(@Query("sport") sport?: string) { return this.leaguesService.findAllLeagues(sport as Sport); } @@ -68,21 +68,21 @@ export class LeaguesController { * Get head-to-head matches between two teams * NOTE: Must come before /teams/:id to avoid route conflict */ - @Get('teams/h2h') + @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 }) + @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, + @Query("team1") team1: string, + @Query("team2") team2: string, + @Query("limit") limit?: string, ) { return this.leaguesService.getHeadToHead( team1, team2, - parseInt(limit || '10', 10), + parseInt(limit || "10", 10), ); } @@ -90,16 +90,16 @@ export class LeaguesController { * GET /leagues/teams/search * Search teams by name */ - @Get('teams/search') + @Get("teams/search") @Public() - @ApiOperation({ summary: 'Search teams by name' }) - @ApiQuery({ name: 'q', required: true, description: 'Search query' }) + @ApiOperation({ summary: "Search teams by name" }) + @ApiQuery({ name: "q", required: true, description: "Search query" }) @ApiQuery({ - name: 'sport', + name: "sport", required: false, - enum: ['football', 'basketball'], + enum: ["football", "basketball"], }) - async searchTeams(@Query('q') query: string, @Query('sport') sport?: string) { + async searchTeams(@Query("q") query: string, @Query("sport") sport?: string) { return this.leaguesService.searchTeams(query, sport as Sport); } @@ -107,13 +107,13 @@ export class LeaguesController { * GET /leagues/teams/:id * Get team by ID */ - @Get('teams/:id') + @Get("teams/:id") @Public() - @ApiOperation({ summary: 'Get team by ID' }) - @ApiParam({ name: 'id', description: 'Team ID' }) - async getTeamById(@Param('id') id: string) { + @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'); + if (!team) throw new NotFoundException("Team not found"); return team; } @@ -121,18 +121,18 @@ export class LeaguesController { * GET /leagues/teams/:id/matches * Get team's recent matches */ - @Get('teams/:id/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 }) + @ApiParam({ name: "id", description: "Team ID" }) + @ApiQuery({ name: "limit", required: false, type: Number }) async getTeamMatches( - @Param('id') id: string, - @Query('limit') limit?: string, + @Param("id") id: string, + @Query("limit") limit?: string, ) { return this.leaguesService.getTeamRecentMatches( id, - parseInt(limit || '10', 10), + parseInt(limit || "10", 10), ); } @@ -140,13 +140,13 @@ export class LeaguesController { * GET /leagues/:id * Get league by ID */ - @Get(':id') + @Get(":id") @Public() - @ApiOperation({ summary: 'Get league by ID' }) - @ApiParam({ name: 'id', description: 'League ID' }) - async getLeagueById(@Param('id') id: string) { + @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'); + 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 index 2b5be9b..9451747 100755 --- a/src/modules/leagues/leagues.module.ts +++ b/src/modules/leagues/leagues.module.ts @@ -1,7 +1,7 @@ -import { Module } from '@nestjs/common'; -import { LeaguesController } from './leagues.controller'; -import { LeaguesService } from './leagues.service'; -import { DatabaseModule } from '../../database/database.module'; +import { Module } from "@nestjs/common"; +import { LeaguesController } from "./leagues.controller"; +import { LeaguesService } from "./leagues.service"; +import { DatabaseModule } from "../../database/database.module"; @Module({ imports: [DatabaseModule], diff --git a/src/modules/leagues/leagues.service.ts b/src/modules/leagues/leagues.service.ts index a484c19..02fa315 100755 --- a/src/modules/leagues/leagues.service.ts +++ b/src/modules/leagues/leagues.service.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { PrismaService } from '../../database/prisma.service'; -import { Sport } from '@prisma/client'; +import { Injectable, Logger } from "@nestjs/common"; +import { PrismaService } from "../../database/prisma.service"; +import { Sport } from "@prisma/client"; @Injectable() export class LeaguesService { @@ -13,7 +13,7 @@ export class LeaguesService { */ async findAllCountries() { return this.prisma.country.findMany({ - orderBy: { name: 'asc' }, + orderBy: { name: "asc" }, }); } @@ -34,7 +34,7 @@ export class LeaguesService { return this.prisma.league.findMany({ where: sport ? { sport } : undefined, include: { country: true }, - orderBy: { name: 'asc' }, + orderBy: { name: "asc" }, }); } @@ -58,7 +58,7 @@ export class LeaguesService { ...(sport ? { sport } : {}), }, include: { country: true }, - orderBy: { name: 'asc' }, + orderBy: { name: "asc" }, }); } @@ -69,9 +69,9 @@ export class LeaguesService { return this.prisma.team.findMany({ where: { ...(sport ? { sport } : {}), - ...(search ? { name: { contains: search, mode: 'insensitive' } } : {}), + ...(search ? { name: { contains: search, mode: "insensitive" } } : {}), }, - orderBy: { name: 'asc' }, + orderBy: { name: "asc" }, take: 100, }); } @@ -91,7 +91,7 @@ export class LeaguesService { async searchTeams(name: string, sport?: Sport) { return this.prisma.team.findMany({ where: { - name: { contains: name, mode: 'insensitive' }, + name: { contains: name, mode: "insensitive" }, ...(sport ? { sport } : {}), }, take: 20, @@ -111,7 +111,7 @@ export class LeaguesService { awayTeam: true, league: { include: { country: true } }, }, - orderBy: { mstUtc: 'desc' }, + orderBy: { mstUtc: "desc" }, take: limit, }); } @@ -126,14 +126,14 @@ export class LeaguesService { { homeTeamId: teamId1, awayTeamId: teamId2 }, { homeTeamId: teamId2, awayTeamId: teamId1 }, ], - state: 'postGame', // Finished matches are stored as "postGame" + state: "postGame", // Finished matches are stored as "postGame" }, include: { homeTeam: true, awayTeam: true, league: true, }, - orderBy: { mstUtc: 'desc' }, + orderBy: { mstUtc: "desc" }, take: limit, }); diff --git a/src/modules/matches/dto/index.ts b/src/modules/matches/dto/index.ts index a704c33..f227758 100755 --- a/src/modules/matches/dto/index.ts +++ b/src/modules/matches/dto/index.ts @@ -8,21 +8,21 @@ import { Max, IsArray, ValidateNested, -} from 'class-validator'; -import { Type } from 'class-transformer'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +} from "class-validator"; +import { Type } from "class-transformer"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; export enum Sport { - FOOTBALL = 'football', - BASKETBALL = 'basketball', + FOOTBALL = "football", + BASKETBALL = "basketball", } export class OddFilterDto { - @ApiProperty({ example: 'Maç Sonucu' }) + @ApiProperty({ example: "Maç Sonucu" }) @IsString() categoryName: string; - @ApiProperty({ example: '1' }) + @ApiProperty({ example: "1" }) @IsString() selectionName: string; @@ -39,10 +39,10 @@ export class TeamFilterDto { @IsString() id: string; - @ApiPropertyOptional({ enum: ['home', 'away', 'any'] }) + @ApiPropertyOptional({ enum: ["home", "away", "any"] }) @IsOptional() @IsString() - role?: 'home' | 'away' | 'any'; + role?: "home" | "away" | "any"; } export class DateRangeDto { @@ -73,13 +73,13 @@ export class MatchQueryDto { leagueId?: string; @ApiPropertyOptional({ - description: 'Filter by status: LIVE, Finished, etc.', + description: "Filter by status: LIVE, Finished, etc.", }) @IsOptional() @IsString() status?: string; - @ApiPropertyOptional({ description: 'Single date filter (YYYY-MM-DD)' }) + @ApiPropertyOptional({ description: "Single date filter (YYYY-MM-DD)" }) @IsOptional() @IsDateString() date?: string; @@ -153,7 +153,7 @@ export class MatchResponseDto { @ApiPropertyOptional() countryName?: string; - @ApiPropertyOptional({ type: 'array' }) + @ApiPropertyOptional({ type: "array" }) odds?: any[]; } diff --git a/src/modules/matches/matches.controller.ts b/src/modules/matches/matches.controller.ts index 642d2d7..580a988 100755 --- a/src/modules/matches/matches.controller.ts +++ b/src/modules/matches/matches.controller.ts @@ -10,26 +10,26 @@ import { NotFoundException, BadRequestException, UseInterceptors, -} from '@nestjs/common'; -import { Public } from '../../common/decorators'; +} 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'; +} from "@nestjs/swagger"; +import { CacheInterceptor, CacheTTL } from "@nestjs/cache-manager"; +import { MatchesService } from "./matches.service"; import { MatchQueryDto, Sport, LeagueWithMatchesDto, ActiveLeagueDto, -} from './dto'; +} from "./dto"; -@ApiTags('Matches') -@Controller('matches') +@ApiTags("Matches") +@Controller("matches") export class MatchesController { constructor(private readonly matchesService: MatchesService) {} @@ -38,9 +38,9 @@ export class MatchesController { * Advanced match query with filters */ @Public() - @Post('query') + @Post("query") @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Advanced match query with filters' }) + @ApiOperation({ summary: "Advanced match query with filters" }) @ApiResponse({ status: 200, type: [LeagueWithMatchesDto] }) async queryMatches( @Body() queryDto: MatchQueryDto, @@ -67,18 +67,18 @@ export class MatchesController { */ @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' }) + @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, + @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 pageNum = parseInt(page || "1", 10); + const limitNum = parseInt(limit || "20", 10); const sportType = sport || Sport.FOOTBALL; return this.matchesService.listMatches(sportType, pageNum, limitNum); @@ -89,14 +89,14 @@ export class MatchesController { * Get active leagues with match counts */ @Public() - @Get('leagues/active') + @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 }) + @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, + @Query("sport") sport?: Sport, ): Promise { return this.matchesService.getActiveLeagues(sport || Sport.FOOTBALL); } @@ -106,23 +106,23 @@ export class MatchesController { * Get full match details */ @Public() - @Get(':id') - @ApiOperation({ summary: 'Get full match details by ID' }) - @ApiParam({ name: 'id', description: 'Match ID' }) + @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', + description: "Match details with lineups, stats, odds, events", }) - @ApiResponse({ status: 404, description: 'Match not found' }) - async getMatchDetails(@Param('id') id: string) { + @ApiResponse({ status: 404, description: "Match not found" }) + async getMatchDetails(@Param("id") id: string) { if (!id) { - throw new BadRequestException('Match ID is required'); + throw new BadRequestException("Match ID is required"); } const match = await this.matchesService.getMatchDetailsById(id); if (!match) { - throw new NotFoundException('Match not found'); + throw new NotFoundException("Match not found"); } return match; diff --git a/src/modules/matches/matches.module.ts b/src/modules/matches/matches.module.ts index 684d625..9e35b6a 100755 --- a/src/modules/matches/matches.module.ts +++ b/src/modules/matches/matches.module.ts @@ -1,7 +1,7 @@ -import { Module } from '@nestjs/common'; -import { MatchesController } from './matches.controller'; -import { MatchesService } from './matches.service'; -import { DatabaseModule } from '../../database/database.module'; +import { Module } from "@nestjs/common"; +import { MatchesController } from "./matches.controller"; +import { MatchesService } from "./matches.service"; +import { DatabaseModule } from "../../database/database.module"; @Module({ imports: [DatabaseModule], diff --git a/src/modules/matches/matches.service.ts b/src/modules/matches/matches.service.ts index 56072e3..7503b85 100755 --- a/src/modules/matches/matches.service.ts +++ b/src/modules/matches/matches.service.ts @@ -1,14 +1,14 @@ -import { Injectable, Logger } from '@nestjs/common'; -import * as fs from 'fs'; -import * as path from 'path'; -import { PrismaService } from '../../database/prisma.service'; +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'; +} from "./dto"; +import { Prisma } from "@prisma/client"; @Injectable() export class MatchesService { @@ -21,9 +21,9 @@ export class MatchesService { private loadTopLeagues() { try { - const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json'); + const topLeaguesPath = path.join(process.cwd(), "top_leagues.json"); if (fs.existsSync(topLeaguesPath)) { - this.topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8')); + this.topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8")); this.logger.log( `Loaded ${this.topLeagueIds.length} top leagues for filtering.`, ); @@ -39,22 +39,22 @@ export class MatchesService { { status: { in: [ - 'LIVE', - '1H', - '2H', - 'HT', - '1Q', - '2Q', - '3Q', - '4Q', - 'Playing', - 'Half Time', + "LIVE", + "1H", + "2H", + "HT", + "1Q", + "2Q", + "3Q", + "4Q", + "Playing", + "Half Time", ], }, }, { state: { - in: ['live', 'firsthalf', 'secondhalf'], + in: ["live", "firsthalf", "secondhalf"], }, }, ], @@ -66,12 +66,12 @@ export class MatchesService { OR: [ { status: { - in: ['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'], + in: ["Finished", "Played", "FT", "AET", "PEN", "Ended"], }, }, { state: { - in: ['Finished', 'post', 'FT', 'postGame'], + in: ["Finished", "post", "FT", "postGame"], }, }, ], @@ -134,16 +134,16 @@ export class MatchesService { if (leagueId) { where.leagueId = leagueId; - } else if (status === 'LIVE' && this.topLeagueIds.length > 0) { + } 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') { + if (status === "LIVE") { andConditions.push(this.getLiveFilter()); - } else if (status === 'UPCOMING' || status === 'NOT_STARTED') { + } else if (status === "UPCOMING" || status === "NOT_STARTED") { andConditions.push(this.getUpcomingFilter(Date.now())); - } else if (status === 'FINISHED') { + } else if (status === "FINISHED") { andConditions.push(this.getFinishedFilter()); } else if (status) { where.status = status; @@ -170,9 +170,9 @@ export class MatchesService { // Team filter if (team) { - if (team.role === 'home') { + if (team.role === "home") { where.homeTeamId = team.id; - } else if (team.role === 'away') { + } else if (team.role === "away") { where.awayTeamId = team.id; } else { andConditions.push({ @@ -197,7 +197,7 @@ export class MatchesService { const matches = await this.prisma.liveMatch.findMany({ where, select: { id: true }, - orderBy: { mstUtc: 'asc' }, // Sort by nearest match first + orderBy: { mstUtc: "asc" }, // Sort by nearest match first take: limit, }); @@ -220,7 +220,7 @@ export class MatchesService { AND: [this.getUpcomingFilter(Date.now())], }, select: { id: true }, - orderBy: { mstUtc: 'asc' }, + orderBy: { mstUtc: "asc" }, take: limit, }); console.log( @@ -283,16 +283,16 @@ export class MatchesService { const leaguesMap = new Map(); for (const match of matches) { - const leagueId = match.leagueId || 'unknown'; + const leagueId = match.leagueId || "unknown"; if (!leaguesMap.has(leagueId)) { leaguesMap.set(leagueId, { id: leagueId, - name: match.league?.name || 'Unknown League', + name: match.league?.name || "Unknown League", code: match.league?.code || undefined, country: { - id: match.league?.country?.id || '', - name: match.league?.country?.name || '', + id: match.league?.country?.id || "", + name: match.league?.country?.name || "", flagUrl: match.league?.country?.flagUrl || undefined, }, sport: sport, @@ -306,13 +306,13 @@ export class MatchesService { const structuredOdds: any[] = []; if ( match.odds && - typeof match.odds === 'object' && + 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') { + if (selections && typeof selections === "object") { for (const [selName, selOdd] of Object.entries(selections)) { structuredSelections[selName] = { odd: String(selOdd) }; } @@ -325,15 +325,15 @@ export class MatchesService { } // Map status for frontend - let displayStatus = match.status || 'NS'; - if (match.state === 'live') { - displayStatus = 'LIVE'; + let displayStatus = match.status || "NS"; + if (match.state === "live") { + displayStatus = "LIVE"; } else if ( - match.state === 'post' || - match.state === 'FT' || - match.status === 'Finished' + match.state === "post" || + match.state === "FT" || + match.status === "Finished" ) { - displayStatus = 'Finished'; + displayStatus = "Finished"; } league.matches.push({ @@ -349,11 +349,11 @@ export class MatchesService { scoreAway: match.scoreAway ?? undefined, htScoreHome: undefined, // LiveMatch table doesn't have HT scores separately usually htScoreAway: undefined, - homeTeamName: match.homeTeam?.name || 'Unknown', + homeTeamName: match.homeTeam?.name || "Unknown", homeTeamLogo: match.homeTeamId ? `https://file.mackolikfeeds.com/teams/${match.homeTeamId}` : undefined, - awayTeamName: match.awayTeam?.name || 'Unknown', + awayTeamName: match.awayTeam?.name || "Unknown", awayTeamLogo: match.awayTeamId ? `https://file.mackolikfeeds.com/teams/${match.awayTeamId}` : undefined, @@ -390,15 +390,15 @@ export class MatchesService { // 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', + "Trendyol Süper Lig", + "Süper Lig", + "Trendyol 1. Lig", + "1. Lig", + "Premier Lig", + "LaLiga", + "Serie A", + "Bundesliga", + "Ligue 1", ]; return leagues @@ -410,7 +410,7 @@ export class MatchesService { const bPriority = bIdx === -1 ? 999 : bIdx; if (aPriority !== bPriority) return aPriority - bPriority; - return (a.name || '').localeCompare(b.name || ''); + return (a.name || "").localeCompare(b.name || ""); }) .map((l) => ({ id: l.id, @@ -439,7 +439,7 @@ export class MatchesService { include: { country: true }, }, }, - orderBy: { mstUtc: 'desc' }, + orderBy: { mstUtc: "desc" }, skip, take: limit, }), @@ -482,7 +482,7 @@ export class MatchesService { createdAt: stat.createdAt, }; - if ((sport || '').toLowerCase() === 'basketball') { + if ((sport || "").toLowerCase() === "basketball") { return { ...base, points: stat.points, @@ -532,7 +532,7 @@ export class MatchesService { basketballTeamStats: true, playerParticipations: { include: { player: true }, - orderBy: [{ isStarting: 'desc' }, { position: 'asc' }], + orderBy: [{ isStarting: "desc" }, { position: "asc" }], }, playerEvents: { include: { @@ -540,7 +540,7 @@ export class MatchesService { assistPlayer: true, substitutedOut: true, }, - orderBy: [{ periodId: 'asc' }, { timeMinute: 'asc' }], + orderBy: [{ periodId: "asc" }, { timeMinute: "asc" }], }, oddCategories: { include: { selections: true }, @@ -562,15 +562,15 @@ export class MatchesService { if (liveMatch) { // Map liveMatch status - let displayStatus = liveMatch.status || 'NS'; - if (liveMatch.state === 'live') { - displayStatus = 'LIVE'; + let displayStatus = liveMatch.status || "NS"; + if (liveMatch.state === "live") { + displayStatus = "LIVE"; } else if ( - liveMatch.state === 'post' || - liveMatch.state === 'FT' || - liveMatch.status === 'Finished' + liveMatch.state === "post" || + liveMatch.state === "FT" || + liveMatch.status === "Finished" ) { - displayStatus = 'Finished'; + displayStatus = "Finished"; } match = { @@ -607,14 +607,14 @@ export class MatchesService { if ( match.isLiveSource && match.odds && - typeof match.odds === 'object' && + 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') { + if (selections && typeof selections === "object") { for (const [selName, selOdd] of Object.entries(selections)) { odds[marketName][selName] = { odd: String(selOdd) }; } @@ -628,7 +628,7 @@ export class MatchesService { for (const sel of cat.selections) { if (sel.name) { odds[cat.name][sel.name] = { - odd: sel.oddValue || '', + odd: sel.oddValue || "", sov: sel.sov ?? undefined, }; } @@ -637,7 +637,7 @@ export class MatchesService { } const sportStats = - match.sport === 'basketball' + match.sport === "basketball" ? match.basketballTeamStats || [] : match.footballTeamStats || []; const normalizedTeamStats = sportStats.map((s: any) => @@ -692,7 +692,7 @@ export class MatchesService { // Fuzzy search team = await this.prisma.team.findFirst({ where: { - name: { contains: trimmedName, mode: 'insensitive' }, + name: { contains: trimmedName, mode: "insensitive" }, sport: sport as any, }, select: { id: true }, diff --git a/src/modules/predictions/dto/index.ts b/src/modules/predictions/dto/index.ts index cebf94d..016d385 100755 --- a/src/modules/predictions/dto/index.ts +++ b/src/modules/predictions/dto/index.ts @@ -1,11 +1,6 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty } from "@nestjs/swagger"; -export type SignalTier = - | 'CORE' - | 'VALUE' - | 'LEAN' - | 'LONGSHOT' - | 'PASS'; +export type SignalTier = "CORE" | "VALUE" | "LEAN" | "LONGSHOT" | "PASS"; export class MatchInfoDto { @ApiProperty() @@ -34,14 +29,14 @@ export class MatchInfoDto { @ApiProperty({ required: false, - enum: ['football', 'basketball'], + enum: ["football", "basketball"], }) - sport?: 'football' | 'basketball'; + sport?: "football" | "basketball"; } export class DataQualityDto { - @ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] }) - label: 'HIGH' | 'MEDIUM' | 'LOW'; + @ApiProperty({ enum: ["HIGH", "MEDIUM", "LOW"] }) + label: "HIGH" | "MEDIUM" | "LOW"; @ApiProperty() score: number; @@ -52,7 +47,7 @@ export class DataQualityDto { @ApiProperty() away_lineup_count: number; - @ApiProperty({ required: false, default: 'none' }) + @ApiProperty({ required: false, default: "none" }) lineup_source?: string; @ApiProperty({ type: [String] }) @@ -69,16 +64,16 @@ export class ConfidenceIntervalDto { @ApiProperty() width: number; - @ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] }) - band: 'HIGH' | 'MEDIUM' | 'LOW'; + @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({ enum: ["LOW", "MEDIUM", "HIGH", "EXTREME"] }) + level: "LOW" | "MEDIUM" | "HIGH" | "EXTREME"; @ApiProperty() score: number; @@ -156,8 +151,8 @@ export class MatchPickDto { @ApiProperty() playable: boolean; - @ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] }) - bet_grade: 'A' | 'B' | 'C' | 'PASS'; + @ApiProperty({ enum: ["A", "B", "C", "PASS"] }) + bet_grade: "A" | "B" | "C" | "PASS"; @ApiProperty() stake_units: number; @@ -170,7 +165,7 @@ export class MatchPickDto { @ApiProperty({ required: false, - enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'], + enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"], }) signal_tier?: SignalTier; } @@ -185,15 +180,15 @@ export class MatchBetAdviceDto { @ApiProperty() reason: string; - @ApiProperty({ required: false, enum: ['HIGH', 'MEDIUM', 'LOW'] }) - confidence_band?: 'HIGH' | 'MEDIUM' | 'LOW'; + @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'], + enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"], }) signal_tier?: SignalTier; } @@ -211,8 +206,8 @@ export class MatchBetSummaryItemDto { @ApiProperty() calibrated_confidence: number; - @ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] }) - bet_grade: 'A' | 'B' | 'C' | 'PASS'; + @ApiProperty({ enum: ["A", "B", "C", "PASS"] }) + bet_grade: "A" | "B" | "C" | "PASS"; @ApiProperty() playable: boolean; @@ -240,30 +235,30 @@ export class MatchBetSummaryItemDto { @ApiProperty({ required: false, - enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'], + enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"], }) signal_tier?: SignalTier; } export class HtFtPredictionDto { @ApiProperty() - '1/1': number; + "1/1": number; @ApiProperty() - '1/X': number; + "1/X": number; @ApiProperty() - '1/2': number; + "1/2": number; @ApiProperty() - 'X/1': number; + "X/1": number; @ApiProperty() - 'X/X': number; + "X/X": number; @ApiProperty() - 'X/2': number; + "X/2": number; @ApiProperty() - '2/1': number; + "2/1": number; @ApiProperty() - '2/X': number; + "2/X": number; @ApiProperty() - '2/2': number; + "2/2": number; @ApiProperty() pick: string; @ApiProperty() @@ -310,8 +305,8 @@ export class AggressivePickDto { @ApiProperty() playable: boolean; - @ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] }) - bet_grade: 'A' | 'B' | 'C' | 'PASS'; + @ApiProperty({ enum: ["A", "B", "C", "PASS"] }) + bet_grade: "A" | "B" | "C" | "PASS"; @ApiProperty() stake_units: number; @@ -468,4 +463,4 @@ export class AIHealthDto { predictionServiceReady: boolean; } -export * from './smart-coupon.dto'; +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 index 92abe7b..d768232 100644 --- a/src/modules/predictions/dto/predictions-request.dto.ts +++ b/src/modules/predictions/dto/predictions-request.dto.ts @@ -8,28 +8,28 @@ import { ArrayMaxSize, Min, Max, -} from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; export class GeneratePredictionDto { - @ApiProperty({ description: 'Match ID to generate prediction for' }) + @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', + 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'], + description: "List of match IDs for coupon", + example: ["match-1", "match-2"], }) @IsArray() @IsString({ each: true }) @@ -44,7 +44,7 @@ export class SmartCouponRequestDto { @IsEnum(CouponStrategy) strategy?: CouponStrategy; - @ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 }) + @ApiPropertyOptional({ description: "Maximum matches in coupon", example: 5 }) @IsOptional() @IsNumber() @Min(1) @@ -52,7 +52,7 @@ export class SmartCouponRequestDto { maxMatches?: number; @ApiPropertyOptional({ - description: 'Minimum confidence threshold (0-100)', + description: "Minimum confidence threshold (0-100)", example: 60, }) @IsOptional() diff --git a/src/modules/predictions/dto/smart-coupon.dto.ts b/src/modules/predictions/dto/smart-coupon.dto.ts index ddb588f..2de25b5 100755 --- a/src/modules/predictions/dto/smart-coupon.dto.ts +++ b/src/modules/predictions/dto/smart-coupon.dto.ts @@ -3,14 +3,14 @@ */ export type CouponStrategy = - | 'SAFE' - | 'BALANCED' - | 'AGGRESSIVE' - | 'VALUE' - | 'MIRACLE'; + | "SAFE" + | "BALANCED" + | "AGGRESSIVE" + | "VALUE" + | "MIRACLE"; -export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME'; -export type DataQualityLabel = 'HIGH' | 'MEDIUM' | 'LOW'; +export type RiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME"; +export type DataQualityLabel = "HIGH" | "MEDIUM" | "LOW"; export interface SmartCouponRequestDto { match_ids: string[]; diff --git a/src/modules/predictions/predictions.controller.ts b/src/modules/predictions/predictions.controller.ts index b8f207f..0ba03db 100755 --- a/src/modules/predictions/predictions.controller.ts +++ b/src/modules/predictions/predictions.controller.ts @@ -7,24 +7,24 @@ import { HttpCode, HttpStatus, NotFoundException, -} from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; -import { PredictionsService } from './predictions.service'; +} from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiResponse, ApiParam } from "@nestjs/swagger"; +import { PredictionsService } from "./predictions.service"; import { MatchPredictionDto, PredictionHistoryResponseDto, UpcomingPredictionsDto, ValueBetDto, AIHealthDto, -} from './dto'; +} from "./dto"; import { GeneratePredictionDto, SmartCouponRequestDto, -} from './dto/predictions-request.dto'; -import { Public } from 'src/common/decorators'; +} from "./dto/predictions-request.dto"; +import { Public } from "src/common/decorators"; -@ApiTags('Predictions') -@Controller('predictions') +@ApiTags("Predictions") +@Controller("predictions") export class PredictionsController { constructor(private readonly predictionsService: PredictionsService) {} @@ -32,8 +32,8 @@ export class PredictionsController { * GET /predictions/health * Check AI Engine health status */ - @Get('health') - @ApiOperation({ summary: '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(); @@ -43,8 +43,8 @@ export class PredictionsController { * GET /predictions/upcoming * Get predictions for upcoming matches */ - @Get('upcoming') - @ApiOperation({ summary: '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(); @@ -54,10 +54,10 @@ export class PredictionsController { * 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) { + @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); } @@ -65,8 +65,8 @@ export class PredictionsController { * GET /predictions/value-bets * Get EV+ betting opportunities */ - @Get('value-bets') - @ApiOperation({ summary: 'Get value betting opportunities (EV+)' }) + @Get("value-bets") + @ApiOperation({ summary: "Get value betting opportunities (EV+)" }) @ApiResponse({ status: 200, type: [ValueBetDto] }) async getValueBets(): Promise { return this.predictionsService.getValueBets(); @@ -76,8 +76,8 @@ export class PredictionsController { * GET /predictions/history * Get prediction history and accuracy stats */ - @Get('history') - @ApiOperation({ summary: 'Get prediction history and accuracy statistics' }) + @Get("history") + @ApiOperation({ summary: "Get prediction history and accuracy statistics" }) @ApiResponse({ status: 200, type: PredictionHistoryResponseDto }) async getHistory(): Promise { return this.predictionsService.getPredictionHistory(); @@ -87,14 +87,14 @@ export class PredictionsController { * GET /predictions/:matchId * Get prediction for a specific match */ - @Get(':matchId') + @Get(":matchId") @Public() - @ApiOperation({ summary: 'Get prediction for a specific match' }) - @ApiParam({ name: 'matchId', description: 'Match ID' }) + @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' }) + @ApiResponse({ status: 404, description: "Match not found" }) async getPrediction( - @Param('matchId') matchId: string, + @Param("matchId") matchId: string, ): Promise { // Check cache first const cached = await this.predictionsService.getCachedPrediction(matchId); @@ -119,9 +119,9 @@ export class PredictionsController { * POST /predictions/generate * Generate prediction with provided match data */ - @Post('generate') + @Post("generate") @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Generate prediction with provided match data' }) + @ApiOperation({ summary: "Generate prediction with provided match data" }) @ApiResponse({ status: 200, type: MatchPredictionDto }) async generatePrediction( @Body() dto: GeneratePredictionDto, @@ -131,7 +131,7 @@ export class PredictionsController { }); if (!prediction) { - throw new NotFoundException('Failed to generate prediction'); + throw new NotFoundException("Failed to generate prediction"); } return prediction; @@ -141,19 +141,19 @@ export class PredictionsController { * POST /predictions/smart-coupon * Generate Smart Coupon using AI Engine V20 */ - @Post('smart-coupon') + @Post("smart-coupon") @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'Generate Smart Coupon with V20 AI recommendations', + summary: "Generate Smart Coupon with V20 AI recommendations", }) @ApiResponse({ status: 200, - description: 'Smart coupon generated successfully', + description: "Smart coupon generated successfully", }) async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise { const coupon = await this.predictionsService.getSmartCoupon( dto.matchIds, - dto.strategy || 'BALANCED', + dto.strategy || "BALANCED", { maxMatches: dto.maxMatches, minConfidence: dto.minConfidence, @@ -161,7 +161,7 @@ export class PredictionsController { ); if (!coupon) { - throw new NotFoundException('Failed to generate Smart 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 index 3cfe60b..dc1efd2 100755 --- a/src/modules/predictions/predictions.module.ts +++ b/src/modules/predictions/predictions.module.ts @@ -1,17 +1,17 @@ -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'; +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'; +const redisEnabled = process.env.REDIS_ENABLED === "true"; @Module({ imports: [ diff --git a/src/modules/predictions/predictions.service.ts b/src/modules/predictions/predictions.service.ts index f60a0aa..1e76ddf 100755 --- a/src/modules/predictions/predictions.service.ts +++ b/src/modules/predictions/predictions.service.ts @@ -6,26 +6,26 @@ import { 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'; +} 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'; +} 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'; +type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW"; interface ConfidenceInterval { lower: number; @@ -47,73 +47,72 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { 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_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', + "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', + "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', + "Skor modeli alt seçeneğiyle çelişiyor", score_model_conflicts_with_over_pick: - 'Skor modeli üst seçeneğiyle çelişiyor', + "Skor modeli üst seçeneğiyle çelişiyor", market_stack_conflict_over25: - 'Üst 2.5 sinyaliyle çeliştiği için zayıflatıldı', + "Ü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ı', + "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', + "İ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', + "İ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', + "İ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', + "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', + "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', + "İ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', + "Skor modeli KG Yok seçeneğiyle çelişiyor", score_model_conflicts_with_draw_pick: - 'Skor modeli beraberlik seçeneğiyle çelişiyor', + "Skor modeli beraberlik seçeneğiyle çelişiyor", score_model_conflicts_with_home_pick: - 'Skor modeli ev sahibi seçeneğiyle çelişiyor', + "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', + "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( @@ -123,8 +122,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { @Optional() private readonly predictionsQueue?: PredictionsQueue, ) { this.aiEngineUrl = this.configService.get( - 'AI_ENGINE_URL', - 'http://localhost:8000', + "AI_ENGINE_URL", + "http://localhost:8000", ); this.topLeagueIds = this.loadTopLeagueIds(); } @@ -133,14 +132,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { 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'), + 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'); + this.logger.log("Queue mode enabled for predictions"); } else { - this.logger.log('Direct HTTP mode enabled for predictions (no Redis)'); + this.logger.log("Direct HTTP mode enabled for predictions (no Redis)"); } } @@ -152,7 +151,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { checkHealth(): Promise { return Promise.resolve({ - status: 'healthy', + status: "healthy", modelLoaded: true, predictionServiceReady: true, }); @@ -212,12 +211,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { } if (status === 422) { throw new HttpException( - `AI Engine: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`, + `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)}`, + `AI Engine error: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`, status || HttpStatus.SERVICE_UNAVAILABLE, ); } @@ -232,7 +231,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { 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'); + const refreshResult = await this.feederService.refreshMatch(matchId, "all"); if (!refreshResult.success) { this.logger.warn( @@ -251,7 +250,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { const upcoming = await this.prisma.prediction.findMany({ where: { match: { - status: 'NS', + status: "NS", mstUtc: { gte: Math.floor(Date.now() / 1000) }, }, }, @@ -260,13 +259,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { include: { homeTeam: true, awayTeam: true, league: true }, }, }, - orderBy: { match: { mstUtc: 'asc' } }, + orderBy: { match: { mstUtc: "asc" } }, take: 50, }); return { count: upcoming.length, - modelVersion: 'v25-v30-ensemble', + modelVersion: "v25-v30-ensemble", matches: upcoming.map((p) => { const out = p.predictionJson as Record; const matchInfo = (out?.match_info || {}) as Record; @@ -276,9 +275,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { ...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: p.match.league?.name || "", league_id: p.match.leagueId, - is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ''), + is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ""), }, } as unknown as MatchPredictionDto; }), @@ -287,12 +286,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { private loadTopLeagueIds(): Set { try { - const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json'); + const topLeaguesPath = path.join(process.cwd(), "top_leagues.json"); if (!fs.existsSync(topLeaguesPath)) { return new Set(); } - const raw = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8')); + const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8")); if (!Array.isArray(raw)) { return new Set(); } @@ -318,7 +317,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { if (match) { return { leagueId: match.leagueId ?? null, - isTopLeague: this.topLeagueIds.has(match.leagueId ?? ''), + isTopLeague: this.topLeagueIds.has(match.leagueId ?? ""), }; } @@ -329,7 +328,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { return { leagueId: liveMatch?.leagueId ?? null, - isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ''), + isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ""), }; } @@ -346,7 +345,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { league_id: this.asRecord(response.match_info).league_id ?? matchContext.leagueId, is_top_league: - this.asRecord(response.match_info).is_top_league ?? matchContext.isTopLeague, + this.asRecord(response.match_info).is_top_league ?? + matchContext.isTopLeague, }; const mainPick = this.enrichPick( @@ -369,9 +369,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { ); 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) + ? 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) @@ -380,8 +382,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { ) : []; - const mainBand = - this.asRecord(mainPick?.confidence_interval).band ?? 'LOW'; + const mainBand = this.asRecord(mainPick?.confidence_interval).band ?? "LOW"; const minConfidenceForPlay = this.getMinConfidenceForPlay( this.asRecord(mainPick).market, matchContext.isTopLeague, @@ -402,7 +403,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { if (mainPick && !isMainPlayable) { reasoningFactors.unshift( - this.translateReason('confidence_interval_too_wide_for_main_pick'), + this.translateReason("confidence_interval_too_wide_for_main_pick"), ); } @@ -416,9 +417,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { isMainPlayable ? String( this.asRecord(response.bet_advice).reason || - 'playable_edge_found', + "playable_edge_found", ) - : 'confidence_below_threshold', + : "confidence_below_threshold", ), suggested_stake_units: isMainPlayable ? Number(this.asRecord(response.bet_advice).suggested_stake_units ?? 0) @@ -428,15 +429,18 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { 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') { + 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), + 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), @@ -447,7 +451,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { implied_prob: 0, play_score: 0, playable: false, - bet_grade: 'PASS', + bet_grade: "PASS", stake_units: 0, decision_reasons: [], }; @@ -464,7 +468,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { { ...record, confidence_interval: this.asRecord(enriched?.confidence_interval), - confidence_band: this.asRecord(enriched?.confidence_interval).band ?? 'LOW', + confidence_band: + this.asRecord(enriched?.confidence_interval).band ?? "LOW", }, ]; }), @@ -472,14 +477,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { return { ...response, - match_info: matchInfo as MatchPredictionDto['match_info'], + match_info: matchInfo as MatchPredictionDto["match_info"], data_quality: { ...dataQuality, - lineup_source: String(dataQuality.lineup_source ?? 'none'), - } as MatchPredictionDto['data_quality'], + lineup_source: String(dataQuality.lineup_source ?? "none"), + } as MatchPredictionDto["data_quality"], risk: { ...risk, - surprise_type: this.translateReason(String(risk.surprise_type ?? '')), + surprise_type: this.translateReason(String(risk.surprise_type ?? "")), surprise_reasons: Array.isArray(risk.surprise_reasons) ? risk.surprise_reasons.map((reason) => this.translateReason(String(reason)), @@ -490,13 +495,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { this.translateReason(String(warning)), ) : [], - } as MatchPredictionDto['risk'], + } 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'], + bet_advice: betAdvice as MatchPredictionDto["bet_advice"], market_board: enrichedMarketBoard, reasoning_factors: reasoningFactors, }; @@ -507,14 +512,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { prediction: Record, matchContext: MatchContext, marketBoard: Record, - ): MatchPredictionDto['main_pick'] { - if (!pick || typeof pick !== 'object') { + ): 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 market = String(record.market ?? ""); + const pickName = String(record.pick ?? ""); const probs = this.resolveMarketProbabilities(marketBoard, market); const probability = this.asNumber(record.probability) || @@ -538,7 +543,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { ), riskScore: this.normalizeScore(this.asRecord(prediction.risk).score), lineupSource: String( - this.asRecord(prediction.data_quality).lineup_source ?? 'none', + this.asRecord(prediction.data_quality).lineup_source ?? "none", ), isTopLeague: matchContext.isTopLeague, }); @@ -547,10 +552,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { ? [...record.decision_reasons] : []; if (!interval.threshold_met) { - nextReasons.push('confidence_interval_too_wide'); + nextReasons.push("confidence_interval_too_wide"); } - if (interval.band === 'LOW') { - nextReasons.push('confidence_band_low'); + if (interval.band === "LOW") { + nextReasons.push("confidence_band_low"); } const displayOdds = this.normalizeDisplayOdds( @@ -559,7 +564,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { ); return { - ...(record as MatchPredictionDto['main_pick']), + ...(record as MatchPredictionDto["main_pick"]), market, pick: pickName, probability, @@ -573,7 +578,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { 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', + 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, @@ -594,10 +599,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { prediction: Record, matchContext: MatchContext, marketBoard: Record, - ): MatchPredictionDto['bet_summary'][number] { + ): MatchPredictionDto["bet_summary"][number] { const record = this.asRecord(item); - const market = String(record.market ?? ''); - const pickName = String(record.pick ?? ''); + const market = String(record.market ?? ""); + const pickName = String(record.pick ?? ""); const probs = this.resolveMarketProbabilities(marketBoard, market); const probability = this.lookupProbability(probs, pickName); const calibratedConfidence = @@ -621,13 +626,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { ), riskScore: this.normalizeScore(this.asRecord(prediction.risk).score), lineupSource: String( - this.asRecord(prediction.data_quality).lineup_source ?? 'none', + this.asRecord(prediction.data_quality).lineup_source ?? "none", ), isTopLeague: matchContext.isTopLeague, }); return { - ...(record as MatchPredictionDto['bet_summary'][number]), + ...(record as MatchPredictionDto["bet_summary"][number]), odds: this.normalizeDisplayOdds(odds, impliedProb), implied_prob: impliedProb, ev_edge: evEdge, @@ -658,12 +663,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { private translateReason(reason: string): string { if (!reason) { - return ''; + return ""; } - const normalized = reason.startsWith('risk:') - ? reason.slice(5) - : reason; + const normalized = reason.startsWith("risk:") ? reason.slice(5) : reason; if (this.reasonTranslations[normalized]) { return this.reasonTranslations[normalized]; @@ -674,7 +677,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`; } - const negativeEdgeMatch = normalized.match(/^negative_model_edge_([+\-]?[\d.]+)$/); + const negativeEdgeMatch = normalized.match( + /^negative_model_edge_([+\-]?[\d.]+)$/, + ); if (negativeEdgeMatch) { return `Model avantajı negatif (${negativeEdgeMatch[1]})`; } @@ -692,47 +697,44 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { private classifySignalTier( record: Record, interval: { - band?: 'HIGH' | 'MEDIUM' | 'LOW'; + band?: "HIGH" | "MEDIUM" | "LOW"; threshold_met?: boolean; }, - ): 'CORE' | 'VALUE' | 'LEAN' | 'LONGSHOT' | 'PASS' { - const playable = Boolean(record.playable) && Boolean(interval.threshold_met); + ): "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(); + const band = String(interval.band ?? "LOW").toUpperCase(); if ( playable && - band === 'HIGH' && + band === "HIGH" && calibratedConfidence >= 72 && evEdge >= 0.02 && playScore >= 68 ) { - return 'CORE'; + return "CORE"; } - if ( - calibratedConfidence >= 52 && - odds >= 1.75 && - evEdge >= 0.04 - ) { - return playable ? 'VALUE' : 'LONGSHOT'; + if (calibratedConfidence >= 52 && odds >= 1.75 && evEdge >= 0.04) { + return playable ? "VALUE" : "LONGSHOT"; } if ( calibratedConfidence >= 46 && - (band === 'HIGH' || band === 'MEDIUM' || evEdge > 0) + (band === "HIGH" || band === "MEDIUM" || evEdge > 0) ) { - return 'LEAN'; + return "LEAN"; } if (odds >= 2.2 && calibratedConfidence >= 38) { - return 'LONGSHOT'; + return "LONGSHOT"; } - return 'PASS'; + return "PASS"; } private estimateConfidenceInterval(input: { @@ -755,7 +757,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { const secondProb = sortedProbs[1] ?? 0; const topProb = sortedProbs[0] ?? probability; const margin = Math.max(0, topProb - secondProb); - const normalizedConfidence = this.normalizePercent(input.calibratedConfidence); + const normalizedConfidence = this.normalizePercent( + input.calibratedConfidence, + ); const baseWidthByMarket: Record = { MS: 0.18, @@ -767,19 +771,19 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { }; const baseWidth = baseWidthByMarket[input.market] ?? 0.19; const lineupPenalty = - input.lineupSource === 'confirmed_live' + input.lineupSource === "confirmed_live" ? -0.015 - : input.lineupSource === 'probable_xi' + : 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, + 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, ); @@ -795,17 +799,17 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { width <= this.getMaxAllowedWidth(input.market) && input.dataQualityScore >= 0.58 && input.evEdge >= this.getMinEdge(input.market) && - (upper - input.impliedProb) >= 0.03; + upper - input.impliedProb >= 0.03; - let band: ConfidenceBand = 'LOW'; + let band: ConfidenceBand = "LOW"; if (input.calibratedConfidence >= 69 && width <= 0.12 && margin >= 0.07) { - band = 'HIGH'; + band = "HIGH"; } else if ( input.calibratedConfidence >= 58 && width <= 0.18 && margin >= 0.035 ) { - band = 'MEDIUM'; + band = "MEDIUM"; } return { @@ -864,7 +868,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { ): Record { const entry = this.asRecord(marketBoard[market]); const probs = entry.probs; - return probs && typeof probs === 'object' + return probs && typeof probs === "object" ? (probs as Record) : {}; } @@ -895,7 +899,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { private normalizeScore(value: unknown): number { const numeric = this.asNumber(value); - return numeric > 1 ? this.clamp(numeric / 100, 0, 1) : this.clamp(numeric, 0, 1); + return numeric > 1 + ? this.clamp(numeric / 100, 0, 1) + : this.clamp(numeric, 0, 1); } private normalizePercent(value: number): number { @@ -903,15 +909,15 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { } private asRecord(value: unknown): Record { - return value && typeof value === 'object' + return value && typeof value === "object" ? (value as Record) : {}; } private asNumber(value: unknown): number { - return typeof value === 'number' + return typeof value === "number" ? value - : typeof value === 'string' + : typeof value === "string" ? Number(value) || 0 : 0; } @@ -922,7 +928,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { async getValueBets(): Promise { const predictions = await this.prisma.prediction.findMany({ - where: { match: { status: 'NS' } }, + where: { match: { status: "NS" } }, include: { match: { include: { homeTeam: true, awayTeam: true } } }, }); @@ -937,14 +943,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { 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, + 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' + typeof vb.edge === "number" ? vb.edge - : typeof vb.expectedValue === 'number' + : typeof vb.expectedValue === "number" ? vb.expectedValue : 0, }); @@ -959,7 +965,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { async getSmartCoupon( matchIds: string[], - strategy: string = 'BALANCED', + strategy: string = "BALANCED", options: { maxMatches?: number; minConfidence?: number } = {}, ): Promise { await this.ensureSmartCouponDataReady(matchIds); @@ -997,23 +1003,23 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { private throwAiError(message: string): never { if ( - message.includes('timed out') || - message.includes('AI_ENGINE_TIMEOUT') || - message.includes('AI_ENGINE_504') + message.includes("timed out") || + message.includes("AI_ENGINE_TIMEOUT") || + message.includes("AI_ENGINE_504") ) { throw new HttpException( - 'Prediction request timed out', + "Prediction request timed out", HttpStatus.GATEWAY_TIMEOUT, ); } - if (message.includes('AI_ENGINE_502')) { + if (message.includes("AI_ENGINE_502")) { throw new HttpException( - 'AI Engine upstream returned 502', + "AI Engine upstream returned 502", HttpStatus.BAD_GATEWAY, ); } throw new HttpException( - 'Failed to get prediction from AI Engine', + "Failed to get prediction from AI Engine", HttpStatus.SERVICE_UNAVAILABLE, ); } @@ -1066,12 +1072,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { } const cached = prediction.predictionJson as Record; - const modelVersion = cached['model_version']; - if (typeof modelVersion !== 'string') { + const modelVersion = cached["model_version"]; + if (typeof modelVersion !== "string") { return null; } - if (!modelVersion.startsWith('v25')) { + if (!modelVersion.startsWith("v25")) { return null; } @@ -1082,7 +1088,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { const uniqueMatchIds = [...new Set(matchIds.filter((id) => !!id))]; if (uniqueMatchIds.length === 0) { throw new HttpException( - 'No matchIds provided for smart coupon generation', + "No matchIds provided for smart coupon generation", HttpStatus.BAD_REQUEST, ); } @@ -1122,7 +1128,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { const hasLiveOdds = !!liveMatch?.odds && - typeof liveMatch.odds === 'object' && + typeof liveMatch.odds === "object" && !Array.isArray(liveMatch.odds) && Object.keys(liveMatch.odds as Record).length > 0; const matchExists = !!liveMatch?.id || !!persistedMatch?.id; @@ -1146,9 +1152,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { const isFinished = hasScores || - state === 'MS' || - state === 'postGame' || - ['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'].includes( + state === "MS" || + state === "postGame" || + ["Finished", "Played", "FT", "AET", "PEN", "Ended"].includes( status as string, ); diff --git a/src/modules/predictions/queues/predictions.processor.spec.ts b/src/modules/predictions/queues/predictions.processor.spec.ts index 05bdca5..774816b 100755 --- a/src/modules/predictions/queues/predictions.processor.spec.ts +++ b/src/modules/predictions/queues/predictions.processor.spec.ts @@ -1,18 +1,18 @@ /* eslint-disable @typescript-eslint/unbound-method */ -import axios from 'axios'; -import { PredictionJobType } from './predictions.types'; -import { PredictionsProcessor } from './predictions.processor'; +import axios from "axios"; +import { PredictionJobType } from "./predictions.types"; +import { PredictionsProcessor } from "./predictions.processor"; -jest.mock('axios'); +jest.mock("axios"); const mockedAxios = axios as jest.Mocked; -describe('PredictionsProcessor', () => { +describe("PredictionsProcessor", () => { let processor: PredictionsProcessor; beforeEach(() => { jest.clearAllMocks(); - process.env.AI_ENGINE_URL = 'http://unit-ai:8000'; + process.env.AI_ENGINE_URL = "http://unit-ai:8000"; processor = new PredictionsProcessor(); }); @@ -20,34 +20,34 @@ describe('PredictionsProcessor', () => { delete process.env.AI_ENGINE_URL; }); - it('posts to analyze endpoint for predict-match jobs', async () => { + it("posts to analyze endpoint for predict-match jobs", async () => { mockedAxios.post.mockResolvedValueOnce({ data: { ok: true } } as any); const job = { - id: 'j1', + id: "j1", name: PredictionJobType.PREDICT_MATCH, - data: { matchId: 'match-123' }, + 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', + "http://unit-ai:8000/v20plus/analyze/match-123", {}, { timeout: 30000 }, ); }); - it('posts mapped payload to coupon endpoint for smart-coupon jobs', async () => { + it("posts mapped payload to coupon endpoint for smart-coupon jobs", async () => { mockedAxios.post.mockResolvedValueOnce({ data: { bets: [] } } as any); const job = { - id: 'j2', + id: "j2", name: PredictionJobType.SMART_COUPON, data: { - matchIds: ['m1', 'm2'], - strategy: 'BALANCED', + matchIds: ["m1", "m2"], + strategy: "BALANCED", options: { maxMatches: 4, minConfidence: 65 }, }, } as any; @@ -56,10 +56,10 @@ describe('PredictionsProcessor', () => { expect(result).toEqual({ bets: [] }); expect(mockedAxios.post).toHaveBeenCalledWith( - 'http://unit-ai:8000/v20plus/coupon', + "http://unit-ai:8000/v20plus/coupon", { - match_ids: ['m1', 'm2'], - strategy: 'BALANCED', + match_ids: ["m1", "m2"], + strategy: "BALANCED", max_matches: 4, min_confidence: 65, }, @@ -67,15 +67,15 @@ describe('PredictionsProcessor', () => { ); }); - it('throws for unknown job type', async () => { + it("throws for unknown job type", async () => { const job = { - id: 'j3', - name: 'unknown-job', + id: "j3", + name: "unknown-job", data: {}, } as any; await expect(processor.process(job)).rejects.toThrow( - 'Unknown job type: unknown-job', + "Unknown job type: unknown-job", ); }); }); diff --git a/src/modules/predictions/queues/predictions.processor.ts b/src/modules/predictions/queues/predictions.processor.ts index fe474f0..1d88c26 100755 --- a/src/modules/predictions/queues/predictions.processor.ts +++ b/src/modules/predictions/queues/predictions.processor.ts @@ -1,14 +1,14 @@ /* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ -import { Processor, WorkerHost } from '@nestjs/bullmq'; -import { Logger } from '@nestjs/common'; -import { Job } from 'bullmq'; +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'; +} from "./predictions.types"; +import axios from "axios"; /** * Predictions Processor @@ -22,7 +22,7 @@ export class PredictionsProcessor extends WorkerHost { constructor() { super(); // Default to container service URL - this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000'; + this.aiEngineUrl = process.env.AI_ENGINE_URL || "http://ai-engine:8000"; } async process(job: Job): Promise { @@ -56,7 +56,7 @@ export class PredictionsProcessor extends WorkerHost { ); return response.data; } catch (error) { - throw this.mapAxiosError(error, matchId, 'predict'); + throw this.mapAxiosError(error, matchId, "predict"); } } @@ -81,14 +81,14 @@ export class PredictionsProcessor extends WorkerHost { ); return response.data; } catch (error) { - throw this.mapAxiosError(error, matchIds.join(','), 'smart-coupon'); + throw this.mapAxiosError(error, matchIds.join(","), "smart-coupon"); } } private mapAxiosError( error: unknown, identifier: string, - flow: 'predict' | 'smart-coupon', + flow: "predict" | "smart-coupon", ): Error { if (!axios.isAxiosError(error)) { return error instanceof Error @@ -98,7 +98,7 @@ export class PredictionsProcessor extends WorkerHost { const status = error.response?.status; const detail = error.response?.data?.detail || error.message; - const code = error.code || ''; + const code = error.code || ""; if (status === 502) { this.logger.error(`AI Engine 502 (${flow}:${identifier}): ${detail}`); @@ -110,13 +110,13 @@ export class PredictionsProcessor extends WorkerHost { return new Error(`AI_ENGINE_504|${flow}|${detail}`); } - if (code === 'ECONNABORTED' || code === 'ETIMEDOUT') { + 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}`, + `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 index 1b6d1f0..44ff889 100755 --- a/src/modules/predictions/queues/predictions.queue.ts +++ b/src/modules/predictions/queues/predictions.queue.ts @@ -1,12 +1,12 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectQueue } from '@nestjs/bullmq'; -import { Queue } from 'bullmq'; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectQueue } from "@nestjs/bullmq"; +import { Queue } from "bullmq"; import { PREDICTIONS_QUEUE, PredictionJobType, PredictMatchJobData, SmartCouponJobData, -} from './predictions.types'; +} from "./predictions.types"; @Injectable() export class PredictionsQueue { diff --git a/src/modules/predictions/queues/predictions.types.ts b/src/modules/predictions/queues/predictions.types.ts index 4426756..b36e83d 100755 --- a/src/modules/predictions/queues/predictions.types.ts +++ b/src/modules/predictions/queues/predictions.types.ts @@ -3,11 +3,11 @@ * Senior Level Strict Typing */ -export const PREDICTIONS_QUEUE = 'predictions-queue'; +export const PREDICTIONS_QUEUE = "predictions-queue"; export enum PredictionJobType { - PREDICT_MATCH = 'predict-match', - SMART_COUPON = 'smart-coupon', + PREDICT_MATCH = "predict-match", + SMART_COUPON = "smart-coupon", } export interface PredictMatchJobData { diff --git a/src/modules/predictions/services/ai-feature-store.service.ts b/src/modules/predictions/services/ai-feature-store.service.ts index ef3b32d..23b1569 100755 --- a/src/modules/predictions/services/ai-feature-store.service.ts +++ b/src/modules/predictions/services/ai-feature-store.service.ts @@ -1,5 +1,5 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { PrismaService } from '../../../database/prisma.service'; +import { Injectable, Logger } from "@nestjs/common"; +import { PrismaService } from "../../../database/prisma.service"; @Injectable() export class AiFeatureStoreService { @@ -16,10 +16,10 @@ export class AiFeatureStoreService { where: { id: matchId }, include: { homeTeam: { - include: { homeMatches: { take: 5, orderBy: { mstUtc: 'desc' } } }, + include: { homeMatches: { take: 5, orderBy: { mstUtc: "desc" } } }, }, awayTeam: { - include: { awayMatches: { take: 5, orderBy: { mstUtc: 'desc' } } }, + include: { awayMatches: { take: 5, orderBy: { mstUtc: "desc" } } }, }, }, }); diff --git a/src/modules/social-poster/caption-generator.service.ts b/src/modules/social-poster/caption-generator.service.ts index f20a099..b4973f2 100644 --- a/src/modules/social-poster/caption-generator.service.ts +++ b/src/modules/social-poster/caption-generator.service.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { GeminiService } from '../gemini/gemini.service'; -import { PredictionCardDto } from './dto/prediction-card.dto'; +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. @@ -28,7 +28,7 @@ export class CaptionGeneratorService { */ async generateCaption(card: PredictionCardDto): Promise { if (!this.geminiService.isAvailable()) { - this.logger.warn('Gemini not available, using template caption'); + this.logger.warn("Gemini not available, using template caption"); return this.generateFallbackCaption(card); } @@ -48,7 +48,7 @@ export class CaptionGeneratorService { ); return caption; } catch (error) { - this.logger.error('Gemini caption generation failed', error); + this.logger.error("Gemini caption generation failed", error); return this.generateFallbackCaption(card); } } @@ -59,7 +59,7 @@ export class CaptionGeneratorService { (p, i) => `${i + 1}. ${p.market} (${p.marketEn}) — ${p.pick} — Güven: %${p.confidence} — Oran: ${p.odds}`, ) - .join('\n'); + .join("\n"); return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur: @@ -79,12 +79,12 @@ 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('#')) { + 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, ''); + .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(); @@ -96,13 +96,13 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`; private generateFallbackCaption(card: PredictionCardDto): string { const topPick = card.topPicks[0]; const leagueTag = card.leagueName - .replace(/\s+/g, '') - .replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, ''); + .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})` : ''} +${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 index 4fb379d..9608e2c 100644 --- a/src/modules/social-poster/dto/prediction-card.dto.ts +++ b/src/modules/social-poster/dto/prediction-card.dto.ts @@ -42,7 +42,7 @@ export interface PredictionCardDto { topPicks: TopPick[]; // ─── Risk ─── - riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME'; + riskLevel: "LOW" | "MEDIUM" | "HIGH" | "EXTREME"; // ─── Raw prediction JSON (for Gemini caption) ─── rawPrediction?: Record; diff --git a/src/modules/social-poster/image-renderer.service.ts b/src/modules/social-poster/image-renderer.service.ts index db5c84e..c8d6f18 100644 --- a/src/modules/social-poster/image-renderer.service.ts +++ b/src/modules/social-poster/image-renderer.service.ts @@ -1,17 +1,17 @@ -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'; +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', + "public", + "predictions", ); onModuleInit() { @@ -53,8 +53,8 @@ export class ImageRendererService implements OnModuleInit { try { // Case 1: Local relative path → read from public/ directory - if (url.startsWith('/')) { - const localPath = path.join(process.cwd(), 'public', url); + 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); @@ -66,9 +66,9 @@ export class ImageRendererService implements OnModuleInit { } // Case 2: Full HTTP/HTTPS URL → fetch directly - if (url.startsWith('http')) { + if (url.startsWith("http")) { const response = await axios.get(url, { - responseType: 'arraybuffer', + responseType: "arraybuffer", timeout: 5000, }); return await loadImage(response.data); @@ -133,14 +133,14 @@ export class ImageRendererService implements OnModuleInit { const width = 1080; const height = 1920; const canvas = createCanvas(width, height); - const ctx = canvas.getContext('2d'); + 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'); + 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); @@ -148,12 +148,12 @@ export class ImageRendererService implements OnModuleInit { 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'; + 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'; + "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); } @@ -163,14 +163,14 @@ export class ImageRendererService implements OnModuleInit { const paddingX = 80; // Header - ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; - ctx.font = '600 28px sans-serif'; - ctx.textAlign = 'left'; + 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.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 @@ -184,31 +184,31 @@ export class ImageRendererService implements OnModuleInit { 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); + 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.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.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)'); + 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( @@ -225,7 +225,7 @@ export class ImageRendererService implements OnModuleInit { ); }; - drawSectionTitle(currentY, 'SKOR TAHMİNİ / SCORE PREDICTION'); + drawSectionTitle(currentY, "SKOR TAHMİNİ / SCORE PREDICTION"); // Scores currentY += 80; @@ -235,20 +235,20 @@ export class ImageRendererService implements OnModuleInit { const ftX = width / 2 + 24; // HT Box - ctx.fillStyle = 'rgba(255, 255, 255, 0.04)'; - ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; + 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.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 @@ -258,19 +258,19 @@ export class ImageRendererService implements OnModuleInit { ftX + scoreBoxWidth, currentY + scoreBoxHeight, ); - ftGrad.addColorStop(0, 'rgba(120, 80, 255, 0.15)'); - ftGrad.addColorStop(1, 'rgba(0, 200, 255, 0.1)'); + 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)'; + 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); + 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( @@ -279,15 +279,15 @@ export class ImageRendererService implements OnModuleInit { ftX, currentY + 160, ); - txtGrad.addColorStop(0, '#9b6fff'); - txtGrad.addColorStop(1, '#00c8ff'); + txtGrad.addColorStop(0, "#9b6fff"); + txtGrad.addColorStop(1, "#00c8ff"); ctx.fillStyle = txtGrad; - ctx.font = '900 80px sans-serif'; + 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)'; + ctx.fillStyle = "#0a0e27"; + ctx.strokeStyle = "rgba(120, 80, 255, 0.6)"; this.fillRoundRect( ctx, ftX + scoreBoxWidth / 2 - 80, @@ -304,8 +304,8 @@ export class ImageRendererService implements OnModuleInit { 40, 20, ); - ctx.fillStyle = '#b89dff'; - ctx.font = '800 20px sans-serif'; + ctx.fillStyle = "#b89dff"; + ctx.font = "800 20px sans-serif"; ctx.fillText( `🎯 %${data.scoreConfidence}`, ftX + scoreBoxWidth / 2, @@ -314,13 +314,13 @@ export class ImageRendererService implements OnModuleInit { // Divider: Picks currentY += scoreBoxHeight + 100; - drawSectionTitle(currentY, 'EN İYİ TAHMİNLER / BEST PICKS'); + 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)'; + ctx.fillStyle = "rgba(255, 255, 255, 0.03)"; + ctx.strokeStyle = "rgba(255, 255, 255, 0.06)"; this.fillRoundRect( ctx, paddingX, @@ -338,18 +338,18 @@ export class ImageRendererService implements OnModuleInit { 16, ); - ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; - ctx.font = '700 28px sans-serif'; - ctx.textAlign = 'left'; + 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.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.fillStyle = "rgba(255, 255, 255, 0.35)"; + ctx.font = "400 18px sans-serif"; ctx.fillText( `(${pick.marketEn})`, paddingX + 80 + marketWidth + 10, @@ -357,7 +357,7 @@ export class ImageRendererService implements OnModuleInit { ); // Pick Bar bg - ctx.fillStyle = 'rgba(255, 255, 255, 0.06)'; + ctx.fillStyle = "rgba(255, 255, 255, 0.06)"; const barMaxWidth = width - 2 * paddingX - 220; this.fillRoundRect(ctx, paddingX + 80, currentY + 65, barMaxWidth, 12, 6); @@ -369,15 +369,15 @@ export class ImageRendererService implements OnModuleInit { paddingX + 80 + barMaxWidth, 0, ); - barGrad.addColorStop(0, '#7850ff'); - barGrad.addColorStop(1, '#00c8ff'); + 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.fillStyle = "#b89dff"; + ctx.font = "900 32px sans-serif"; + ctx.textAlign = "right"; ctx.fillText(`%${pick.confidence}`, width - paddingX - 30, currentY + 58); currentY += 124; @@ -385,41 +385,41 @@ export class ImageRendererService implements OnModuleInit { // 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); + 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)'; + 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)'; + 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)'; + 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)'; + 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)'; + 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'; + ctx.font = "800 20px sans-serif"; const riskWidth = ctx.measureText(riskText).width; ctx.fillStyle = riskBg; ctx.strokeStyle = riskBorder; @@ -441,11 +441,11 @@ export class ImageRendererService implements OnModuleInit { ); ctx.fillStyle = riskColor; - ctx.textAlign = 'center'; + 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'); + const buffer = canvas.toBuffer("image/png"); fs.writeFileSync(outPath, buffer); } @@ -454,9 +454,9 @@ export class ImageRendererService implements OnModuleInit { */ getImageUrl(filePath: string): string { const relativePath = path.relative( - path.join(process.cwd(), 'public'), + path.join(process.cwd(), "public"), filePath, ); - return `/${relativePath.replace(/\\/g, '/')}`; + return `/${relativePath.replace(/\\/g, "/")}`; } } diff --git a/src/modules/social-poster/meta.service.ts b/src/modules/social-poster/meta.service.ts index 2063264..df45de3 100644 --- a/src/modules/social-poster/meta.service.ts +++ b/src/modules/social-poster/meta.service.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import axios from 'axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import axios from "axios"; @Injectable() export class MetaService { @@ -10,21 +10,21 @@ export class MetaService { private readonly pageId: string; private readonly igUserId: string; private readonly isEnabled: boolean; - private readonly graphApiBase = 'https://graph.facebook.com/v21.0'; + 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.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'); + 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', + "⚠️ Meta API not configured. Set META_PAGE_ACCESS_TOKEN, META_PAGE_ID, META_IG_USER_ID", ); } } @@ -53,7 +53,7 @@ export class MetaService { imageUrl: string, ): Promise { if (!this.facebookAvailable) { - this.logger.warn('Facebook not available, skipping post'); + this.logger.warn("Facebook not available, skipping post"); return null; } @@ -98,7 +98,7 @@ export class MetaService { imageUrl: string, ): Promise { if (!this.instagramAvailable) { - this.logger.warn('Instagram not available, skipping post'); + this.logger.warn("Instagram not available, skipping post"); return null; } @@ -115,7 +115,7 @@ export class MetaService { const containerId = containerResponse.data?.id; if (!containerId) { - throw new Error('No container ID returned'); + throw new Error("No container ID returned"); } // Wait for container processing (IG needs a few seconds) @@ -156,25 +156,25 @@ export class MetaService { `${this.graphApiBase}/${containerId}`, { params: { - fields: 'status_code', + 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'); + if (status === "FINISHED") return; + if (status === "ERROR") { + throw new Error("Container processing failed"); } } catch (error) { - if (error.message === 'Container processing failed') throw 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'); + 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 index 7f84118..6de2dfe 100644 --- a/src/modules/social-poster/social-poster.controller.ts +++ b/src/modules/social-poster/social-poster.controller.ts @@ -1,25 +1,25 @@ -import { Controller, Post, Param, Get, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +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'; +import { SocialPosterService } from "./social-poster.service"; +import { Roles } from "../../common/decorators"; +import { RolesGuard } from "../auth/guards/auth.guards"; -@ApiTags('Social Poster') +@ApiTags("Social Poster") @ApiBearerAuth() @UseGuards(RolesGuard) -@Roles('admin') -@Controller('social-poster') +@Roles("admin") +@Controller("social-poster") export class SocialPosterController { constructor(private readonly socialPosterService: SocialPosterService) {} - @Get('preview/:matchId') - async previewCard(@Param('matchId') matchId: string) { + @Get("preview/:matchId") + async previewCard(@Param("matchId") matchId: string) { return this.socialPosterService.renderPreview(matchId); } - @Post('post/:matchId') - async postMatch(@Param('matchId') matchId: string) { + @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 index 0489244..70ec130 100644 --- a/src/modules/social-poster/social-poster.module.ts +++ b/src/modules/social-poster/social-poster.module.ts @@ -1,14 +1,14 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { ScheduleModule } from '@nestjs/schedule'; +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 { 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'; +import { SocialPosterController } from "./social-poster.controller"; /** * Social Poster Module diff --git a/src/modules/social-poster/social-poster.service.ts b/src/modules/social-poster/social-poster.service.ts index 221c225..85fc021 100644 --- a/src/modules/social-poster/social-poster.service.ts +++ b/src/modules/social-poster/social-poster.service.ts @@ -1,24 +1,24 @@ -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 { 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 { 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'; +} from "./dto/prediction-card.dto"; // Top leagues loaded once -const TOP_LEAGUES_PATH = path.join(process.cwd(), 'top_leagues.json'); +const TOP_LEAGUES_PATH = path.join(process.cwd(), "top_leagues.json"); @Injectable() export class SocialPosterService { @@ -38,24 +38,24 @@ export class SocialPosterService { private readonly metaService: MetaService, ) { this.aiEngineUrl = - this.configService.get('AI_ENGINE_URL') || - 'http://localhost:8000'; + this.configService.get("AI_ENGINE_URL") || + "http://localhost:8000"; this.appBaseUrl = - this.configService.get('APP_BASE_URL') || 'http://localhost:3000'; + this.configService.get("APP_BASE_URL") || "http://localhost:3000"; this.isEnabled = - this.configService.get('SOCIAL_POSTER_ENABLED') === 'true'; + this.configService.get("SOCIAL_POSTER_ENABLED") === "true"; this.loadTopLeagues(); } private loadTopLeagues() { try { - const data = fs.readFileSync(TOP_LEAGUES_PATH, 'utf-8'); + 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'); + this.logger.warn("⚠️ Could not load top_leagues.json"); } } @@ -63,7 +63,7 @@ export class SocialPosterService { * Cron: Every 10 minutes, check for upcoming matches. * Posts predictions 30 minutes before kickoff. */ - @Cron('*/10 * * * *') + @Cron("*/10 * * * *") async checkAndPostUpcomingMatches() { if (!this.isEnabled) return; @@ -115,7 +115,7 @@ export class SocialPosterService { const matches = await this.prisma.liveMatch.findMany({ where: { - sport: 'football', + sport: "football", leagueId: { in: Array.from(this.topLeagueIds) }, mstUtc: { gte: minTime, @@ -144,7 +144,7 @@ export class SocialPosterService { // Step 1: Get prediction from AI Engine const prediction = await this.getPrediction(matchId); if (!prediction) { - throw new Error('No prediction returned from AI Engine'); + throw new Error("No prediction returned from AI Engine"); } // Step 2: Build prediction card data @@ -194,9 +194,9 @@ export class SocialPosterService { this.logger.log( `✅ Posted: ${match.homeTeam?.name} vs ${match.awayTeam?.name} ` + - `[TW: ${result.twitterPostId ? '✅' : '❌'}, ` + - `FB: ${result.facebookPostId ? '✅' : '❌'}, ` + - `IG: ${result.instagramPostId ? '✅' : '❌'}]`, + `[TW: ${result.twitterPostId ? "✅" : "❌"}, ` + + `FB: ${result.facebookPostId ? "✅" : "❌"}, ` + + `IG: ${result.instagramPostId ? "✅" : "❌"}]`, ); return result; @@ -229,8 +229,8 @@ export class SocialPosterService { ): PredictionCardDto { // V20+ returns score_prediction.ft / .ht const score = prediction.score_prediction || {}; - const htScore = score.ht || '0-0'; - const ftScore = score.ft || '1-1'; + const htScore = score.ht || "0-0"; + const ftScore = score.ft || "1-1"; // Extract best bets from bet_summary array const topPicks = this.extractTopPicks(prediction); @@ -247,18 +247,18 @@ export class SocialPosterService { return { matchId: match.id, homeTeam: - match.homeTeam?.name || prediction.match_info?.home_team || 'Home', + 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 || '', + 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', + riskLevel: prediction.risk?.level || "MEDIUM", rawPrediction: prediction, }; } @@ -271,16 +271,16 @@ export class SocialPosterService { // 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' }, + 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) => { @@ -308,11 +308,11 @@ export class SocialPosterService { * Locally during dev, we fetch them from the deployed server via APP_BASE_URL. */ private resolveLogoUrl(logoUrl: string): string { - if (!logoUrl) return ''; + if (!logoUrl) return ""; // Already a full URL - if (logoUrl.startsWith('http')) return logoUrl; + if (logoUrl.startsWith("http")) return logoUrl; // Relative path → check local first, otherwise make full URL - const localPath = path.join(process.cwd(), 'public', logoUrl); + 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}`; @@ -321,24 +321,24 @@ export class SocialPosterService { 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', + "Oca", + "Şub", + "Mar", + "Nis", + "May", + "Haz", + "Tem", + "Ağu", + "Eyl", + "Eki", + "Kas", + "Ara", ]; - const day = String(d.getDate()).padStart(2, '0'); + 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'); + const hour = String(d.getHours()).padStart(2, "0"); + const min = String(d.getMinutes()).padStart(2, "0"); return `${day} ${month} ${year} - ${hour}:${min}`; } @@ -383,7 +383,7 @@ export class SocialPosterService { const prediction = await this.getPrediction(matchId); if (!prediction) { - throw new Error('No prediction returned from AI Engine'); + throw new Error("No prediction returned from AI Engine"); } const card = this.buildCardFromPrediction(match, prediction); diff --git a/src/modules/social-poster/twitter.service.ts b/src/modules/social-poster/twitter.service.ts index 27b3206..fe9c358 100644 --- a/src/modules/social-poster/twitter.service.ts +++ b/src/modules/social-poster/twitter.service.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import * as fs from 'fs'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import * as fs from "fs"; @Injectable() export class TwitterService { @@ -9,18 +9,18 @@ export class TwitterService { 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 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', + "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', + "⚠️ Twitter API keys not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET", ); } } @@ -32,7 +32,7 @@ export class TwitterService { accessSecret: string, ) { try { - const { TwitterApi } = await import('twitter-api-v2'); + const { TwitterApi } = await import("twitter-api-v2"); this.client = new TwitterApi({ appKey: apiKey, appSecret: apiSecret, @@ -40,9 +40,9 @@ export class TwitterService { accessSecret, }); this.isEnabled = true; - this.logger.log('✅ Twitter API client initialized'); + this.logger.log("✅ Twitter API client initialized"); } catch (error) { - this.logger.error('Failed to initialize Twitter client', error); + this.logger.error("Failed to initialize Twitter client", error); } } @@ -59,7 +59,7 @@ export class TwitterService { */ async postWithImage(text: string, imagePath: string): Promise { if (!this.available) { - this.logger.warn('Twitter not available, skipping post'); + this.logger.warn("Twitter not available, skipping post"); return null; } @@ -67,7 +67,7 @@ export class TwitterService { // Step 1: Upload media via v1.1 const mediaData = fs.readFileSync(imagePath); const mediaId = await this.client.v1.uploadMedia(mediaData, { - mimeType: 'image/png', + mimeType: "image/png", }); // Step 2: Create tweet via v2 diff --git a/src/modules/spor-toto/dto/spor-toto.dto.ts b/src/modules/spor-toto/dto/spor-toto.dto.ts index f2c972d..40936f9 100644 --- a/src/modules/spor-toto/dto/spor-toto.dto.ts +++ b/src/modules/spor-toto/dto/spor-toto.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { IsArray, IsDateString, @@ -10,37 +10,37 @@ import { Max, Min, ValidateNested, -} from 'class-validator'; -import { Type } from 'class-transformer'; +} 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)' }) + @ApiProperty({ example: 1, description: "Sıra numarası (1-15)" }) @IsInt() @Min(1) @Max(15) matchOrder: number; - @ApiProperty({ example: 'Blackpool' }) + @ApiProperty({ example: "Blackpool" }) @IsString() homeTeamName: string; - @ApiProperty({ example: 'Burton Albion' }) + @ApiProperty({ example: "Burton Albion" }) @IsString() awayTeamName: string; - @ApiPropertyOptional({ example: 'İN1' }) + @ApiPropertyOptional({ example: "İN1" }) @IsOptional() @IsString() leagueName?: string; - @ApiPropertyOptional({ example: '2026-03-28T18:00:00' }) + @ApiPropertyOptional({ example: "2026-03-28T18:00:00" }) @IsOptional() @IsDateString() kickoffTime?: string; - @ApiPropertyOptional({ description: 'Link to existing match ID' }) + @ApiPropertyOptional({ description: "Link to existing match ID" }) @IsOptional() @IsString() matchId?: string; @@ -49,26 +49,26 @@ export class BulletinMatchItemDto { // ─── Create Bulletin DTO ─── export class CreateBulletinDto { - @ApiProperty({ example: 333, description: 'Game cycle number from API' }) + @ApiProperty({ example: 333, description: "Game cycle number from API" }) @IsInt() gameCycleNo: number; - @ApiPropertyOptional({ example: '27-29 Mart' }) + @ApiPropertyOptional({ example: "27-29 Mart" }) @IsOptional() @IsString() programName?: string; - @ApiPropertyOptional({ example: '2025-2026' }) + @ApiPropertyOptional({ example: "2025-2026" }) @IsOptional() @IsString() season?: string; - @ApiPropertyOptional({ example: '2026-03-22T10:00:00' }) + @ApiPropertyOptional({ example: "2026-03-22T10:00:00" }) @IsOptional() @IsDateString() payinBeginDate?: string; - @ApiPropertyOptional({ example: '2026-03-27T20:55:00' }) + @ApiPropertyOptional({ example: "2026-03-27T20:55:00" }) @IsOptional() @IsDateString() payinEndDate?: string; @@ -83,24 +83,24 @@ export class CreateBulletinDto { // ─── Update Results DTO ─── export class MatchResultDto { - @ApiProperty({ example: 1, description: 'Match order (1-15)' }) + @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'; + @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'] }) + @ApiPropertyOptional({ enum: ["HOME", "DRAW", "AWAY"] }) @IsOptional() - @IsEnum({ HOME: 'HOME', DRAW: 'DRAW', AWAY: 'AWAY' }) - drawResult?: 'HOME' | 'DRAW' | 'AWAY'; + @IsEnum({ HOME: "HOME", DRAW: "DRAW", AWAY: "AWAY" }) + drawResult?: "HOME" | "DRAW" | "AWAY"; } export class UpdateResultsDto { @@ -110,12 +110,12 @@ export class UpdateResultsDto { @Type(() => MatchResultDto) results: MatchResultDto[]; - @ApiPropertyOptional({ description: '15 bilen sayısı' }) + @ApiPropertyOptional({ description: "15 bilen sayısı" }) @IsOptional() @IsInt() winners15?: number; - @ApiPropertyOptional({ description: '15 bilen ödülü (TL)' }) + @ApiPropertyOptional({ description: "15 bilen ödülü (TL)" }) @IsOptional() @IsNumber() prize15?: number; @@ -150,7 +150,7 @@ export class UpdateResultsDto { @IsNumber() prize12?: number; - @ApiPropertyOptional({ description: 'Sonraki haftaya devir' }) + @ApiPropertyOptional({ description: "Sonraki haftaya devir" }) @IsOptional() @IsNumber() rolloverNext?: number; @@ -158,7 +158,7 @@ export class UpdateResultsDto { // ─── Generate Columns DTO ─── -export type TotoSelectionType = '1' | 'X' | '2'; +export type TotoSelectionType = "1" | "X" | "2"; export class TotoMatchSelection { @ApiProperty({ example: 1 }) @@ -169,15 +169,15 @@ export class TotoMatchSelection { @ApiProperty({ type: [String], - example: ['1', 'X'], - description: 'Seçimler: 1=Ev, X=Beraberlik, 2=Deplasman', + example: ["1", "X"], + description: "Seçimler: 1=Ev, X=Beraberlik, 2=Deplasman", }) @IsArray() selections: TotoSelectionType[]; } export class GenerateColumnsDto { - @ApiProperty({ description: 'Bulletin ID' }) + @ApiProperty({ description: "Bulletin ID" }) @IsString() bulletinId: string; @@ -188,8 +188,8 @@ export class GenerateColumnsDto { matchSelections: TotoMatchSelection[]; @ApiPropertyOptional({ - example: 'FULL_SYSTEM', - description: 'FULL_SYSTEM | REDUCED_SYSTEM | MANUAL', + example: "FULL_SYSTEM", + description: "FULL_SYSTEM | REDUCED_SYSTEM | MANUAL", }) @IsOptional() @IsString() @@ -197,7 +197,7 @@ export class GenerateColumnsDto { @ApiPropertyOptional({ example: 100, - description: 'Max kolon sayısı (reduced system için)', + description: "Max kolon sayısı (reduced system için)", }) @IsOptional() @IsInt() @@ -207,23 +207,23 @@ export class GenerateColumnsDto { // ─── Generate AI Prediction DTO ─── export class GenerateSporTotoPredictionDto { - @ApiProperty({ description: 'Bulletin ID' }) + @ApiProperty({ description: "Bulletin ID" }) @IsString() bulletinId: string; @ApiPropertyOptional({ - example: 'BALANCED', - enum: ['CONSERVATIVE', 'BALANCED', 'AGGRESSIVE', 'FORMULA_6PCT'], + example: "BALANCED", + enum: ["CONSERVATIVE", "BALANCED", "AGGRESSIVE", "FORMULA_6PCT"], description: - 'CONSERVATIVE(100 col), BALANCED(500), AGGRESSIVE(2500), FORMULA_6PCT(%6 sampling)', + "CONSERVATIVE(100 col), BALANCED(500), AGGRESSIVE(2500), FORMULA_6PCT(%6 sampling)", }) @IsOptional() @IsString() - strategy?: 'CONSERVATIVE' | 'BALANCED' | 'AGGRESSIVE' | 'FORMULA_6PCT'; + 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.', + description: "Max bütçe (TL). Kolon sayısı buna göre sınırlanır.", }) @IsOptional() @IsNumber() @@ -231,7 +231,7 @@ export class GenerateSporTotoPredictionDto { @ApiPropertyOptional({ example: 200, - description: 'Max kolon sayısı override', + description: "Max kolon sayısı override", }) @IsOptional() @IsInt() @@ -241,14 +241,14 @@ export class GenerateSporTotoPredictionDto { // ─── Evaluate Columns DTO ─── export class EvaluateColumnsDto { - @ApiProperty({ description: 'Bulletin ID' }) + @ApiProperty({ description: "Bulletin ID" }) @IsString() bulletinId: string; @ApiProperty({ type: [String], - example: ['11X2X1XX21X1121'], - description: 'Array of 15-char column strings', + example: ["11X2X1XX21X1121"], + description: "Array of 15-char column strings", }) @IsArray() @IsString({ each: true }) diff --git a/src/modules/spor-toto/services/toto-analytics.service.ts b/src/modules/spor-toto/services/toto-analytics.service.ts index 59448cf..59b70ec 100644 --- a/src/modules/spor-toto/services/toto-analytics.service.ts +++ b/src/modules/spor-toto/services/toto-analytics.service.ts @@ -1,5 +1,5 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { PrismaService } from '../../../database/prisma.service'; +import { Injectable, Logger } from "@nestjs/common"; +import { PrismaService } from "../../../database/prisma.service"; /** * Spor Toto Analitik Servisi @@ -91,8 +91,8 @@ export class TotoAnalyticsService { consecutiveRollovers: number; }> { const bulletins = await this.prisma.totoBulletin.findMany({ - where: { status: 'COMPLETED' }, - orderBy: { gameCycleNo: 'desc' }, + where: { status: "COMPLETED" }, + orderBy: { gameCycleNo: "desc" }, take: limit, include: { result: true }, }); diff --git a/src/modules/spor-toto/services/toto-combinatorics.service.ts b/src/modules/spor-toto/services/toto-combinatorics.service.ts index a432173..d5c4fd4 100644 --- a/src/modules/spor-toto/services/toto-combinatorics.service.ts +++ b/src/modules/spor-toto/services/toto-combinatorics.service.ts @@ -1,8 +1,8 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from "@nestjs/common"; export interface TotoMatchSelectionInput { matchOrder: number; - selections: ('1' | 'X' | '2')[]; + selections: ("1" | "X" | "2")[]; } export interface GeneratedColumn { @@ -38,7 +38,7 @@ export class TotoCombinatoricsService { for (let i = 1; i <= 15; i++) { const sel = selectionsMap.get(i); if (!sel || sel.length === 0) { - orderedSelections.push(['1']); // Default: ev sahibi + orderedSelections.push(["1"]); // Default: ev sahibi } else { orderedSelections.push(sel); } @@ -56,7 +56,7 @@ export class TotoCombinatoricsService { // Tüm kombinasyonları üret const columns: GeneratedColumn[] = []; - this.generateCombinations(orderedSelections, 0, '', columns); + this.generateCombinations(orderedSelections, 0, "", columns); return columns; } diff --git a/src/modules/spor-toto/services/toto-fetcher.service.ts b/src/modules/spor-toto/services/toto-fetcher.service.ts index 48902ab..09cf1aa 100644 --- a/src/modules/spor-toto/services/toto-fetcher.service.ts +++ b/src/modules/spor-toto/services/toto-fetcher.service.ts @@ -1,5 +1,5 @@ -import { Injectable, Logger } from '@nestjs/common'; -import axios from 'axios'; +import { Injectable, Logger } from "@nestjs/common"; +import axios from "axios"; /** * Spor Toto API response types @@ -45,25 +45,25 @@ export interface SporTotoApiResponse { @Injectable() export class TotoFetcherService { private readonly logger = new Logger(TotoFetcherService.name); - private readonly apiUrl = 'https://sportotov2.iddaa.com/SporToto'; + 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...'); + 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', + Accept: "application/json", + "User-Agent": "SuggestBet/1.0", }, }); if (!response.data?.isSuccess || !response.data?.data) { this.logger.warn( - 'Spor Toto API returned unsuccessful response', + "Spor Toto API returned unsuccessful response", response.data?.message, ); return null; @@ -80,7 +80,7 @@ export class TotoFetcherService { error.response?.status, ); } else { - this.logger.error('Spor Toto fetch failed', error); + this.logger.error("Spor Toto fetch failed", error); } return null; } @@ -93,30 +93,30 @@ export class TotoFetcherService { homeTeam: string; awayTeam: string; } { - const parts = eventName.split('-'); + const parts = eventName.split("-"); if (parts.length >= 2) { return { homeTeam: parts[0].trim(), - awayTeam: parts.slice(1).join('-').trim(), + awayTeam: parts.slice(1).join("-").trim(), }; } - return { homeTeam: eventName, awayTeam: '' }; + 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 { + 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'; + 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 index 6083c69..3634823 100644 --- a/src/modules/spor-toto/services/toto-prediction.service.ts +++ b/src/modules/spor-toto/services/toto-prediction.service.ts @@ -1,23 +1,23 @@ -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 { 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'; +} from "./toto-combinatorics.service"; +import { TotoAnalyticsService } from "./toto-analytics.service"; // ═══════════ TYPES ═══════════ export type PredictionStrategy = - | 'CONSERVATIVE' - | 'BALANCED' - | 'AGGRESSIVE' - | 'FORMULA_6PCT'; + | "CONSERVATIVE" + | "BALANCED" + | "AGGRESSIVE" + | "FORMULA_6PCT"; -export type TotoSelection = '1' | 'X' | '2'; +export type TotoSelection = "1" | "X" | "2"; export interface MatchPredictionAnalysis { matchOrder: number; @@ -27,7 +27,7 @@ export interface MatchPredictionAnalysis { /** Linked matchId from DB (null if not found) */ linkedMatchId: string | null; /** AI Engine prediction source */ - predictionSource: 'AI_ENGINE' | 'HISTORICAL_FORM' | 'FALLBACK'; + predictionSource: "AI_ENGINE" | "HISTORICAL_FORM" | "FALLBACK"; /** Raw AI probabilities for each outcome */ probabilities: { home: number; draw: number; away: number }; /** AI confidence (0-100) */ @@ -62,7 +62,7 @@ export interface PredictionResult { effectivePool: number; ev15: number; evPerColumn: number; - recommendation: 'PLAY' | 'WAIT' | 'HIGH_VALUE'; + recommendation: "PLAY" | "WAIT" | "HIGH_VALUE"; recommendationReason: string; }; /** System info */ @@ -140,7 +140,7 @@ export class TotoPredictionService { private readonly analytics: TotoAnalyticsService, ) { this.aiEngineUrl = - this.configService.get('AI_ENGINE_URL') || 'http://127.0.0.1:8000'; + this.configService.get("AI_ENGINE_URL") || "http://127.0.0.1:8000"; } /** @@ -148,7 +148,7 @@ export class TotoPredictionService { */ async generatePrediction( bulletinId: string, - strategy: PredictionStrategy = 'BALANCED', + strategy: PredictionStrategy = "BALANCED", maxBudget?: number, ): Promise { const config = STRATEGY_CONFIGS[strategy]; @@ -156,7 +156,7 @@ export class TotoPredictionService { // 1. Bülteni getir const bulletin = await this.prisma.totoBulletin.findUnique({ where: { id: bulletinId }, - include: { matches: { orderBy: { matchOrder: 'asc' } } }, + include: { matches: { orderBy: { matchOrder: "asc" } } }, }); if (!bulletin) { @@ -286,10 +286,10 @@ export class TotoPredictionService { // 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ı'; + 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); @@ -298,7 +298,7 @@ export class TotoPredictionService { probabilities = aiResult.probabilities; confidence = aiResult.confidence; aiPick = aiResult.pick; - predictionSource = 'AI_ENGINE'; + predictionSource = "AI_ENGINE"; reasoning = aiResult.reasoning; } else { // AI Engine erişilemez → tarihsel form analizi @@ -306,7 +306,7 @@ export class TotoPredictionService { probabilities = formResult.probabilities; confidence = formResult.confidence; aiPick = formResult.pick; - predictionSource = 'HISTORICAL_FORM'; + predictionSource = "HISTORICAL_FORM"; reasoning = formResult.reasoning; } } else { @@ -316,7 +316,7 @@ export class TotoPredictionService { probabilities = formResult.probabilities; confidence = formResult.confidence; aiPick = formResult.pick; - predictionSource = 'HISTORICAL_FORM'; + predictionSource = "HISTORICAL_FORM"; reasoning = formResult.reasoning; } } @@ -362,7 +362,7 @@ export class TotoPredictionService { >( `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` : ''} + ${kickoffTime ? `AND ABS(CAST(mst_utc AS BIGINT) - $3) < 259200000` : ""} LIMIT 1`, `%${homeNorm}%`, `%${awayNorm}%`, @@ -384,7 +384,7 @@ export class TotoPredictionService { 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` : ''} + ${kickoffTime ? `AND ABS(CAST(mst_utc AS BIGINT) - $3) < 259200000` : ""} ORDER BY mst_utc DESC LIMIT 1`, `%${homeNorm}%`, @@ -413,14 +413,14 @@ export class TotoPredictionService { 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, ' '); + .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 ═══════════ @@ -460,9 +460,9 @@ export class TotoPredictionService { }> ).find( (b) => - b.market?.toLowerCase().includes('maç sonucu') || - b.market?.toLowerCase().includes('match result') || - b.market === '1X2', + b.market?.toLowerCase().includes("maç sonucu") || + b.market?.toLowerCase().includes("match result") || + b.market === "1X2", ); // Score prediction'dan olasılıklar çıkar @@ -495,23 +495,23 @@ export class TotoPredictionService { } // Pick'i Toto formatına çevir - let pick: TotoSelection = '1'; + let pick: TotoSelection = "1"; if (msPick) { const rawPick = msPick.pick?.toLowerCase(); if ( - rawPick?.includes('2') || - rawPick?.includes('away') || - rawPick?.includes('deplasman') + rawPick?.includes("2") || + rawPick?.includes("away") || + rawPick?.includes("deplasman") ) { - pick = '2'; + pick = "2"; } else if ( - rawPick?.includes('x') || - rawPick?.includes('draw') || - rawPick?.includes('beraberlik') + rawPick?.includes("x") || + rawPick?.includes("draw") || + rawPick?.includes("beraberlik") ) { - pick = 'X'; + pick = "X"; } else { - pick = '1'; + pick = "1"; } } else { // No explicit MS pick → use probabilities @@ -519,16 +519,16 @@ export class TotoPredictionService { probabilities.away > probabilities.home && probabilities.away > probabilities.draw ) { - pick = '2'; + pick = "2"; } else if (probabilities.draw > probabilities.home) { - pick = 'X'; + pick = "X"; } } const confidence = Math.round( (msPick?.calibrated_confidence ?? msPick?.confidence ?? 50) * (typeof (msPick?.calibrated_confidence ?? msPick?.confidence) === - 'number' && + "number" && (msPick?.calibrated_confidence ?? msPick?.confidence ?? 0) <= 1 ? 100 : 1), @@ -542,7 +542,7 @@ export class TotoPredictionService { pick, reasoning: reasons.length > 0 - ? reasons.join(' | ') + ? reasons.join(" | ") : `AI Engine: ${pick} (confidence: ${confidence}%)`, }; } catch (error) { @@ -596,21 +596,21 @@ export class TotoPredictionService { return { probabilities: { home: 0.33, draw: 0.33, away: 0.34 }, confidence: 33, - pick: '1', - reasoning: 'Tarihsel veri bulunamadı, eşit dağılım', + 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 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 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 @@ -630,14 +630,14 @@ export class TotoPredictionService { }; // En yüksek olasılık - let pick: TotoSelection = '1'; + let pick: TotoSelection = "1"; if ( probabilities.away > probabilities.home && probabilities.away > probabilities.draw ) { - pick = '2'; + pick = "2"; } else if (probabilities.draw > probabilities.home) { - pick = 'X'; + pick = "X"; } const confidence = Math.round( @@ -656,8 +656,8 @@ export class TotoPredictionService { 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', + pick: "1", + reasoning: "Form analizi yapılamadı, eşit dağılım", }; } } @@ -685,9 +685,9 @@ export class TotoPredictionService { } { // 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 }, + { 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; @@ -724,7 +724,7 @@ export class TotoPredictionService { contrarianReasoning = `İkili: ${probs[0].pick} + ${probs[1].pick} — Orta güven, varyans koruması`; } else { // Düşük güven → üçlü kapatma - selections = ['1', 'X', '2']; + selections = ["1", "X", "2"]; contrarianReasoning = `Kapatma: 1X2 — Düşük güven (${confidence}%), maç çok belirsiz`; } @@ -741,7 +741,7 @@ export class TotoPredictionService { rolloverAmount: number, columnCount: number, totalCost: number, - ): Promise { + ): Promise { const effectivePool = poolTotal + rolloverAmount; const distribution = this.analytics.calculatePoolDistribution(effectivePool); @@ -765,20 +765,20 @@ export class TotoPredictionService { } // Karar - let recommendation: PredictionResult['evReport']['recommendation']; + let recommendation: PredictionResult["evReport"]["recommendation"]; let recommendationReason: string; if (rolloverAmount > 50_000_000) { - recommendation = 'HIGH_VALUE'; + 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'; + 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'; + recommendation = "PLAY"; recommendationReason = `✅ Orta düzey devir: ${(rolloverAmount / 1_000_000).toFixed(1)}M TL`; } else { - recommendation = 'WAIT'; + recommendation = "WAIT"; recommendationReason = `⏳ Devir düşük (${(rolloverAmount / 1_000_000).toFixed(1)}M TL). Havuz büyümesini bekle.`; } diff --git a/src/modules/spor-toto/spor-toto.controller.ts b/src/modules/spor-toto/spor-toto.controller.ts index dc76b9e..98de84c 100644 --- a/src/modules/spor-toto/spor-toto.controller.ts +++ b/src/modules/spor-toto/spor-toto.controller.ts @@ -10,7 +10,7 @@ import { HttpStatus, Logger, UseGuards, -} from '@nestjs/common'; +} from "@nestjs/common"; import { ApiTags, ApiOperation, @@ -19,21 +19,21 @@ import { ApiParam, ApiBody, ApiBearerAuth, -} from '@nestjs/swagger'; -import { SporTotoService } from './spor-toto.service'; +} 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'; +} 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') +@ApiTags("Spor Toto") +@Controller("spor-toto") export class SporTotoController { private readonly logger = new Logger(SporTotoController.name); @@ -41,51 +41,51 @@ export class SporTotoController { // ═══════════ BULLETINS ═══════════ - @Post('sync') + @Post("sync") @UseGuards(JwtAuthGuard) - @Roles('admin') + @Roles("admin") @ApiBearerAuth() @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'Sync current bulletin from Spor Toto API', + 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.', + "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)', + description: "Sync result with action (created/updated/unchanged)", }) async syncFromApi() { const result = await this.sporTotoService.syncFromApi(); return { success: true, data: result }; } - @Get('bulletins') + @Get("bulletins") @Public() @ApiOperation({ - summary: 'List Spor Toto bulletins', + summary: "List Spor Toto bulletins", description: - 'Returns a paginated list of bulletins, optionally filtered by status.', + "Returns a paginated list of bulletins, optionally filtered by status.", }) @ApiQuery({ - name: 'status', + name: "status", required: false, enum: TotoBulletinStatus, - description: 'Filter by bulletin status', + description: "Filter by bulletin status", }) @ApiQuery({ - name: 'limit', + name: "limit", required: false, type: Number, - description: 'Max results (default: 10)', + description: "Max results (default: 10)", }) @ApiResponse({ status: 200, - description: 'Array of bulletins with matches and results', + description: "Array of bulletins with matches and results", }) async listBulletins( - @Query('status') status?: TotoBulletinStatus, - @Query('limit') limit?: string, + @Query("status") status?: TotoBulletinStatus, + @Query("limit") limit?: string, ) { const bulletins = await this.sporTotoService.listBulletins( status, @@ -94,95 +94,95 @@ export class SporTotoController { return { success: true, data: bulletins }; } - @Get('bulletins/:id') + @Get("bulletins/:id") @Public() @ApiOperation({ - summary: 'Get bulletin details', + summary: "Get bulletin details", description: - 'Returns a single bulletin with all 15 matches, results, and dividend info.', + "Returns a single bulletin with all 15 matches, results, and dividend info.", }) - @ApiParam({ name: 'id', description: 'Bulletin UUID' }) + @ApiParam({ name: "id", description: "Bulletin UUID" }) @ApiResponse({ status: 200, - description: 'Bulletin with matches and results', + description: "Bulletin with matches and results", }) - @ApiResponse({ status: 404, description: 'Bulletin not found' }) - async getBulletin(@Param('id') id: string) { + @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') + @Post("bulletins") @UseGuards(JwtAuthGuard) - @Roles('admin') + @Roles("admin") @ApiBearerAuth() @HttpCode(HttpStatus.CREATED) @ApiOperation({ - summary: 'Create a bulletin manually', + summary: "Create a bulletin manually", description: - 'Creates a new bulletin with 15 matches. Fails if gameCycleNo already exists.', + "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: 201, description: "Created bulletin with matches" }) @ApiResponse({ status: 409, - description: 'Bulletin with this gameCycleNo already exists', + 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') + @Patch("bulletins/:id/results") @UseGuards(JwtAuthGuard) - @Roles('admin') + @Roles("admin") @ApiBearerAuth() @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'Update bulletin match results', + 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.', + "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' }) + @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) { + @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') + @Get("bulletins/:id/stats") @Public() @ApiOperation({ - summary: 'Get bulletin pool & EV statistics', + summary: "Get bulletin pool & EV statistics", description: - 'Returns pool distribution (35/20/20/25), expected value calculations, and rollover analysis for a bulletin.', + "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) { + @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') + @Get("history") @Public() @ApiOperation({ - summary: 'Get rollover history and trends', + summary: "Get rollover history and trends", description: - 'Returns the last N bulletins with rollover amounts and consecutive rollover streak.', + "Returns the last N bulletins with rollover amounts and consecutive rollover streak.", }) @ApiQuery({ - name: 'limit', + name: "limit", required: false, type: Number, - description: 'Number of results (default: 20)', + description: "Number of results (default: 20)", }) - @ApiResponse({ status: 200, description: 'Rollover history with trend data' }) - async getRolloverHistory(@Query('limit') limit?: string) { + @ApiResponse({ status: 200, description: "Rollover history with trend data" }) + async getRolloverHistory(@Query("limit") limit?: string) { const history = await this.sporTotoService.getRolloverHistory( Number(limit) || 20, ); @@ -191,38 +191,38 @@ export class SporTotoController { // ═══════════ COLUMNS ═══════════ - @Post('columns/generate') + @Post("columns/generate") @UseGuards(JwtAuthGuard) @ApiBearerAuth() @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'Generate Spor Toto columns (full or reduced system)', + 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.', + "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', + 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') + @Post("columns/evaluate") @UseGuards(JwtAuthGuard) @ApiBearerAuth() @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'Evaluate columns against results', + 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).', + "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', + description: "Evaluation results with correct counts per column", }) async evaluateColumns(@Body() dto: EvaluateColumnsDto) { const result = await this.sporTotoService.evaluateColumns( @@ -234,24 +234,24 @@ export class SporTotoController { // ═══════════ AI PREDICTION ═══════════ - @Post('predict') + @Post("predict") @UseGuards(JwtAuthGuard) @ApiBearerAuth() @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'Generate AI predictions with contrarian strategy', + 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).', + "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', + "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'}`, + `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 index 8ca030e..5f45cf0 100644 --- a/src/modules/spor-toto/spor-toto.module.ts +++ b/src/modules/spor-toto/spor-toto.module.ts @@ -1,13 +1,13 @@ -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'; +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], diff --git a/src/modules/spor-toto/spor-toto.service.ts b/src/modules/spor-toto/spor-toto.service.ts index 1c38bce..eb1f4a1 100644 --- a/src/modules/spor-toto/spor-toto.service.ts +++ b/src/modules/spor-toto/spor-toto.service.ts @@ -3,25 +3,25 @@ import { Logger, NotFoundException, ConflictException, -} from '@nestjs/common'; -import { PrismaService } from '../../database/prisma.service'; -import { TotoFetcherService } from './services/toto-fetcher.service'; +} 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'; +} from "./services/toto-combinatorics.service"; +import { TotoAnalyticsService } from "./services/toto-analytics.service"; import { TotoPredictionService, PredictionStrategy, -} from './services/toto-prediction.service'; +} from "./services/toto-prediction.service"; import { CreateBulletinDto, UpdateResultsDto, GenerateColumnsDto, GenerateSporTotoPredictionDto, -} from './dto/spor-toto.dto'; -import { TotoBulletinStatus, TotoMatchResult, Prisma } from '@prisma/client'; +} from "./dto/spor-toto.dto"; +import { TotoBulletinStatus, TotoMatchResult, Prisma } from "@prisma/client"; @Injectable() export class SporTotoService { @@ -41,13 +41,13 @@ export class SporTotoService { * Fetch and sync current bulletin from Spor Toto API */ async syncFromApi(): Promise<{ - action: 'created' | 'updated' | 'unchanged'; + 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'); + throw new NotFoundException("Spor Toto API returned no data"); } const apiData = apiResponse.data; @@ -84,7 +84,7 @@ export class SporTotoService { // Check if all matches have results → mark COMPLETED const allHaveResults = apiData.events.every((e) => e.winner !== null); - if (allHaveResults && existing.status !== 'COMPLETED') { + if (allHaveResults && existing.status !== "COMPLETED") { await this.prisma.totoBulletin.update({ where: { id: existing.id }, data: { status: TotoBulletinStatus.COMPLETED }, @@ -93,7 +93,7 @@ export class SporTotoService { } return { - action: hasChanges ? 'updated' : 'unchanged', + action: hasChanges ? "updated" : "unchanged", gameCycleNo: apiData.gameCycleNo, matchCount: apiData.events.length, }; @@ -132,7 +132,7 @@ export class SporTotoService { ); return { - action: 'created', + action: "created", gameCycleNo: apiData.gameCycleNo, matchCount: matchData.length, }; @@ -187,10 +187,10 @@ export class SporTotoService { return this.prisma.totoBulletin.findMany({ where, - orderBy: { gameCycleNo: 'desc' }, + orderBy: { gameCycleNo: "desc" }, take: limit, include: { - matches: { orderBy: { matchOrder: 'asc' } }, + matches: { orderBy: { matchOrder: "asc" } }, result: true, }, }); @@ -203,13 +203,13 @@ export class SporTotoService { const bulletin = await this.prisma.totoBulletin.findUnique({ where: { id }, include: { - matches: { orderBy: { matchOrder: 'asc' } }, + matches: { orderBy: { matchOrder: "asc" } }, result: true, }, }); if (!bulletin) { - throw new NotFoundException('Bulletin not found'); + throw new NotFoundException("Bulletin not found"); } return bulletin; @@ -227,7 +227,7 @@ export class SporTotoService { }); if (!bulletin) { - throw new NotFoundException('Bulletin not found'); + throw new NotFoundException("Bulletin not found"); } // Update individual match results @@ -347,10 +347,10 @@ export class SporTotoService { this.combinatorics.calculateColumnCount(matchSelections); let columns; - const strategy = dto.strategy || 'FULL_SYSTEM'; + const strategy = dto.strategy || "FULL_SYSTEM"; if ( - strategy === 'REDUCED_SYSTEM' && + strategy === "REDUCED_SYSTEM" && dto.maxColumns && totalColumnCount > dto.maxColumns ) { @@ -381,33 +381,33 @@ export class SporTotoService { async evaluateColumns(bulletinId: string, columnPredictions: string[]) { const bulletin = await this.prisma.totoBulletin.findUnique({ where: { id: bulletinId }, - include: { matches: { orderBy: { matchOrder: 'asc' } } }, + include: { matches: { orderBy: { matchOrder: "asc" } } }, }); if (!bulletin) { - throw new NotFoundException('Bulletin not found'); + throw new NotFoundException("Bulletin not found"); } // Build results string (15 chars) const resultMap: Record = { - HOME: '1', - DRAW: 'X', - AWAY: '2', + HOME: "1", + DRAW: "X", + AWAY: "2", }; const resultsString = bulletin.matches .map((m) => { if (m.isCancelled && m.drawResult) { - return resultMap[m.drawResult] || '?'; + return resultMap[m.drawResult] || "?"; } - return m.result ? resultMap[m.result] || '?' : '?'; + return m.result ? resultMap[m.result] || "?" : "?"; }) - .join(''); + .join(""); - if (resultsString.includes('?')) { + if (resultsString.includes("?")) { return { complete: false, - message: 'Bazı maçların sonuçları henüz girilmedi', + message: "Bazı maçların sonuçları henüz girilmedi", resultsString, evaluations: [], }; @@ -452,7 +452,7 @@ export class SporTotoService { * AI Engine ile akıllı sistem kuponu üret */ async generatePrediction(dto: GenerateSporTotoPredictionDto) { - const strategy: PredictionStrategy = dto.strategy || 'BALANCED'; + const strategy: PredictionStrategy = dto.strategy || "BALANCED"; return this.prediction.generatePrediction( dto.bulletinId, strategy, diff --git a/src/modules/users/dto/user.dto.ts b/src/modules/users/dto/user.dto.ts index 1ffa0ab..b1dee37 100755 --- a/src/modules/users/dto/user.dto.ts +++ b/src/modules/users/dto/user.dto.ts @@ -4,25 +4,25 @@ import { IsOptional, IsBoolean, MinLength, -} from 'class-validator'; -import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger"; export class CreateUserDto { - @ApiPropertyOptional({ example: 'user@example.com' }) + @ApiPropertyOptional({ example: "user@example.com" }) @IsEmail() email: string; - @ApiPropertyOptional({ example: 'password123', minLength: 8 }) + @ApiPropertyOptional({ example: "password123", minLength: 8 }) @IsString() @MinLength(8) password: string; - @ApiPropertyOptional({ example: 'John' }) + @ApiPropertyOptional({ example: "John" }) @IsOptional() @IsString() firstName?: string; - @ApiPropertyOptional({ example: 'Doe' }) + @ApiPropertyOptional({ example: "Doe" }) @IsOptional() @IsString() lastName?: string; @@ -34,12 +34,12 @@ export class CreateUserDto { } export class UpdateUserDto extends PartialType(CreateUserDto) { - @ApiPropertyOptional({ example: 'John' }) + @ApiPropertyOptional({ example: "John" }) @IsOptional() @IsString() firstName?: string; - @ApiPropertyOptional({ example: 'Doe' }) + @ApiPropertyOptional({ example: "Doe" }) @IsOptional() @IsString() lastName?: string; @@ -51,29 +51,29 @@ export class UpdateUserDto extends PartialType(CreateUserDto) { } export class UpdateProfileDto { - @ApiPropertyOptional({ example: 'John' }) + @ApiPropertyOptional({ example: "John" }) @IsOptional() @IsString() firstName?: string; - @ApiPropertyOptional({ example: 'Doe' }) + @ApiPropertyOptional({ example: "Doe" }) @IsOptional() @IsString() lastName?: string; } export class ChangePasswordDto { - @ApiProperty({ example: 'oldPassword123' }) + @ApiProperty({ example: "oldPassword123" }) @IsString() currentPassword: string; - @ApiProperty({ example: 'newPassword456', minLength: 8 }) + @ApiProperty({ example: "newPassword456", minLength: 8 }) @IsString() @MinLength(8) newPassword: string; } -import { Exclude, Expose } from 'class-transformer'; +import { Exclude, Expose } from "class-transformer"; @Exclude() export class UserResponseDto { diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 0c16a81..ba38fa6 100755 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -1,27 +1,27 @@ -import { Controller, Get, Put, Patch, Body } from '@nestjs/common'; +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'; +} 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'; +} from "./dto/user.dto"; +import { CurrentUser, Roles } from "../../common/decorators"; import { ApiResponse, createSuccessResponse, -} from '../../common/types/api-response.type'; -import { User } from '@prisma/client'; +} from "../../common/types/api-response.type"; +import { User } from "@prisma/client"; -import { plainToInstance } from 'class-transformer'; -import { UserResponseDto } from './dto/user.dto'; +import { plainToInstance } from "class-transformer"; +import { UserResponseDto } from "./dto/user.dto"; interface AuthenticatedUser { id: string; @@ -29,20 +29,20 @@ interface AuthenticatedUser { role: string; } -@ApiTags('Users') +@ApiTags("Users") @ApiBearerAuth() -@Controller('users') +@Controller("users") export class UsersController extends BaseController< User, CreateUserDto, UpdateUserDto > { constructor(private readonly usersService: UsersService) { - super(usersService, 'User'); + super(usersService, "User"); } - @Get('me') - @ApiOperation({ summary: 'Get current authenticated user profile' }) + @Get("me") + @ApiOperation({ summary: "Get current authenticated user profile" }) @ApiOkResponse({ type: UserResponseDto }) async getMe( @CurrentUser() user: AuthenticatedUser, @@ -50,12 +50,12 @@ export class UsersController extends BaseController< const fullUser = await this.usersService.findOneWithDetails(user.id); return createSuccessResponse( plainToInstance(UserResponseDto, fullUser), - 'User profile retrieved successfully', + "User profile retrieved successfully", ); } - @Put('me') - @ApiOperation({ summary: 'Update current user profile' }) + @Put("me") + @ApiOperation({ summary: "Update current user profile" }) @ApiOkResponse({ type: UserResponseDto }) async updateMe( @CurrentUser() user: AuthenticatedUser, @@ -64,13 +64,13 @@ export class UsersController extends BaseController< const updatedUser = await this.usersService.updateProfile(user.id, dto); return createSuccessResponse( plainToInstance(UserResponseDto, updatedUser), - 'User profile updated successfully', + "User profile updated successfully", ); } - @Patch('me/password') - @ApiOperation({ summary: 'Change current user password' }) - @ApiOkResponse({ description: 'Password changed successfully' }) + @Patch("me/password") + @ApiOperation({ summary: "Change current user password" }) + @ApiOkResponse({ description: "Password changed successfully" }) async changePassword( @CurrentUser() user: AuthenticatedUser, @Body() dto: ChangePasswordDto, @@ -80,24 +80,24 @@ export class UsersController extends BaseController< dto.currentPassword, dto.newPassword, ); - return createSuccessResponse(null, 'Password changed successfully'); + return createSuccessResponse(null, "Password changed successfully"); } // Override create to require admin role - @Roles('admin') + @Roles("admin") async create( ...args: Parameters< - BaseController['create'] + BaseController["create"] > ) { return super.create(...args); } // Override delete to require admin role - @Roles('admin') + @Roles("admin") async delete( ...args: Parameters< - BaseController['delete'] + BaseController["delete"] > ) { return super.delete(...args); diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index 513776d..1a5cff0 100755 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -1,6 +1,6 @@ -import { Module } from '@nestjs/common'; -import { UsersController } from './users.controller'; -import { UsersService } from './users.service'; +import { Module } from "@nestjs/common"; +import { UsersController } from "./users.controller"; +import { UsersService } from "./users.service"; @Module({ controllers: [UsersController], diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 67093c3..5dc0a21 100755 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -2,12 +2,12 @@ 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'; +} 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< @@ -16,7 +16,7 @@ export class UsersService extends BaseService< UpdateUserDto > { constructor(prisma: PrismaService) { - super(prisma, 'User'); + super(prisma, "User"); } /** @@ -26,7 +26,7 @@ export class UsersService extends BaseService< // Check if email already exists const existingUser = await this.findOneBy({ email: dto.email }); if (existingUser) { - throw new ConflictException('EMAIL_ALREADY_EXISTS'); + throw new ConflictException("EMAIL_ALREADY_EXISTS"); } // Hash password @@ -177,7 +177,7 @@ export class UsersService extends BaseService< }); if (!user) { - throw new UnauthorizedException('USER_NOT_FOUND'); + throw new UnauthorizedException("USER_NOT_FOUND"); } const isCurrentPasswordValid = await this.comparePassword( @@ -186,7 +186,7 @@ export class UsersService extends BaseService< ); if (!isCurrentPasswordValid) { - throw new UnauthorizedException('INVALID_CURRENT_PASSWORD'); + throw new UnauthorizedException("INVALID_CURRENT_PASSWORD"); } const hashedNewPassword = await this.hashPassword(newPassword); diff --git a/src/scripts/backtest-accuracy.ts b/src/scripts/backtest-accuracy.ts index a8793ac..3106836 100644 --- a/src/scripts/backtest-accuracy.ts +++ b/src/scripts/backtest-accuracy.ts @@ -9,8 +9,8 @@ * Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/backtest-accuracy.ts */ -import { PrismaClient } from '@prisma/client'; -import axios from 'axios'; +import { PrismaClient } from "@prisma/client"; +import axios from "axios"; const prisma = new PrismaClient(); @@ -18,7 +18,7 @@ const prisma = new PrismaClient(); // Configuration // ═══════════════════════════════════════════════════════ -const AI_ENGINE_URL = process.env.AI_ENGINE_URL || 'http://127.0.0.1:3005'; +const AI_ENGINE_URL = process.env.AI_ENGINE_URL || "http://127.0.0.1:3005"; const CONCURRENT_REQUESTS = 5; const MAX_MATCHES = 1000; @@ -60,14 +60,14 @@ function determineActualOutcome( 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'; + 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'; + let htft = "unknown"; if (htScoreHome !== null && htScoreAway !== null) { const htResult = - htScoreHome > htScoreAway ? '1' : htScoreHome < htScoreAway ? '2' : 'X'; + htScoreHome > htScoreAway ? "1" : htScoreHome < htScoreAway ? "2" : "X"; htft = `${htResult}/${ms}`; } @@ -78,7 +78,7 @@ function extractPrediction(response: unknown): { ms: string; ou25: string; btts: string; - probs: BacktestResult['probabilities']; + probs: BacktestResult["probabilities"]; mainPick: string; mainMarket: string; } { @@ -87,9 +87,9 @@ function extractPrediction(response: unknown): { const mainPickObj = data?.main_pick as Record | undefined; const mainPick = - typeof mainPickObj?.pick === 'string' ? mainPickObj.pick : ''; + typeof mainPickObj?.pick === "string" ? mainPickObj.pick : ""; const mainMarket = - typeof mainPickObj?.market === 'string' ? mainPickObj.market : ''; + typeof mainPickObj?.market === "string" ? mainPickObj.market : ""; // Extract MS from probabilities or main pick const msProbs = (predictions?.ms || data?.ms || {}) as Record< @@ -97,27 +97,27 @@ function extractPrediction(response: unknown): { unknown >; const homeProb = - typeof msProbs['1'] === 'number' - ? msProbs['1'] - : typeof msProbs.home_prob === 'number' + 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' + 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' + 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'; + 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< @@ -125,18 +125,18 @@ function extractPrediction(response: unknown): { unknown >; const overProb = - typeof ou25Probs.Over === 'number' + typeof ou25Probs.Over === "number" ? ou25Probs.Over - : typeof ou25Probs.over_prob === 'number' + : typeof ou25Probs.over_prob === "number" ? ou25Probs.over_prob : 0; const underProb = - typeof ou25Probs.Under === 'number' + typeof ou25Probs.Under === "number" ? ou25Probs.Under - : typeof ou25Probs.under_prob === 'number' + : typeof ou25Probs.under_prob === "number" ? ou25Probs.under_prob : 0; - const ou25 = overProb > underProb ? 'Over' : 'Under'; + const ou25 = overProb > underProb ? "Over" : "Under"; // Extract BTTS const bttsProbs = (predictions?.btts || data?.btts || {}) as Record< @@ -144,18 +144,18 @@ function extractPrediction(response: unknown): { unknown >; const bttsYes = - typeof bttsProbs.Yes === 'number' + typeof bttsProbs.Yes === "number" ? bttsProbs.Yes - : typeof bttsProbs.yes_prob === 'number' + : typeof bttsProbs.yes_prob === "number" ? bttsProbs.yes_prob : 0; const bttsNo = - typeof bttsProbs.No === 'number' + typeof bttsProbs.No === "number" ? bttsProbs.No - : typeof bttsProbs.no_prob === 'number' + : typeof bttsProbs.no_prob === "number" ? bttsProbs.no_prob : 0; - const btts = bttsYes > bttsNo ? 'Yes' : 'No'; + const btts = bttsYes > bttsNo ? "Yes" : "No"; return { ms, @@ -197,11 +197,11 @@ async function processBatch(batch: TestMatch[]): Promise { // Check main pick let mainPickCorrect = false; - if (pred.mainMarket === 'MS') { + if (pred.mainMarket === "MS") { mainPickCorrect = pred.mainPick === actual.ms; - } else if (pred.mainMarket === 'OU25') { + } else if (pred.mainMarket === "OU25") { mainPickCorrect = pred.mainPick === actual.ou25; - } else if (pred.mainMarket === 'BTTS') { + } else if (pred.mainMarket === "BTTS") { mainPickCorrect = pred.mainPick === actual.btts; } @@ -226,8 +226,8 @@ async function processBatch(batch: TestMatch[]): Promise { // ═══════════════════════════════════════════════════════ async function runBacktest(): Promise { - console.log('🎯 BACKTEST ACCURACY — V30 Betting Engine'); - console.log('════════════════════════════════════════════════════════'); + console.log("🎯 BACKTEST ACCURACY — V30 Betting Engine"); + console.log("════════════════════════════════════════════════════════"); // 1. Health check try { @@ -236,12 +236,12 @@ async function runBacktest(): Promise { }); console.log(`✅ AI Engine: ${JSON.stringify(health.data)}`); } catch { - console.error('❌ AI Engine not reachable at', AI_ENGINE_URL); + 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...'); + 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" @@ -259,7 +259,7 @@ async function runBacktest(): Promise { console.log(` 📊 Test matches: ${matches.length}`); // 3. Run predictions in batches - console.log('\n🤖 Running predictions...'); + console.log("\n🤖 Running predictions..."); const allResults: BacktestResult[] = []; let processed = 0; @@ -277,7 +277,7 @@ async function runBacktest(): Promise { allResults.length) * 100 ).toFixed(1) - : '0'; + : "0"; console.log( ` 📊 ${processed}/${matches.length} — Success: ${allResults.length} — MS Acc: ${currentMsAcc}%`, ); @@ -287,7 +287,7 @@ async function runBacktest(): Promise { // 4. Calculate metrics const total = allResults.length; if (total === 0) { - console.error('❌ No results to analyze'); + console.error("❌ No results to analyze"); process.exit(1); } @@ -303,22 +303,22 @@ async function runBacktest(): Promise { 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; + 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; + 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 }, + "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) { @@ -329,25 +329,25 @@ async function runBacktest(): Promise { ); const key = maxProb >= 0.7 - ? '70%+' + ? "70%+" : maxProb >= 0.6 - ? '60-70%' + ? "60-70%" : maxProb >= 0.5 - ? '50-60%' + ? "50-60%" : maxProb >= 0.4 - ? '40-50%' - : '33-40%'; + ? "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("\n════════════════════════════════════════════════════════"); + console.log("📊 BACKTEST ACCURACY REPORT"); + console.log("════════════════════════════════════════════════════════"); console.log(` Total Matches Analyzed: ${total}`); - console.log(''); - console.log(' 🎯 Market Accuracy:'); + console.log(""); + console.log(" 🎯 Market Accuracy:"); console.log( ` ⚽ Match Result (MS): ${((msCorrect / total) * 100).toFixed(2)}% (${msCorrect}/${total})`, ); @@ -361,7 +361,7 @@ async function runBacktest(): Promise { ` 🏆 Main Pick Success: ${((mainPickCorrect / total) * 100).toFixed(2)}% (${mainPickCorrect}/${total})`, ); - console.log('\n 📊 MS Distribution:'); + 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)}%)`, ); @@ -369,21 +369,21 @@ async function runBacktest(): Promise { ` 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:'); + 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)); + 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; + 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 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)}%)`, @@ -392,11 +392,11 @@ async function runBacktest(): Promise { ` 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; + 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', + (r) => r.predicted.btts === "Yes", ).length; const predBttsNo = total - predBttsYes; console.log( @@ -406,14 +406,14 @@ async function runBacktest(): Promise { ` Predicted: Yes: ${predBttsYes} (${((predBttsYes / total) * 100).toFixed(1)}%) | No: ${predBttsNo} (${((predBttsNo / total) * 100).toFixed(1)}%)`, ); - console.log('════════════════════════════════════════════════════════'); - console.log('✅ Backtest complete!'); + console.log("════════════════════════════════════════════════════════"); + console.log("✅ Backtest complete!"); await prisma.$disconnect(); } runBacktest().catch((err: unknown) => { - console.error('❌ Backtest failed:', err); + 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 index 47cb4b2..1e3c805 100644 --- a/src/scripts/batch-predict.ts +++ b/src/scripts/batch-predict.ts @@ -9,18 +9,18 @@ * Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/batch-predict.ts */ -import { PrismaClient } from '@prisma/client'; -import axios from 'axios'; +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 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('════════════════════════════════════════════════════════'); + console.log("🗓 BATCH PREDICTION PIPELINE STARTING"); + console.log("════════════════════════════════════════════════════════"); // 1. Health check try { @@ -30,20 +30,20 @@ async function runBatchPrediction() { 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); + 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', + status: "NS", mstUtc: { gte: Math.floor(Date.now() / 1000), // Future matches }, - sport: 'football', + sport: "football", }, - orderBy: { mstUtc: 'asc' }, + orderBy: { mstUtc: "asc" }, take: MAX_MATCHES_TO_PROCESS, select: { id: true, @@ -105,7 +105,7 @@ async function runBatchPrediction() { const err = e as Error; console.error( ` ❌ Failed for match ${match.id}:`, - err?.message || 'Unknown error', + err?.message || "Unknown error", ); return false; } @@ -116,12 +116,12 @@ async function runBatchPrediction() { processedCount += batch.length; } - console.log('\n════════════════════════════════════════════════════════'); + 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('════════════════════════════════════════════════════════'); + console.log("════════════════════════════════════════════════════════"); await prisma.$disconnect(); } diff --git a/src/scripts/check-duplicate-matches.ts b/src/scripts/check-duplicate-matches.ts index a2eaaa7..8f29640 100755 --- a/src/scripts/check-duplicate-matches.ts +++ b/src/scripts/check-duplicate-matches.ts @@ -1,9 +1,9 @@ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { - console.log('🔍 Checking for potential duplicate matches...'); + 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, @@ -35,7 +35,7 @@ async function main() { if (duplicates.length === 0) { console.log( - '✅ No duplicate matches found based on (HomeTeam + AwayTeam + Date).', + "✅ No duplicate matches found based on (HomeTeam + AwayTeam + Date).", ); return; } @@ -56,7 +56,7 @@ async function main() { console.log( `📅 ${date} | ${homeTeam?.name} vs ${awayTeam?.name} (Count: ${group.count})`, ); - console.log(` IDs: ${group.ids.join(', ')}`); + 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) { @@ -73,20 +73,20 @@ async function main() { if (match) { const counts = [ - match.oddCategories.length > 0 ? 'Odds' : '', - match.footballTeamStats.length > 0 ? 'Stats' : '', - match.playerEvents.length > 0 ? 'Events' : '', - match.officials.length > 0 ? 'Officials' : '', + match.oddCategories.length > 0 ? "Odds" : "", + match.footballTeamStats.length > 0 ? "Stats" : "", + match.playerEvents.length > 0 ? "Events" : "", + match.officials.length > 0 ? "Officials" : "", ] .filter(Boolean) - .join(', '); + .join(", "); console.log( - ` - [${id}] Status: ${match.status} | Score: ${match.scoreHome}-${match.scoreAway} | Data: ${counts || 'None'}`, + ` - [${id}] Status: ${match.status} | Score: ${match.scoreHome}-${match.scoreAway} | Data: ${counts || "None"}`, ); } } - console.log('---------------------------------------------------'); + console.log("---------------------------------------------------"); } } diff --git a/src/scripts/cleanup-live-matches.ts b/src/scripts/cleanup-live-matches.ts index f40931e..88cc0c9 100755 --- a/src/scripts/cleanup-live-matches.ts +++ b/src/scripts/cleanup-live-matches.ts @@ -5,29 +5,29 @@ * Kullanım: npx ts-node -r tsconfig-paths/register src/scripts/cleanup-live-matches.ts */ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient } from "@prisma/client"; -const FINISHED_STATUSES = ['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended']; -const FINISHED_STATES = ['Finished', 'post', 'FT', 'postGame']; +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', + "LIVE", + "1H", + "2H", + "HT", + "1Q", + "2Q", + "3Q", + "4Q", + "Playing", + "Half Time", ]; -const LIVE_STATES = ['live', 'firsthalf', 'secondhalf']; +const LIVE_STATES = ["live", "firsthalf", "secondhalf"]; async function cleanupLiveMatches() { const prisma = new PrismaClient(); try { - console.log('🧹 Live matches temizliği başlıyor...'); + console.log("🧹 Live matches temizliği başlıyor..."); const now = Date.now(); const finishedGraceMs = 6 * 60 * 60 * 1000; @@ -51,7 +51,7 @@ async function cleanupLiveMatches() { }, }); - console.log('📊 Mevcut durum:'); + console.log("📊 Mevcut durum:"); console.log(` Toplam live_matches: ${totalBefore}`); console.log(` Geçmiş zamanlı kayıt: ${outdatedCount}`); console.log( @@ -83,7 +83,7 @@ async function cleanupLiveMatches() { const totalAfter = await prisma.liveMatch.count(); - console.log('\n✅ Temizlik tamamlandı!'); + console.log("\n✅ Temizlik tamamlandı!"); console.log(` Silinen maç: ${deleted.count}`); console.log(` Kalan maç: ${totalAfter}`); @@ -93,12 +93,12 @@ async function cleanupLiveMatches() { GROUP BY state `; - console.log('\n📋 Kalan maçların durumları:'); + console.log("\n📋 Kalan maçların durumları:"); (states as any).forEach((s: any) => { - console.log(` ${s.state || 'null'}: ${s.count}`); + console.log(` ${s.state || "null"}: ${s.count}`); }); } catch (error) { - console.error('❌ Hata:', error); + console.error("❌ Hata:", error); } finally { await prisma.$disconnect(); } diff --git a/src/scripts/compute-elo-ratings.ts b/src/scripts/compute-elo-ratings.ts index d098907..5a9ad2d 100644 --- a/src/scripts/compute-elo-ratings.ts +++ b/src/scripts/compute-elo-ratings.ts @@ -12,7 +12,7 @@ * Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/compute-elo-ratings.ts */ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient } from "@prisma/client"; // ───────────────────────────────────────────────────────────── // Types @@ -72,9 +72,9 @@ function getResultChar( scoreAway: number, isHomeTeam: boolean, ): string { - if (scoreHome > scoreAway) return isHomeTeam ? 'W' : 'L'; - if (scoreHome < scoreAway) return isHomeTeam ? 'L' : 'W'; - return 'D'; + if (scoreHome > scoreAway) return isHomeTeam ? "W" : "L"; + if (scoreHome < scoreAway) return isHomeTeam ? "L" : "W"; + return "D"; } function calculateFormElo(recentResults: string[]): number { @@ -86,7 +86,7 @@ function calculateFormElo(recentResults: string[]): number { 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; + const score = result === "W" ? 3 : result === "D" ? 1 : 0; formScore += score * weight; totalWeight += 3 * weight; // Max possible per match } @@ -105,14 +105,14 @@ async function computeEloRatings(): Promise { const startTime = Date.now(); try { - console.log('🏟️ ELO Rating Computation — Starting...'); - console.log('─'.repeat(60)); + 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', + sport: "football", + status: "FT", scoreHome: { not: null }, scoreAway: { not: null }, homeTeamId: { not: null }, @@ -126,7 +126,7 @@ async function computeEloRatings(): Promise { scoreAway: true, mstUtc: true, }, - orderBy: { mstUtc: 'asc' }, + orderBy: { mstUtc: "asc" }, }); console.log( @@ -228,7 +228,7 @@ async function computeEloRatings(): Promise { ); // 4. Bulk upsert to team_elo_ratings - console.log('💾 Writing to team_elo_ratings...'); + console.log("💾 Writing to team_elo_ratings..."); const BATCH_SIZE = 500; const teams = Array.from(eloMap.entries()); @@ -246,7 +246,7 @@ async function computeEloRatings(): Promise { awayElo: Math.round(state.awayElo * 10) / 10, formElo: Math.round(state.formElo * 10) / 10, matchesPlayed: state.matchesPlayed, - recentForm: state.recentResults.join(''), + recentForm: state.recentResults.join(""), }, create: { teamId, @@ -255,7 +255,7 @@ async function computeEloRatings(): Promise { awayElo: Math.round(state.awayElo * 10) / 10, formElo: Math.round(state.formElo * 10) / 10, matchesPlayed: state.matchesPlayed, - recentForm: state.recentResults.join(''), + recentForm: state.recentResults.join(""), }, }), ), @@ -276,38 +276,38 @@ async function computeEloRatings(): Promise { .map((s) => s.overallElo) .sort((a, b) => b - a); - console.log('─'.repeat(60)); - console.log('📊 ELO Rating Summary:'); + 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(` Highest ELO: ${overallElos[0]?.toFixed(1) ?? "N/A"}`); console.log( - ` Lowest ELO: ${overallElos[overallElos.length - 1]?.toFixed(1) ?? 'N/A'}`, + ` Lowest ELO: ${overallElos[overallElos.length - 1]?.toFixed(1) ?? "N/A"}`, ); console.log( - ` Median ELO: ${overallElos[Math.floor(overallElos.length / 2)]?.toFixed(1) ?? 'N/A'}`, + ` Median ELO: ${overallElos[Math.floor(overallElos.length / 2)]?.toFixed(1) ?? "N/A"}`, ); console.log(` Duration: ${elapsedTotal}s`); - console.log('─'.repeat(60)); + console.log("─".repeat(60)); // Top 20 teams const topTeams = await prisma.teamEloRating.findMany({ - orderBy: { overallElo: 'desc' }, + orderBy: { overallElo: "desc" }, take: 20, include: { team: { select: { name: true } } }, }); - console.log('\n🏆 Top 20 Teams by ELO:'); + console.log("\n🏆 Top 20 Teams by ELO:"); topTeams.forEach((t, i) => { - const form = t.recentForm.split('').join('-'); + 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!'); + console.log("\n✅ Done!"); } catch (error) { - console.error('❌ ELO computation failed:', error); + console.error("❌ ELO computation failed:", error); process.exit(1); } finally { await prisma.$disconnect(); diff --git a/src/scripts/export-postman-collection.ts b/src/scripts/export-postman-collection.ts index 52900ad..e6f34ca 100644 --- a/src/scripts/export-postman-collection.ts +++ b/src/scripts/export-postman-collection.ts @@ -1,9 +1,9 @@ -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'; +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>; @@ -14,7 +14,7 @@ interface PostmanResponse { originalRequest: JsonRecord; status: string; code: number; - _postman_previewlanguage: 'json'; + _postman_previewlanguage: "json"; header: Array<{ key: string; value: string }>; body: string; } @@ -28,7 +28,7 @@ interface PostmanItem { interface AiEndpointDefinition { name: string; - method: 'GET' | 'POST'; + method: "GET" | "POST"; path: string; description: string; query?: Array<{ key: string; value: string; description: string }>; @@ -40,7 +40,7 @@ function refName(ref: string | undefined): string | null { if (!ref) { return null; } - const parts = ref.split('/'); + const parts = ref.split("/"); return parts[parts.length - 1] ?? null; } @@ -48,12 +48,13 @@ function resolveSchema( schema: unknown, schemas: SwaggerSchemas, ): JsonRecord | null { - if (!schema || typeof schema !== 'object') { + if (!schema || typeof schema !== "object") { return null; } const schemaObject = schema as JsonRecord; - const schemaRef = typeof schemaObject.$ref === 'string' ? schemaObject.$ref : null; + const schemaRef = + typeof schemaObject.$ref === "string" ? schemaObject.$ref : null; if (schemaRef) { const name = refName(schemaRef); return name ? (schemas[name] ?? null) : null; @@ -73,31 +74,31 @@ function examplePrimitive(schema: JsonRecord): unknown { return schema.enum[0]; } - const type = typeof schema.type === 'string' ? schema.type : 'string'; - const format = typeof schema.format === 'string' ? schema.format : ''; + 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 (type === "string") { + if (format === "email") { + return "user@example.com"; } - if (format === 'date-time') { - return '2026-04-14T00:00:00.000Z'; + if (format === "date-time") { + return "2026-04-14T00:00:00.000Z"; } - if (format === 'date') { - return '2026-04-14'; + if (format === "date") { + return "2026-04-14"; } - if (format === 'uuid') { - return '11111111-1111-1111-1111-111111111111'; + if (format === "uuid") { + return "11111111-1111-1111-1111-111111111111"; } - return 'string'; + return "string"; } - if (type === 'integer' || type === 'number') { + if (type === "integer" || type === "number") { return 1; } - if (type === 'boolean') { + if (type === "boolean") { return true; } - return 'string'; + return "string"; } function buildExampleFromSchema( @@ -110,7 +111,7 @@ function buildExampleFromSchema( return null; } - const schemaRef = typeof resolved.$ref === 'string' ? resolved.$ref : null; + const schemaRef = typeof resolved.$ref === "string" ? resolved.$ref : null; if (schemaRef) { const name = refName(schemaRef); if (!name || visited.has(name)) { @@ -124,7 +125,7 @@ function buildExampleFromSchema( 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)) { + if (partial && typeof partial === "object" && !Array.isArray(partial)) { return { ...accumulator, ...(partial as JsonRecord) }; } return accumulator; @@ -139,12 +140,12 @@ function buildExampleFromSchema( return buildExampleFromSchema(resolved.anyOf[0], schemas, visited); } - const type = typeof resolved.type === 'string' ? resolved.type : 'object'; - if (type === 'array') { + const type = typeof resolved.type === "string" ? resolved.type : "object"; + if (type === "array") { return [buildExampleFromSchema(resolved.items, schemas, visited)]; } - if (type === 'object' || resolved.properties) { + if (type === "object" || resolved.properties) { const properties = (resolved.properties ?? {}) as JsonRecord; const output: JsonRecord = {}; for (const [key, value] of Object.entries(properties)) { @@ -159,23 +160,23 @@ function buildExampleFromSchema( } function swaggerSchemaFromContent(content: unknown): unknown { - if (!content || typeof content !== 'object') { + if (!content || typeof content !== "object") { return null; } const contentObject = content as JsonRecord; - const jsonContent = contentObject['application/json']; - if (jsonContent && typeof jsonContent === 'object') { + 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') { + if (firstContent && typeof firstContent === "object") { return (firstContent as JsonRecord).schema ?? null; } return null; } function toPostmanPath(pathname: string): string { - return pathname.replace(/\{([^}]+)\}/g, '{{$1}}'); + return pathname.replace(/\{([^}]+)\}/g, "{{$1}}"); } function buildRequestBody( @@ -213,25 +214,26 @@ function buildResponses( name: `${method.toUpperCase()} ${rawPath} - ${statusCode}`, originalRequest: { method: method.toUpperCase(), - header: [{ key: 'Content-Type', value: 'application/json' }], + header: [{ key: "Content-Type", value: "application/json" }], body: body ? { - mode: 'raw', + mode: "raw", raw: body, } : undefined, url: { raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`, host: [`{{${baseUrlVariable}}}`], - path: rawPath.split('/').filter(Boolean), + path: rawPath.split("/").filter(Boolean), }, }, - status: typeof responseRecord.description === 'string' - ? responseRecord.description - : `HTTP ${statusCode}`, + 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' }], + _postman_previewlanguage: "json", + header: [{ key: "Content-Type", value: "application/json" }], body: JSON.stringify(example ?? {}, null, 2), }; }); @@ -243,14 +245,14 @@ function buildQueryParams(operation: JsonRecord): Array { : []; return parameters - .filter((parameter) => parameter.in === 'query') + .filter((parameter) => parameter.in === "query") .map((parameter) => ({ - key: String(parameter.name ?? ''), + key: String(parameter.name ?? ""), value: - parameter.schema && typeof parameter.schema === 'object' - ? String(((parameter.schema as JsonRecord).default ?? '')) - : '', - description: String(parameter.description ?? ''), + parameter.schema && typeof parameter.schema === "object" + ? String((parameter.schema as JsonRecord).default ?? "") + : "", + description: String(parameter.description ?? ""), disabled: parameter.required === true ? false : true, })); } @@ -258,8 +260,8 @@ function buildQueryParams(operation: JsonRecord): Array { function buildHeaders(operation: JsonRecord): Array { const headers: Array = [ { - key: 'Content-Type', - value: 'application/json', + key: "Content-Type", + value: "application/json", }, ]; @@ -268,8 +270,8 @@ function buildHeaders(operation: JsonRecord): Array { : []; if (security.length > 0) { headers.push({ - key: 'Authorization', - value: 'Bearer {{accessToken}}', + key: "Authorization", + value: "Bearer {{accessToken}}", }); } @@ -292,20 +294,22 @@ function createRequestItem( method: method.toUpperCase(), header: headers, description: - typeof operation.description === 'string' + typeof operation.description === "string" ? operation.description - : (typeof operation.summary === 'string' ? operation.summary : ''), + : typeof operation.summary === "string" + ? operation.summary + : "", url: { raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`, host: [`{{${baseUrlVariable}}}`], - path: rawPath.split('/').filter(Boolean), + path: rawPath.split("/").filter(Boolean), query, }, }; if (body) { request.body = { - mode: 'raw', + mode: "raw", raw: body, }; } @@ -335,18 +339,19 @@ function buildNestFolders(document: JsonRecord): PostmanItem[] { for (const [rawPath, pathItem] of Object.entries(paths)) { for (const [method, operationObject] of Object.entries(pathItem)) { - if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) { + if (!["get", "post", "put", "patch", "delete"].includes(method)) { continue; } - const operation = operationObject as JsonRecord; + const operation = operationObject; const tags = Array.isArray(operation.tags) ? operation.tags : []; const folderName = - typeof tags[0] === 'string' && tags[0].trim().length > 0 + typeof tags[0] === "string" && tags[0].trim().length > 0 ? tags[0] - : 'Misc'; + : "Misc"; const requestName = - typeof operation.summary === 'string' && operation.summary.trim().length > 0 + typeof operation.summary === "string" && + operation.summary.trim().length > 0 ? operation.summary : `${method.toUpperCase()} ${rawPath}`; @@ -354,7 +359,7 @@ function buildNestFolders(document: JsonRecord): PostmanItem[] { requestName, method, rawPath, - 'beBaseUrl', + "beBaseUrl", operation, safeSchemas, ); @@ -379,8 +384,8 @@ function createAiRequest( ): PostmanItem { const url: JsonRecord = { raw: `{{aiBaseUrl}}${endpoint.path}`, - host: ['{{aiBaseUrl}}'], - path: endpoint.path.split('/').filter(Boolean), + host: ["{{aiBaseUrl}}"], + path: endpoint.path.split("/").filter(Boolean), }; if (endpoint.query && endpoint.query.length > 0) { @@ -393,14 +398,14 @@ function createAiRequest( const request: JsonRecord = { method: endpoint.method, - header: [{ key: 'Content-Type', value: 'application/json' }], + header: [{ key: "Content-Type", value: "application/json" }], description: endpoint.description, url, }; if (endpoint.body) { request.body = { - mode: 'raw', + mode: "raw", raw: JSON.stringify(endpoint.body, null, 2), }; } @@ -412,10 +417,10 @@ function createAiRequest( { name: `${endpoint.method} ${endpoint.path}`, originalRequest: request, - status: 'OK', + status: "OK", code: 200, - _postman_previewlanguage: 'json', - header: [{ key: 'Content-Type', value: 'application/json' }], + _postman_previewlanguage: "json", + header: [{ key: "Content-Type", value: "application/json" }], body: JSON.stringify(endpoint.response, null, 2), }, ], @@ -425,105 +430,105 @@ function createAiRequest( function buildAiFolder(): PostmanItem { const v20Endpoints: AiEndpointDefinition[] = [ { - name: 'Root', - method: 'GET', - path: '/', - description: 'AI engine root status endpoint', + name: "Root", + method: "GET", + path: "/", + description: "AI engine root status endpoint", response: { - status: 'Suggest-Bet AI Engine v20+', - engine: 'V20 Plus Single Match Orchestrator', + 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: "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', + 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' }, + 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 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', + 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', + 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 }, + 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', + name: "Generate Coupon", + method: "POST", + path: "/v20plus/coupon", + description: "Generate V20+ coupon from selected matches", body: { - match_ids: ['match-1', 'match-2'], - strategy: 'BALANCED', + match_ids: ["match-1", "match-2"], + strategy: "BALANCED", max_matches: 4, min_confidence: 55, }, response: { success: true, data: { - strategy: 'BALANCED', + strategy: "BALANCED", bets: [], }, }, }, { - name: 'Daily Banker', - method: 'GET', - path: '/v20plus/daily-banker', - description: 'Get daily banker picks', + name: "Daily Banker", + method: "GET", + path: "/v20plus/daily-banker", + description: "Get daily banker picks", query: [ { - key: 'count', - value: '3', - description: 'Number of banker picks', + 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', + 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: "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', + key: "top_leagues_only", + value: "false", + description: "Filter to top leagues", }, ], response: { count: 0, items: [] }, @@ -532,42 +537,42 @@ function buildAiFolder(): PostmanItem { const v2Endpoints: AiEndpointDefinition[] = [ { - name: 'V2 Health', - method: 'GET', - path: '/v2/health', - description: 'V2 betting engine health', + name: "V2 Health", + method: "GET", + path: "/v2/health", + description: "V2 betting engine health", response: { - status: 'healthy', - engine: 'v2.betting_engine', + 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', + 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' }, + 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 }, + MS: { pick: "1", confidence: 58.4 }, }, }, }, ]; return { - name: 'AI Engine', + name: "AI Engine", item: [ { - name: 'V20+', - item: v20Endpoints.map((endpoint) => createAiRequest(endpoint, 'V20+')), + name: "V20+", + item: v20Endpoints.map((endpoint) => createAiRequest(endpoint, "V20+")), }, { - name: 'V2', - item: v2Endpoints.map((endpoint) => createAiRequest(endpoint, 'V2')), + name: "V2", + item: v2Endpoints.map((endpoint) => createAiRequest(endpoint, "V2")), }, ], }; @@ -575,21 +580,21 @@ function buildAiFolder(): PostmanItem { async function run(): Promise { const projectRoot = process.cwd(); - const outputDir = path.join(projectRoot, 'mds'); + const outputDir = path.join(projectRoot, "mds"); const outputFile = path.join( outputDir, - 'suggest-bet-platform.postman_collection.json', + "suggest-bet-platform.postman_collection.json", ); - process.env.REDIS_ENABLED = 'true'; + process.env.REDIS_ENABLED = "true"; const app = await NestFactory.create(AppModule, { logger: false }); - app.setGlobalPrefix('api'); + app.setGlobalPrefix("api"); const swaggerConfig = new DocumentBuilder() - .setTitle('Suggest Bet Backend API') - .setDescription('Postman collection export source') - .setVersion('1.0') + .setTitle("Suggest Bet Backend API") + .setDescription("Postman collection export source") + .setVersion("1.0") .addBearerAuth() .build(); @@ -600,25 +605,25 @@ async function run(): Promise { const collection: JsonRecord = { info: { - name: 'Suggest-Bet Platform API', + name: "Suggest-Bet Platform API", description: - 'Auto-generated Postman collection for Nest backend and AI engine endpoints.', + "Auto-generated Postman collection for Nest backend and AI engine endpoints.", schema: - 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json', + "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' }, + { 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' }], + type: "bearer", + bearer: [{ key: "token", value: "{{accessToken}}", type: "string" }], }, item: [ { - name: 'Nest API', + name: "Nest API", item: buildNestFolders(document), }, buildAiFolder(), @@ -626,7 +631,7 @@ async function run(): Promise { }; mkdirSync(outputDir, { recursive: true }); - writeFileSync(outputFile, JSON.stringify(collection, null, 2), 'utf8'); + writeFileSync(outputFile, JSON.stringify(collection, null, 2), "utf8"); await app.close(); diff --git a/src/scripts/export-swagger-endpoints-summary.ts b/src/scripts/export-swagger-endpoints-summary.ts index 70a75d0..076c1b1 100755 --- a/src/scripts/export-swagger-endpoints-summary.ts +++ b/src/scripts/export-swagger-endpoints-summary.ts @@ -1,20 +1,20 @@ -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'; +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'; + | "get" + | "post" + | "put" + | "patch" + | "delete" + | "options" + | "head" + | "all"; interface TsDecoratorMeta { name: string; @@ -42,14 +42,14 @@ interface TsMethodMeta { } const HTTP_DECORATOR_TO_METHOD: Record = { - Get: 'get', - Post: 'post', - Put: 'put', - Patch: 'patch', - Delete: 'delete', - Options: 'options', - Head: 'head', - All: 'all', + Get: "get", + Post: "post", + Put: "put", + Patch: "patch", + Delete: "delete", + Options: "options", + Head: "head", + All: "all", }; function getDecorators(node: ts.Node): readonly ts.Decorator[] { @@ -105,7 +105,7 @@ function collectControllerFiles(dirPath: string): string[] { continue; } - if (entry.isFile() && entry.name.endsWith('.controller.ts')) { + if (entry.isFile() && entry.name.endsWith(".controller.ts")) { files.push(absolutePath); } } @@ -115,9 +115,9 @@ function collectControllerFiles(dirPath: string): string[] { function normalizeRoutePart(value: string | undefined): string { if (!value || value === "''" || value === '""') { - return ''; + return ""; } - return value.trim().replace(/^\/+|\/+$/g, ''); + return value.trim().replace(/^\/+|\/+$/g, ""); } function buildSwaggerPath( @@ -131,18 +131,18 @@ function buildSwaggerPath( normalizeRoutePart(routePath), ].filter(Boolean); - return `/${parts.join('/')}`; + return `/${parts.join("/")}`; } function collectTsEndpointMetadata( projectRoot: string, ): Map { - const modulesDir = path.join(projectRoot, 'src', 'modules'); + 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 sourceText = readFileSync(filePath, "utf8"); const sourceFile = ts.createSourceFile( filePath, sourceText, @@ -164,7 +164,7 @@ function collectTsEndpointMetadata( ); const controllerDecorator = classDecorators.find( - (decorator) => decorator.name === 'Controller', + (decorator) => decorator.name === "Controller", ); if (!controllerDecorator) { return; @@ -221,7 +221,7 @@ function collectTsEndpointMetadata( routePath, returnType, hasPublicDecorator: methodDecorators.some( - (decorator) => decorator.name === 'Public', + (decorator) => decorator.name === "Public", ), methodDecorators: methodDecorators.map((decorator) => decorator.name), params, @@ -235,10 +235,10 @@ function collectTsEndpointMetadata( } function refName(ref?: string): string | null { - if (!ref || typeof ref !== 'string') { + if (!ref || typeof ref !== "string") { return null; } - const parts = ref.split('/'); + const parts = ref.split("/"); return parts[parts.length - 1] ?? null; } @@ -246,13 +246,13 @@ function collectSchemaRefs( value: unknown, refs = new Set(), ): Set { - if (!value || typeof value !== 'object') { + if (!value || typeof value !== "object") { return refs; } const recordValue = value as Record; const maybeRef = recordValue.$ref; - if (typeof maybeRef === 'string') { + if (typeof maybeRef === "string") { const name = refName(maybeRef); if (name) { refs.add(name); @@ -267,22 +267,22 @@ function collectSchemaRefs( } function schemaTypeSummary(schema: unknown): string { - if (!schema || typeof schema !== 'object') { - return 'unknown'; + if (!schema || typeof schema !== "object") { + return "unknown"; } const schemaObj = schema as Record; - if (typeof schemaObj.$ref === 'string') { - return refName(schemaObj.$ref) ?? 'unknown'; + if (typeof schemaObj.$ref === "string") { + return refName(schemaObj.$ref) ?? "unknown"; } - const type = typeof schemaObj.type === 'string' ? schemaObj.type : 'object'; - if (type === 'array') { + 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}(${schemaObj.enum.join(" | ")})`; } return type; @@ -295,35 +295,35 @@ function normalizeParameters(parameters: unknown[] = []) { .map((parameter) => { const schema = (parameter.schema ?? {}) as Record; return { - name: typeof parameter.name === 'string' ? parameter.name : '', - in: typeof parameter.in === 'string' ? parameter.in : '', + name: typeof parameter.name === "string" ? parameter.name : "", + in: typeof parameter.in === "string" ? parameter.in : "", required: Boolean(parameter.required), description: - typeof parameter.description === 'string' + 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, + 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'), + 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') { + if (!requestBody || typeof requestBody !== "object") { return null; } const requestBodyObj = requestBody as Record; - if (typeof requestBodyObj.$ref === 'string') { + if (typeof requestBodyObj.$ref === "string") { return { required: false, contentTypes: [], @@ -378,9 +378,9 @@ function normalizeResponses(responses: Record) { return { status: Number(statusCode), description: - typeof responseObj.description === 'string' + typeof responseObj.description === "string" ? responseObj.description - : '', + : "", contentTypes, schemaTypes, schemaRefs: [...refs].sort(), @@ -392,25 +392,25 @@ function normalizeResponses(responses: Record) { async function run() { const projectRoot = process.cwd(); - const outputDir = path.join(projectRoot, 'mds'); + const outputDir = path.join(projectRoot, "mds"); const outputFile = path.join( outputDir, - 'backend_endpoints_swagger_summary.json', + "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'; + process.env.REDIS_ENABLED = "true"; const tsMetadata = collectTsEndpointMetadata(projectRoot); const app = await NestFactory.create(AppModule, { logger: false }); - app.setGlobalPrefix('api'); + app.setGlobalPrefix("api"); const swaggerConfig = new DocumentBuilder() - .setTitle('Suggest Bet Backend API') - .setDescription('Auto-generated endpoint summary from Swagger document') - .setVersion('1.0') + .setTitle("Suggest Bet Backend API") + .setDescription("Auto-generated endpoint summary from Swagger document") + .setVersion("1.0") .addBearerAuth() .build(); @@ -419,7 +419,7 @@ async function run() { const endpoints: Array> = []; const seenOperationIds = new Set(); - const globalPrefix = 'api'; + const globalPrefix = "api"; const sortedPaths = Object.keys(paths).sort((a, b) => a.localeCompare(b)); for (const endpointPath of sortedPaths) { @@ -427,7 +427,7 @@ async function run() { const methods = Object.keys(pathItem) .filter((method) => - ['get', 'post', 'put', 'patch', 'delete', 'options', 'head'].includes( + ["get", "post", "put", "patch", "delete", "options", "head"].includes( method, ), ) @@ -436,7 +436,7 @@ async function run() { for (const method of methods) { const operation = pathItem[method] as Record; const operationId = - typeof operation.operationId === 'string' ? operation.operationId : ''; + typeof operation.operationId === "string" ? operation.operationId : ""; if (operationId) { seenOperationIds.add(operationId); @@ -464,13 +464,13 @@ async function run() { const tsBodyParams = tsMeta?.params .filter((param) => - param.decorators.some((decorator) => decorator.name === 'Body'), + param.decorators.some((decorator) => decorator.name === "Body"), ) .map((param) => ({ name: param.name, type: param.type, bodyKey: - param.decorators.find((decorator) => decorator.name === 'Body') + param.decorators.find((decorator) => decorator.name === "Body") ?.firstArg ?? null, })) ?? []; @@ -482,9 +482,9 @@ async function run() { tag: tags[0] ?? null, tags, summary: - typeof operation.summary === 'string' ? operation.summary : null, + typeof operation.summary === "string" ? operation.summary : null, description: - typeof operation.description === 'string' + typeof operation.description === "string" ? operation.description : null, auth: { @@ -527,10 +527,10 @@ async function run() { tsMeta.controllerRoute, tsMeta.routePath, ), - tag: tsMeta.controller.replace(/Controller$/, ''), - tags: [tsMeta.controller.replace(/Controller$/, '')], + tag: tsMeta.controller.replace(/Controller$/, ""), + tags: [tsMeta.controller.replace(/Controller$/, "")], summary: null, - description: 'Not present in generated Swagger document', + description: "Not present in generated Swagger document", auth: { swaggerSecurityRequired: null, swaggerSecuritySchemes: [], @@ -546,13 +546,13 @@ async function run() { body: null, tsBodyParams: tsMeta.params .filter((param) => - param.decorators.some((decorator) => decorator.name === 'Body'), + param.decorators.some((decorator) => decorator.name === "Body"), ) .map((param) => ({ name: param.name, type: param.type, bodyKey: - param.decorators.find((decorator) => decorator.name === 'Body') + param.decorators.find((decorator) => decorator.name === "Body") ?.firstArg ?? null, })), }, @@ -569,19 +569,19 @@ async function run() { } endpoints.sort((a, b) => { - const pathA = typeof a.path === 'string' ? a.path : ''; - const pathB = typeof b.path === 'string' ? b.path : ''; + 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 : '', + 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'; + const tag = typeof endpoint.tag === "string" ? endpoint.tag : "Unknown"; tagStats.set(tag, (tagStats.get(tag) ?? 0) + 1); } @@ -591,7 +591,7 @@ async function run() { .body as Record | null; if (requestBody && Array.isArray(requestBody.schemaRefs)) { for (const schemaName of requestBody.schemaRefs) { - if (typeof schemaName === 'string') { + if (typeof schemaName === "string") { referencedSchemas.add(schemaName); } } @@ -604,7 +604,7 @@ async function run() { continue; } for (const schemaName of status.schemaRefs) { - if (typeof schemaName === 'string') { + if (typeof schemaName === "string") { referencedSchemas.add(schemaName); } } @@ -626,16 +626,16 @@ async function run() { const summary = { generatedAt: new Date().toISOString(), - generatedBy: 'src/scripts/export-swagger-endpoints-summary.ts', - project: 'Suggest-Bet-BE', + generatedBy: "src/scripts/export-swagger-endpoints-summary.ts", + project: "Suggest-Bet-BE", swagger: { - docsPath: '/api/docs', - globalPrefix: '/api', + 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.', + "Swagger output reflects loaded modules for current environment.", + "This export forces REDIS_ENABLED=true to include conditional Prediction endpoints.", ], }, stats: { @@ -668,7 +668,7 @@ async function run() { }; mkdirSync(outputDir, { recursive: true }); - writeFileSync(outputFile, JSON.stringify(summary, null, 2), 'utf8'); + writeFileSync(outputFile, JSON.stringify(summary, null, 2), "utf8"); await app.close(); @@ -680,7 +680,7 @@ async function run() { } void run().catch((error: unknown) => { - console.error('❌ Failed to export Swagger endpoint summary'); + 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 index 99b9613..28fc2d1 100644 --- a/src/scripts/populate-feature-store.ts +++ b/src/scripts/populate-feature-store.ts @@ -16,7 +16,7 @@ * Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/populate-feature-store.ts */ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); @@ -180,16 +180,16 @@ function buildFormIndex( const homeResult = match.scoreHome > match.scoreAway - ? 'W' + ? "W" : match.scoreHome < match.scoreAway - ? 'L' - : 'D'; + ? "L" + : "D"; const awayResult = match.scoreAway > match.scoreHome - ? 'W' + ? "W" : match.scoreAway < match.scoreHome - ? 'L' - : 'D'; + ? "L" + : "D"; homeState.results.unshift(homeResult); awayState.results.unshift(awayResult); @@ -222,14 +222,14 @@ function extractFormFeatures(formState: TeamFormState): { let winStreak = 0; for (const r of formState.results) { - if (r === 'W') winStreak++; + 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), + (sum, r) => sum + (r === "W" ? 3 : r === "D" ? 1 : 0), 0, ); const maxPoints = last5Results.length * 3 || 1; @@ -302,20 +302,20 @@ async function loadOddsIndex(): Promise> { 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.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') + s.sel.toLowerCase().includes("üst") || + s.sel.toLowerCase().includes("over") ) ou25O = s.odds; - } else if (s.cat === 'Karşılıklı Gol') { + } else if (s.cat === "Karşılıklı Gol") { if ( - s.sel.toLowerCase().includes('var') || - s.sel.toLowerCase().includes('yes') + s.sel.toLowerCase().includes("var") || + s.sel.toLowerCase().includes("yes") ) bttsY = s.odds; } @@ -411,7 +411,7 @@ function buildLeagueIndex(matches: MatchRow[]): Map { const leagueMap = new Map(); for (const match of matches) { - const key = match.leagueId ?? 'unknown'; + const key = match.leagueId ?? "unknown"; let stats = leagueMap.get(key); if (!stats) { stats = { totalMatches: 0, totalGoals: 0, homeWins: 0, over25Count: 0 }; @@ -520,15 +520,15 @@ async function populateFeatureStore(): Promise { const startTime = Date.now(); try { - console.log('🧠 Feature Store Population — Starting...'); - console.log('─'.repeat(60)); + console.log("🧠 Feature Store Population — Starting..."); + console.log("─".repeat(60)); // Load all finished football matches - console.log('📥 Loading matches...'); + console.log("📥 Loading matches..."); const rawMatches = await prisma.match.findMany({ where: { - sport: 'football', - status: 'FT', + sport: "football", + status: "FT", scoreHome: { not: null }, scoreAway: { not: null }, homeTeamId: { not: null }, @@ -543,7 +543,7 @@ async function populateFeatureStore(): Promise { scoreAway: true, mstUtc: true, }, - orderBy: { mstUtc: 'asc' }, + orderBy: { mstUtc: "asc" }, }); const matches: MatchRow[] = rawMatches.map((m) => ({ @@ -559,31 +559,31 @@ async function populateFeatureStore(): Promise { console.log(` 📊 Matches loaded: ${matches.length.toLocaleString()}`); // Pre-compute all indexes - console.log('\n📊 Building feature indexes...'); + console.log("\n📊 Building feature indexes..."); - console.log(' 🏅 Pillar 1: Loading ELO ratings...'); + console.log(" 🏅 Pillar 1: Loading ELO ratings..."); const eloMap = await loadEloMap(); - console.log(' 📈 Pillar 2: Building form index...'); + console.log(" 📈 Pillar 2: Building form index..."); const formIndex = buildFormIndex(matches); - console.log(' 💰 Pillar 3: Loading odds data...'); + console.log(" 💰 Pillar 3: Loading odds data..."); const oddsIndex = await loadOddsIndex(); - console.log(' ⚔️ Pillar 5: Building H2H index...'); + console.log(" ⚔️ Pillar 5: Building H2H index..."); const h2hIndex = buildH2HIndex(matches); - console.log(' 📋 Pillar 6: Loading referee data...'); + console.log(" 📋 Pillar 6: Loading referee data..."); const refereeIndex = await loadRefereeIndex(matches); - console.log(' 🏟️ Pillar 7: Building league DNA...'); + console.log(" 🏟️ Pillar 7: Building league DNA..."); const leagueIndex = buildLeagueIndex(matches); - console.log('\n✅ All indexes built!'); - console.log('─'.repeat(60)); + console.log("\n✅ All indexes built!"); + console.log("─".repeat(60)); // Build feature vectors and batch upsert - console.log('💾 Writing features to database...'); + console.log("💾 Writing features to database..."); const BATCH_SIZE = 1000; let processed = 0; @@ -651,7 +651,7 @@ async function populateFeatureStore(): Promise { const refTotal = refStats?.totalMatches ?? 0; // Pillar 7: League DNA - const leagueKey = match.leagueId ?? 'unknown'; + const leagueKey = match.leagueId ?? "unknown"; const leagueStats = leagueIndex.get(leagueKey) ?? { totalMatches: 1, totalGoals: 0, @@ -730,7 +730,7 @@ async function populateFeatureStore(): Promise { ), // Meta missingPlayersImpact: 0, - calculatorVer: 'v2.0', + calculatorVer: "v2.0", }); } @@ -749,7 +749,7 @@ async function populateFeatureStore(): Promise { } const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.log('─'.repeat(60)); + console.log("─".repeat(60)); console.log(`✅ Feature Store population complete!`); console.log(` Features written: ${processed.toLocaleString()}`); console.log(` Skipped: ${skipped}`); @@ -758,9 +758,9 @@ async function populateFeatureStore(): Promise { // Verify const count = await prisma.footballAiFeature.count(); console.log(` DB row count: ${count.toLocaleString()}`); - console.log('─'.repeat(60)); + console.log("─".repeat(60)); } catch (error) { - console.error('❌ Feature store population failed:', error); + console.error("❌ Feature store population failed:", error); process.exit(1); } finally { await prisma.$disconnect(); diff --git a/src/scripts/run-all-fe-compatible.ts b/src/scripts/run-all-fe-compatible.ts index 340fbca..65ee890 100644 --- a/src/scripts/run-all-fe-compatible.ts +++ b/src/scripts/run-all-fe-compatible.ts @@ -1,5 +1,6 @@ -process.env.PORT = process.env.PORT || '3005'; -process.env.AI_ENGINE_URL = process.env.AI_ENGINE_URL || 'http://127.0.0.1:8000'; +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'); +require("./run-full-stack"); diff --git a/src/scripts/run-feeder-basketball.ts b/src/scripts/run-feeder-basketball.ts index 58cfba7..237ee4d 100755 --- a/src/scripts/run-feeder-basketball.ts +++ b/src/scripts/run-feeder-basketball.ts @@ -1,24 +1,24 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from '../app.module'; -import { FeederService } from '../modules/feeder/feeder.service'; +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...'); + console.log("🏀 Bootstrapping Basketball Feeder..."); const app = await NestFactory.createApplicationContext(AppModule, { - logger: ['log', 'error', 'warn', 'debug', 'verbose'], + 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']); + await feederService.runHistoricalScan(["basketball"]); - console.log('✅ Basketball Feeder finished.'); + console.log("✅ Basketball Feeder finished."); await app.close(); } bootstrap().catch((err) => { - console.error('❌ Basketball Feeder failed:', 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 index a2abc39..1308e02 100755 --- a/src/scripts/run-feeder-filtered.ts +++ b/src/scripts/run-feeder-filtered.ts @@ -3,24 +3,24 @@ * Fetches matches only for leagues in top_leagues.json for the last ~2.5 seasons. */ -process.env.FEEDER_MODE = 'historical'; +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'; +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...'); + const logger = new Logger("FeederFilteredScript"); + logger.log("🚀 Starting Targeted Historical Feeder..."); // Read top_leagues.json - const leaguesPath = path.join(process.cwd(), 'top_leagues.json'); + const leaguesPath = path.join(process.cwd(), "top_leagues.json"); let targetLeagues: string[] = []; try { - const data = fs.readFileSync(leaguesPath, 'utf8'); + const data = fs.readFileSync(leaguesPath, "utf8"); targetLeagues = JSON.parse(data); // Deduplicate targetLeagues = [...new Set(targetLeagues)]; @@ -34,21 +34,21 @@ async function bootstrap() { } const app = await NestFactory.createApplicationContext(AppModule, { - logger: ['log', 'error', 'warn'], + 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'; + const START_DATE = "2023-07-01"; logger.log(`📅 Date Range: ${START_DATE} -> Today`); await feederService.runHistoricalScan( - ['football'], + ["football"], START_DATE, targetLeagues, ); - logger.log('✅ Targeted Feeder completed successfully!'); + logger.log("✅ Targeted Feeder completed successfully!"); } catch (error: any) { logger.error(`❌ Feeder failed: ${error.message}`); logger.error(error.stack); diff --git a/src/scripts/run-feeder.ts b/src/scripts/run-feeder.ts index a109936..4e0d1c0 100755 --- a/src/scripts/run-feeder.ts +++ b/src/scripts/run-feeder.ts @@ -3,28 +3,28 @@ * Usage: npm run feeder:historical */ -import { NestFactory } from '@nestjs/core'; -import { FeederService } from '../modules/feeder/feeder.service'; -import { Logger } from '@nestjs/common'; +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'; + process.env.FEEDER_MODE = "historical"; - const logger = new Logger('FeederScript'); + const logger = new Logger("FeederScript"); - logger.log('🚀 Starting previous-day completed match sync...'); + 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 { AppModule } = require("../app.module"); const app = await NestFactory.createApplicationContext(AppModule, { - logger: ['log', 'error', 'warn'], + logger: ["log", "error", "warn"], }); try { const feederService = app.get(FeederService); await feederService.runPreviousDayCompletedMatchesScan(); - logger.log('✅ Previous-day completed match sync completed successfully!'); + logger.log("✅ Previous-day completed match sync completed successfully!"); } catch (error: any) { logger.error(`❌ Feeder failed: ${error.message}`); logger.error(error.stack); diff --git a/src/scripts/run-full-stack.ts b/src/scripts/run-full-stack.ts index 9fbd68a..2b77235 100644 --- a/src/scripts/run-full-stack.ts +++ b/src/scripts/run-full-stack.ts @@ -1,9 +1,9 @@ -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'; +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; @@ -11,10 +11,10 @@ interface ManagedProcess { } 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_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; @@ -43,26 +43,26 @@ async function main(): Promise { const pythonCommand = await resolvePythonCommand(); aiProcess = { - name: 'ai-engine', + name: "ai-engine", child: spawn( pythonCommand.command, [ ...pythonCommand.args, - '-m', - 'uvicorn', - 'main:app', - '--host', - '0.0.0.0', - '--port', + "-m", + "uvicorn", + "main:app", + "--host", + "0.0.0.0", + "--port", String(AI_ENGINE_PORT), ...resolveAiExtraArgs(), ], { cwd: AI_ENGINE_DIR, - stdio: 'inherit', + stdio: "inherit", env: { ...process.env, - PYTHONUNBUFFERED: '1', + PYTHONUNBUFFERED: "1", PORT: String(AI_ENGINE_PORT), }, }, @@ -73,7 +73,7 @@ async function main(): Promise { log(`Waiting for AI engine health at ${aiHealthUrl}`); await waitForHealth(aiHealthUrl, AI_START_TIMEOUT_MS); - log('AI engine is ready'); + log("AI engine is ready"); } const nestHealthUrl = `http://127.0.0.1:${DEFAULT_API_PORT}/api/health/live`; @@ -82,7 +82,7 @@ async function main(): Promise { if (nestAlreadyHealthy) { log(`NestJS already running at ${nestHealthUrl}`); } else { - const nestPortBusy = await isPortInUse('127.0.0.1', DEFAULT_API_PORT); + 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`, @@ -90,7 +90,7 @@ async function main(): Promise { } nestProcess = { - name: 'nest', + name: "nest", child: spawnNestProcess(), }; @@ -98,54 +98,54 @@ async function main(): Promise { log(`Waiting for NestJS health at ${nestHealthUrl}`); await waitForHealth(nestHealthUrl, NEST_START_TIMEOUT_MS); - log('NestJS is ready'); + log("NestJS is ready"); } - log('Full stack is running'); + log("Full stack is running"); } function ensureWindowsOrUnixShellAwareness(): void { - process.on('SIGINT', () => { - void shutdown('SIGINT'); + process.on("SIGINT", () => { + void shutdown("SIGINT"); }); - process.on('SIGTERM', () => { - void shutdown('SIGTERM'); + process.on("SIGTERM", () => { + void shutdown("SIGTERM"); }); - process.on('uncaughtException', (error: Error) => { - console.error('[full:run] Uncaught exception:', error); - void shutdown('uncaughtException', 1); + 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); + 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'; + return process.env.FULL_RUN_NEST_SCRIPT ?? "start:dev"; } function resolveAiExtraArgs(): string[] { - return process.env.FULL_RUN_AI_RELOAD === 'true' ? ['--reload'] : []; + 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}`], { + if (process.platform === "win32") { + return spawn("cmd.exe", ["/d", "/s", "/c", `npm run ${nestScript}`], { cwd: ROOT_DIR, - stdio: 'inherit', + stdio: "inherit", env: process.env, }); } - return spawn('npm', ['run', nestScript], { + return spawn("npm", ["run", nestScript], { cwd: ROOT_DIR, - stdio: 'inherit', + stdio: "inherit", env: process.env, }); } @@ -160,15 +160,18 @@ async function resolvePythonCommand(): Promise<{ } const localVenvPython = - process.platform === 'win32' - ? path.join(AI_ENGINE_DIR, 'venv', 'Scripts', 'python.exe') - : path.join(AI_ENGINE_DIR, 'venv', 'bin', 'python'); + 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: [] }; + return { + command: process.platform === "win32" ? "python" : "python3", + args: [], + }; } function resolveAiPort(aiUrl: string): number { @@ -178,7 +181,7 @@ function resolveAiPort(aiUrl: string): number { return Number(parsedUrl.port); } - return parsedUrl.protocol === 'https:' ? 443 : 80; + return parsedUrl.protocol === "https:" ? 443 : 80; } async function pathExists(targetPath: string): Promise { @@ -196,21 +199,24 @@ function resolveHost(url: string): string { } function attachExitHandlers(managedProcess: ManagedProcess): void { - managedProcess.child.on('exit', (code: number | null, signal: NodeJS.Signals | null) => { - if (shuttingDown) { - return; - } + managedProcess.child.on( + "exit", + (code: number | null, signal: NodeJS.Signals | null) => { + if (shuttingDown) { + return; + } - const detail = - signal !== null - ? `signal=${signal}` - : `code=${code ?? 'unknown'}`; + 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); - }); + console.error( + `[full:run] ${managedProcess.name} exited unexpectedly (${detail})`, + ); + void shutdown(`${managedProcess.name}-exit`, code ?? 1); + }, + ); - managedProcess.child.on('error', (error: Error) => { + managedProcess.child.on("error", (error: Error) => { if (shuttingDown) { return; } @@ -252,12 +258,12 @@ async function isPortInUse(host: string, port: number): Promise { return new Promise((resolve) => { const socket = net.createConnection({ host, port }); - socket.once('connect', () => { + socket.once("connect", () => { socket.destroy(); resolve(true); }); - socket.once('error', () => { + socket.once("error", () => { resolve(false); }); }); @@ -283,7 +289,9 @@ async function shutdown(reason: string, exitCode = 0): Promise { process.exit(exitCode); } -async function stopProcess(managedProcess: ManagedProcess | null): Promise { +async function stopProcess( + managedProcess: ManagedProcess | null, +): Promise { if (!managedProcess) { return; } @@ -293,16 +301,21 @@ async function stopProcess(managedProcess: ManagedProcess | null): Promise return; } - child.kill('SIGTERM'); + 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'); + 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 { +function waitForProcessExit( + child: ChildProcess, + timeoutMs: number, +): Promise { return new Promise((resolve) => { const timeout = setTimeout(() => { cleanup(); @@ -316,10 +329,10 @@ function waitForProcessExit(child: ChildProcess, timeoutMs: number): Promise { clearTimeout(timeout); - child.off('exit', onExit); + child.off("exit", onExit); }; - child.once('exit', onExit); + child.once("exit", onExit); }); } @@ -329,23 +342,23 @@ function log(message: string): void { function loadEnvFile(envPath: string): void { try { - const content = readFileSync(envPath, 'utf8'); + const content = readFileSync(envPath, "utf8"); const lines = content.split(/\r?\n/u); lines.forEach((line) => { const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) { + if (!trimmed || trimmed.startsWith("#")) { return; } - const separatorIndex = trimmed.indexOf('='); + 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, ''); + const normalizedValue = rawValue.replace(/^['"]|['"]$/gu, ""); if (!process.env[key]) { process.env[key] = normalizedValue; @@ -357,6 +370,6 @@ function loadEnvFile(envPath: string): void { } void main().catch((error: Error) => { - console.error('[full:run] Startup failed:', error); - void shutdown('startup-failed', 1); + 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 index 4da1b29..045b816 100755 --- a/src/scripts/run-live-feeder.ts +++ b/src/scripts/run-live-feeder.ts @@ -1,37 +1,25 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from '../app.module'; -import { Logger } from '@nestjs/common'; -import { DataFetcherTask } from '../tasks/data-fetcher.task'; +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 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'], + 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(); + // Run full sync (matches + live scores + odds + lineups) + logger.log("⚽🏀 Running full live match sync..."); + await dataFetcherTask.syncLiveMatches(); - // 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!'); + logger.log("✅ Live Feeder update completed successfully!"); } catch (error: any) { logger.error(`❌ Live Feeder failed: ${error.message}`); console.error(error.stack); diff --git a/src/services/ai.service.ts b/src/services/ai.service.ts index 21134f9..bf6bb82 100755 --- a/src/services/ai.service.ts +++ b/src/services/ai.service.ts @@ -1,7 +1,7 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; -import { firstValueFrom } from 'rxjs'; +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; @@ -46,7 +46,7 @@ export class AiService { private readonly configService: ConfigService, ) { this.pythonEngineUrl = - this.configService.get('AI_ENGINE_URL') || 'http://127.0.0.1:8000'; + this.configService.get("AI_ENGINE_URL") || "http://127.0.0.1:8000"; } /** @@ -61,9 +61,9 @@ export class AiService { _eventData: any[], ): Promise { try { - const matchId = String(matchDetails?.matchId || '').trim(); + const matchId = String(matchDetails?.matchId || "").trim(); if (!matchId) { - this.logger.warn('Skipping AI call: missing matchId'); + this.logger.warn("Skipping AI call: missing matchId"); return null; } @@ -102,18 +102,18 @@ export class AiService { .map((p: any) => `${p.market}: ${p.pick}`); const mappedPredictions = picks.map((p: any) => ({ - betType: String(p.market || ''), - prediction: String(p.pick || ''), + 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(' | ') + ? p.reasons.join(" | ") : Array.isArray(p.decision_reasons) - ? p.decision_reasons.join(' | ') - : '', - odd: typeof p.odds === 'number' ? p.odds : undefined, + ? p.decision_reasons.join(" | ") + : "", + odd: typeof p.odds === "number" ? p.odds : undefined, valueBet: - typeof p.edge === 'number' + typeof p.edge === "number" ? { isValue: p.edge > 0, edge: p.edge, @@ -138,8 +138,8 @@ export class AiService { : recommendedBets, homeAnalysis: undefined, awayAnalysis: undefined, - expertComment: data.ai_commentary || data.expert_comment || '', - modelVersion: data.model_version || 'v25.main', + 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, @@ -161,10 +161,10 @@ export class AiService { prediction: p.pick, confidence: p.calibrated_confidence ?? p.confidence ?? 0, probabilities: {}, - reasoning: Array.isArray(p.reasons) ? p.reasons.join(' | ') : '', + reasoning: Array.isArray(p.reasons) ? p.reasons.join(" | ") : "", odd: p.odds || 0, valueBet: { - is_value: typeof p.edge === 'number' ? p.edge > 0 : false, + is_value: typeof p.edge === "number" ? p.edge > 0 : false, edge: p.edge || 0, }, })); @@ -176,21 +176,21 @@ export class AiService { 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 || '-', + 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', + 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'}`, + `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' + typeof pyData.score_prediction?.xg_total === "number" ? pyData.score_prediction.xg_total.toFixed(2) - : 'N/A' + : "N/A" }`, ], }; @@ -206,33 +206,33 @@ export class AiService { // MS 1 oranını bul const ms1 = odds.find( (o: any) => - o.category?.toLowerCase().includes('maç sonucu') && o.selection === '1', + 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', + 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', + 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ı', + tacticName: "Benzer Maç Sonucu Oranları", description: - 'Ev sahibi galibiyeti için benzer oran aralığındaki maçlar', + "Ev sahibi galibiyeti için benzer oran aralığındaki maçlar", odds: [ { - categoryName: 'Maç Sonucu', - selectionName: '1', + categoryName: "Maç Sonucu", + selectionName: "1", value: parseFloat(ms1.odd_value), tolerance: 0.3, }, @@ -243,18 +243,18 @@ export class AiService { // 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', + tacticName: "Benzer Gol Beklentisi", + description: "Karşılıklı gol ve toplam gol benzerliği", odds: [ { - categoryName: 'Karşılıklı Gol', - selectionName: 'Var', + categoryName: "Karşılıklı Gol", + selectionName: "Var", value: parseFloat(kgVar.odd_value), tolerance: 0.4, }, { - categoryName: '2,5 Alt/Üst', - selectionName: 'Alt', + categoryName: "2,5 Alt/Üst", + selectionName: "Alt", value: parseFloat(alt25.odd_value), tolerance: 0.3, }, @@ -265,12 +265,12 @@ export class AiService { // 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ı', + tacticName: "Favori Takım Analizi", + description: "Benzer şekilde favori olan ev sahibi takımların maçları", odds: [ { - categoryName: 'Maç Sonucu', - selectionName: '1', + categoryName: "Maç Sonucu", + selectionName: "1", value: parseFloat(ms1.odd_value), tolerance: 0.2, }, @@ -291,7 +291,7 @@ export class AiService { timeout: 5000, }), ); - return response.data?.status === 'healthy'; + return response.data?.status === "healthy"; } catch { return false; } @@ -307,11 +307,11 @@ export class AiService { valueBets: [], homeAnalysis: null, awayAnalysis: null, - expertComment: 'Analiz verisi alınamadı (Python Servis Hatası).', - winnerPrediction: 'N/A', - scorePrediction: '-', + expertComment: "Analiz verisi alınamadı (Python Servis Hatası).", + winnerPrediction: "N/A", + scorePrediction: "-", confidenceScore: 0, - modelVersion: 'v25.main', + modelVersion: "v25.main", expectedGoals: 0, keyInsights: [], }; diff --git a/src/services/match-analysis.service.ts b/src/services/match-analysis.service.ts index 86e1201..8c97619 100755 --- a/src/services/match-analysis.service.ts +++ b/src/services/match-analysis.service.ts @@ -1,7 +1,7 @@ -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'; +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; @@ -29,7 +29,7 @@ export class MatchAnalysisService { // Phase 1: Parse URL const { matchId, matchSlug, sport } = this.parseUrl(url); if (!matchId) { - throw new BadRequestException('Invalid match URL'); + throw new BadRequestException("Invalid match URL"); } // Phase 2: Scrape data with retry @@ -56,14 +56,14 @@ export class MatchAnalysisService { } if (attempt === retryDelays.length) { throw new BadRequestException( - 'Maç bilgileri çekilemedi. Lütfen sonra tekrar deneyiniz.', + "Maç bilgileri çekilemedi. Lütfen sonra tekrar deneyiniz.", ); } throw error; } } - this.logger.log('Phase 1 Complete: Data Scraped'); + this.logger.log("Phase 1 Complete: Data Scraped"); // Phase 3: Call Python AI Engine let pythonAnalysis: AIPredictionResult | null = null; @@ -81,7 +81,7 @@ export class MatchAnalysisService { this.logger.warn(`Python Engine error: ${err.message}`); } - this.logger.log('Phase 2 Complete: Python Engine Consulted'); + this.logger.log("Phase 2 Complete: Python Engine Consulted"); // Phase 4: Generate strategy const matchDNA = await this.aggregateDataForAI( @@ -121,7 +121,7 @@ export class MatchAnalysisService { const aiAnalysis = await this.aiService.getPredictionForMatch(aiPayload); - this.logger.log('Phase 5 Complete: Analysis Generated'); + this.logger.log("Phase 5 Complete: Analysis Generated"); // Phase 7: Save to DB if user provided if (userId) { @@ -146,18 +146,18 @@ export class MatchAnalysisService { private parseUrl(url: string): { matchId: string; matchSlug: string; - sport: 'football' | 'basketball'; + 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 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' }; + return { matchId: "", matchSlug: "", sport: "football" }; } } @@ -205,9 +205,9 @@ export class MatchAnalysisService { { homeTeamId, awayTeamId }, { homeTeamId: awayTeamId, awayTeamId: homeTeamId }, ], - state: 'Finished', + state: "Finished", }, - orderBy: { mstUtc: 'desc' }, + orderBy: { mstUtc: "desc" }, take: 10, select: { id: true, @@ -222,9 +222,9 @@ export class MatchAnalysisService { const homeForm = await this.prisma.match.findMany({ where: { OR: [{ homeTeamId }, { awayTeamId: homeTeamId }], - state: 'Finished', + state: "Finished", }, - orderBy: { mstUtc: 'desc' }, + orderBy: { mstUtc: "desc" }, take: 5, select: { id: true, @@ -239,9 +239,9 @@ export class MatchAnalysisService { const awayForm = await this.prisma.match.findMany({ where: { OR: [{ homeTeamId: awayTeamId }, { awayTeamId }], - state: 'Finished', + state: "Finished", }, - orderBy: { mstUtc: 'desc' }, + orderBy: { mstUtc: "desc" }, take: 5, select: { id: true, @@ -277,7 +277,7 @@ export class MatchAnalysisService { scoreHome: { not: null }, }, take: 50, - orderBy: { mstUtc: 'desc' }, + orderBy: { mstUtc: "desc" }, select: { id: true, matchName: true, diff --git a/src/services/scraper.service.ts b/src/services/scraper.service.ts index ac1c6d6..c3efaf0 100755 --- a/src/services/scraper.service.ts +++ b/src/services/scraper.service.ts @@ -1,7 +1,7 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { HttpService } from '@nestjs/axios'; -import { firstValueFrom } from 'rxjs'; -import * as cheerio from 'cheerio'; +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; @@ -44,9 +44,9 @@ export class ScraperService { async scrapeMatchData( matchId: string, matchSlug: string, - sport: 'football' | 'basketball', + sport: "football" | "basketball", ): Promise { - const sportPath = sport === 'basketball' ? 'basketbol/mac' : 'mac'; + 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}`; @@ -104,8 +104,8 @@ export class ScraperService { const $ = cheerio.load(html); const settings: any[] = []; - $('[data-settings]').each((i, elem) => { - const settingsJson = $(elem).attr('data-settings'); + $("[data-settings]").each((i, elem) => { + const settingsJson = $(elem).attr("data-settings"); if (settingsJson) { try { settings.push(JSON.parse(settingsJson)); @@ -162,14 +162,14 @@ export class ScraperService { 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', + 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', + status: this.extractMatchStatus(settings) || "NS", date: matchHeader?.match?.startTime?.utc ? new Date(matchHeader.match.startTime.utc).toISOString() : new Date().toISOString(), @@ -184,7 +184,7 @@ export class ScraperService { */ private extractMatchStatus(settings: any[]): string { const matchState = settings.find((s) => s.matchState || s.status); - return matchState?.matchState || matchState?.status || 'NS'; + return matchState?.matchState || matchState?.status || "NS"; } /** diff --git a/src/services/services.module.ts b/src/services/services.module.ts index 0427299..1de9e1d 100755 --- a/src/services/services.module.ts +++ b/src/services/services.module.ts @@ -1,9 +1,9 @@ -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'; +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({ @@ -13,9 +13,9 @@ import { MatchAnalysisService } from './match-analysis.service'; 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', + "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", }, }), ], diff --git a/src/tasks/data-fetcher.task.ts b/src/tasks/data-fetcher.task.ts index 9ef2bf9..fd701ec 100755 --- a/src/tasks/data-fetcher.task.ts +++ b/src/tasks/data-fetcher.task.ts @@ -1,13 +1,17 @@ -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'; +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"; + +// ──────────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────────── interface LiveScoreTeamPayload { id: string; @@ -58,6 +62,12 @@ interface LiveLineupsJson { away: { xi: unknown[]; subs: unknown[] }; } +type SportType = "football" | "basketball"; + +// ──────────────────────────────────────────────────────────────── +// Service +// ──────────────────────────────────────────────────────────────── + @Injectable() export class DataFetcherTask { private readonly logger = new Logger(DataFetcherTask.name); @@ -68,8 +78,904 @@ export class DataFetcherTask { private readonly scraper: FeederScraperService, ) {} + // ──────────────────────────────────────────────────────────── + // CRON 1: Main sync — every 15 minutes + // Phases: match list → live scores → odds → lineups + // ──────────────────────────────────────────────────────────── + + @Cron("*/15 * * * *") + async syncLiveMatches(): Promise { + if (this.shouldSkipInHistoricalMode("syncLiveMatches")) return; + this.logger.log("━━━ syncLiveMatches START ━━━"); + + const today = new Date().toISOString().split("T")[0]; + + // Phase 1: Match list (football + basketball) + await this.syncMatchList(today); + + // Phase 2: Live score updates + await this.updateLiveScores(); + + // Phase 3: Odds + referee + lineups + sidelined (via processMatchOdds) + await this.fetchOddsForMatches(); + + // Phase 4: Fill missing lineups (backup for edge cases) + await this.fillMissingLineups(); + + this.logger.log("━━━ syncLiveMatches END ━━━"); + } + + // ──────────────────────────────────────────────────────────── + // CRON 2: Daily cleanup + full sync — 07:00 Istanbul + // Truncates live_matches, then runs full sync + // ──────────────────────────────────────────────────────────── + + @Cron("0 7 * * *", { timeZone: "Europe/Istanbul" }) + async cleanAndFullSync(): Promise { + if (this.shouldSkipInHistoricalMode("cleanAndFullSync")) return; + this.logger.log("🧹 cleanAndFullSync: Truncating live_matches..."); + + try { + const deleted = await this.prisma.liveMatch.deleteMany({}); + this.logger.log( + `🧹 Deleted ${deleted.count} live matches. Starting full sync...`, + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Truncate failed: ${message}`); + return; + } + + // Run full sync immediately after cleanup + await this.syncLiveMatches(); + } + + // ──────────────────────────────────────────────────────────── + // Phase 1: Fetch match list for all sports + // ──────────────────────────────────────────────────────────── + + private async syncMatchList(date: string): Promise { + // Football + const footballLeagues = this.loadLeagueFilterSet("top_leagues.json"); + if (footballLeagues && footballLeagues.size > 0) { + await this.fetchMatchesForSport("football", date, footballLeagues); + } else { + this.logger.warn( + "top_leagues.json is missing/empty — writing ALL football matches", + ); + await this.fetchMatchesForSport("football", date, new Set()); + } + + // Basketball + const basketballLeagues = this.loadLeagueFilterSet( + "basketball_top_leagues.json", + ); + if (!basketballLeagues || basketballLeagues.size === 0) { + this.logger.error( + "basketball_top_leagues.json is missing/empty. Basketball ingest skipped to protect data quality.", + ); + } else { + await this.fetchMatchesForSport("basketball", date, basketballLeagues); + + // Clean up basketball matches that are NOT in configured leagues + await this.prisma.liveMatch.deleteMany({ + where: { + sport: "basketball", + OR: [ + { leagueId: null }, + { leagueId: { notIn: Array.from(basketballLeagues) } }, + ], + }, + }); + } + } + + // ──────────────────────────────────────────────────────────── + // Phase 2: Live score updates (merged from live-updater.task) + // ──────────────────────────────────────────────────────────── + + private async updateLiveScores(): Promise { + try { + 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 scores for"); + return; + } + + this.logger.log( + `📡 Updating scores for ${liveMatches.length} live matches`, + ); + + 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: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Live score update failed: ${message}`); + } + } + + // ──────────────────────────────────────────────────────────── + // Phase 3: Odds + referee + lineups + sidelined + // ──────────────────────────────────────────────────────────── + + private async fetchOddsForMatches(): Promise { + this.logger.log("💰 Fetching odds for live matches..."); + + try { + // Load both league filters + const topLeagueIds: string[] = []; + + const footballLeagues = this.loadLeagueFilterSet("top_leagues.json"); + if (footballLeagues) topLeagueIds.push(...footballLeagues); + + const basketballLeagues = this.loadLeagueFilterSet( + "basketball_top_leagues.json", + ); + if (basketballLeagues) topLeagueIds.push(...basketballLeagues); + + const allowedLeagueIds = Array.from(new Set(topLeagueIds)); + + // Get matches needing odds (from 12 hours ago onward) + 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[] = []; + + // Initial pass + for (const match of matchesToFetch) { + try { + await this.processMatchOdds(match); + successCount++; + await this.delay(500); + } catch (err: unknown) { + errorCount++; + const isRetryable = this.isRetryableError(err); + if (isRetryable) { + failedMatches.push(match); + } else { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `Match ${match.id} odds fetch failed (Non-retryable): ${message}`, + ); + } + } + } + + // Retry failed matches (502/Timeout) + 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: unknown) { + const message = + retryErr instanceof Error ? retryErr.message : String(retryErr); + this.logger.error( + `❌ Retry failed for match ${match.id}: ${message}`, + ); + } + } + } + + this.logger.log( + `💰 Odds complete: ${successCount} success, ${errorCount} errors (initially)`, + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Odds fetch job failed: ${message}`); + } + } + + // ──────────────────────────────────────────────────────────── + // Phase 4: Fill missing lineups (backup) + // ──────────────────────────────────────────────────────────── + + private async fillMissingLineups(): Promise { + try { + const matchesToUpdate = await this.prisma.liveMatch.findMany({ + where: { status: { notIn: ["FT", "post", "postGame"] } }, + select: { id: true, matchSlug: true, lineups: true, sport: true }, + take: 30, + }); + + // Only football matches without lineups + const toUpdate = matchesToUpdate.filter( + (m) => !m.lineups && m.sport === "football", + ); + + if (toUpdate.length === 0) { + this.logger.debug("👕 All lineups already filled"); + return; + } + + this.logger.log(`👕 Filling lineups for ${toUpdate.length} matches...`); + + for (const match of toUpdate) { + try { + const formation = await this.scraper.fetchStartingFormation(match.id); + const sidelined = match.matchSlug + ? await this.scraper.fetchSidelinedPlayers( + match.id, + match.matchSlug, + ) + : null; + + 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 filled for match ${match.id}`); + await this.delay(500); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn(`Lineup fill failed for ${match.id}: ${message}`); + } + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Lineup fill error: ${message}`); + } + } + + // ──────────────────────────────────────────────────────────── + // Unified match fetcher — DRY for football + basketball + // ──────────────────────────────────────────────────────────── + + private async fetchMatchesForSport( + sport: SportType, + date: string, + topLeagueIds: Set, + ): Promise { + const sportParam = sport === "basketball" ? "Basketball" : "Soccer"; + const url = `https://www.mackolik.com/perform/p0/ajax/components/competition/livescores/json?sports[]=${sportParam}&matchDate=${date}`; + + try { + const response = await firstValueFrom( + this.httpService.get(url, { timeout: 20000 }), + ); + + const payload = this.parseLiveScoresPayload(response.data); + if (!payload) { + this.logger.warn(`[${sport}] No valid data from API for ${date}`); + return; + } + + const allMatches = payload.matches; + const competitions = payload.competitions; + + // Apply league filter + const targetMatches = + topLeagueIds.size > 0 + ? allMatches.filter( + (m) => + !!m.competitionId && topLeagueIds.has(String(m.competitionId)), + ) + : allMatches; + + if (targetMatches.length === 0) { + this.logger.log(`[${sport}] No matches found for ${date}`); + return; + } + + this.logger.log( + `[${sport}] Processing ${targetMatches.length}/${allMatches.length} matches for ${date}`, + ); + + // Local caches to avoid N+1 redundant upserts + const processedCountries = new Set(); + const processedLeagues = new Set(); + const processedTeams = new Set(); + const integrityChecked = new Map(); + + let upsertCount = 0; + let skippedCount = 0; + + for (const match of targetMatches) { + try { + const homeTeamId = match.homeTeam.id; + const awayTeamId = match.awayTeam.id; + const leagueId = match.competitionId; + + // Sport integrity check (cached) + const ensureIntegrity = async ( + entityType: "league" | "team" | "liveMatch", + entityId: string | null, + ): Promise => { + if (!entityId) return true; + const cacheKey = `${sport}:${entityType}:${entityId}`; + if (!integrityChecked.has(cacheKey)) { + integrityChecked.set( + cacheKey, + await this.ensureLiveEntitySportIntegrity( + entityType, + entityId, + sport, + ), + ); + } + 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; + } + + // Resolve competition details + const compInfo = this.resolveCompetition(competitions, leagueId); + 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: Error) => + this.logger.warn( + `[${sport}] 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, + sport: sport, + }, + create: { + id: leagueId, + name: compInfo.name, + countryId: countryId, + sport: sport, + competitionSlug: compInfo.slug || null, + }, + }) + .catch((e: Error) => + this.logger.warn( + `[${sport}] League upsert failed: ${e.message}`, + ), + ); + processedLeagues.add(String(leagueId)); + } + + // 3. Upsert Home Team (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, + sport: sport, + }, + create: { + id: homeTeamId, + name: match.homeTeam.name, + slug: match.homeTeam.slug || null, + sport: sport, + }, + }) + .catch((e: Error) => + this.logger.warn( + `[${sport}] Home team upsert failed: ${e.message}`, + ), + ); + processedTeams.add(homeTeamId); + } + + // 4. Upsert Away Team (cached) + 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: sport, + }, + create: { + id: awayTeamId, + name: match.awayTeam.name, + slug: match.awayTeam.slug || null, + sport: sport, + }, + }) + .catch((e: Error) => + this.logger.warn( + `[${sport}] Away team upsert failed: ${e.message}`, + ), + ); + processedTeams.add(awayTeamId); + } + + // 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") { + await this.prisma.liveMatch + .updateMany({ + where: { id: String(match.id) }, + data: { + status: "POSTPONED", + state: "postponed", + substate: "postponed", + updatedAt: new Date(), + }, + }) + .catch(() => {}); + this.logger.debug( + `[${sport}] Marked as POSTPONED: ${match.matchName}`, + ); + skippedCount++; + continue; + } + + // 5. Upsert LiveMatch + 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: sport, + 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++; + + // Progress logging every 100 matches + if ( + (upsertCount + skippedCount) % 100 === 0 || + upsertCount + skippedCount === targetMatches.length + ) { + this.logger.log( + `[${sport}] ⏳ Progress: ${upsertCount + skippedCount}/${targetMatches.length} (Saved: ${upsertCount}, Skipped: ${skippedCount})`, + ); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn(`[${sport}] Match ${match.id} failed: ${message}`); + } + } + + this.logger.log( + `[${sport}] [${date}] Done: ${upsertCount} saved, ${skippedCount} skipped`, + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`[${sport}] Fetch failed for ${date}: ${message}`); + } + } + + // ──────────────────────────────────────────────────────────── + // processMatchOdds — odds + referee + lineups + sidelined + // (Preserved from original — no logic changes) + // ──────────────────────────────────────────────────────────── + + private async processMatchOdds(match: LiveMatchOddsTarget): Promise { + 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: unknown) { + if (this.isRetryableError(e)) throw e; + const message = e instanceof Error ? e.message : String(e); + this.logger.warn(`Odds fetch failed for ${match.id}: ${message}`); + } + + if (sport === "football") { + // 2. Fetch Referee from main match 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 + 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: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn(`Lineup fetch failed for ${match.id}: ${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: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `Sidelined fetch failed for ${match.id}: ${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.`, + ); + } + } + + // ──────────────────────────────────────────────────────────── + // HTML Extraction Helpers (preserved — no logic changes) + // ──────────────────────────────────────────────────────────── + + /** + * 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 { + const settingsPattern = /data-settings="([^"]+)"/g; + let match; + + while ((match = settingsPattern.exec(html)) !== null) { + try { + 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; + 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; + } + + /** + * Extract referee name from match page HTML + */ + private extractRefereeFromHtml(html: string): string | null { + try { + // Strategy 1: Mackolik officials section — head referee in '--main' list item + 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" + 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 result = pattern.exec(html); + if (result?.[1]) { + const name = result[1].trim(); + if (name.length > 2 && name.length < 100) return name; + } + } + } catch { + // Ignore extraction errors + } + return null; + } + + // ──────────────────────────────────────────────────────────── + // Low-level Helpers (preserved — no logic changes) + // ──────────────────────────────────────────────────────────── + private shouldSkipInHistoricalMode(jobName: string): boolean { - if (process.env.FEEDER_MODE === 'historical') { + if (process.env.FEEDER_MODE === "historical") { this.logger.debug(`Skipping ${jobName} in historical feeder mode`); return true; } @@ -77,21 +983,21 @@ export class DataFetcherTask { } private isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object' && !Array.isArray(value); + 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)) + 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)) { + if (typeof value === "number" && Number.isFinite(value)) { return Math.trunc(value); } - if (typeof value === 'string' && value.trim().length > 0) { + if (typeof value === "string" && value.trim().length > 0) { const parsed = parseInt(value, 10); return Number.isNaN(parsed) ? null : parsed; } @@ -106,7 +1012,7 @@ export class DataFetcherTask { return { id, - name: this.asString(value.name) || 'Unknown', + name: this.asString(value.name) || "Unknown", slug: this.asString(value.slug), }; } @@ -150,7 +1056,7 @@ export class DataFetcherTask { id, matchName: this.asString(value.matchName) || `${homeTeam.name} - ${awayTeam.name}`, - matchSlug: this.asString(value.matchSlug) || '', + matchSlug: this.asString(value.matchSlug) || "", competitionId: this.asString(value.competitionId), mstUtc: this.asInt(value.mstUtc), state: this.asString(value.state), @@ -171,7 +1077,7 @@ export class DataFetcherTask { competitions: Record; } | null { if (!this.isRecord(raw)) return null; - if (this.asString(raw.status) !== 'success') return null; + if (this.asString(raw.status) !== "success") return null; const data = this.isRecord(raw.data) ? raw.data : null; if (!data) return null; @@ -201,7 +1107,7 @@ export class DataFetcherTask { 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; + 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; @@ -211,8 +1117,9 @@ export class DataFetcherTask { .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}`); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + this.logger.error(`Failed to load ${fileName}: ${message}`); return null; } } @@ -230,21 +1137,21 @@ export class DataFetcherTask { } private async ensureLiveEntitySportIntegrity( - entityType: 'league' | 'team' | 'liveMatch', + entityType: "league" | "team" | "liveMatch", id: string, - expectedSport: 'football' | 'basketball', + expectedSport: SportType, ): Promise { if (!id) return true; let existingSport: string | null | undefined; - if (entityType === 'league') { + if (entityType === "league") { const existing = await this.prisma.league.findUnique({ where: { id }, select: { sport: true }, }); existingSport = existing?.sport; - } else if (entityType === 'team') { + } else if (entityType === "team") { const existing = await this.prisma.team.findUnique({ where: { id }, select: { sport: true }, @@ -268,1193 +1175,17 @@ export class DataFetcherTask { 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)`, + private isRetryableError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const errorObj = err as Record; + const response = errorObj.response as Record | undefined; + return ( + response?.status === 502 || + errorObj.code === "ECONNABORTED" || + errorObj.code === "ETIMEDOUT" ); } - /** - * 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 index c72f614..c35867b 100644 --- a/src/tasks/historical-results-sync.task.spec.ts +++ b/src/tasks/historical-results-sync.task.spec.ts @@ -1,7 +1,7 @@ -import { FeederService } from '../modules/feeder/feeder.service'; -import { HistoricalResultsSyncTask } from './historical-results-sync.task'; +import { FeederService } from "../modules/feeder/feeder.service"; +import { HistoricalResultsSyncTask } from "./historical-results-sync.task"; -describe('HistoricalResultsSyncTask', () => { +describe("HistoricalResultsSyncTask", () => { const runPreviousDayCompletedMatchesScan = jest.fn(); let task: HistoricalResultsSyncTask; @@ -18,14 +18,14 @@ describe('HistoricalResultsSyncTask', () => { delete process.env.FEEDER_MODE; }); - it('calls feeder service in normal mode', async () => { + 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'; + it("skips execution in historical feeder mode", async () => { + process.env.FEEDER_MODE = "historical"; await task.syncPreviousDayCompletedMatches(); diff --git a/src/tasks/historical-results-sync.task.ts b/src/tasks/historical-results-sync.task.ts index a170cdb..bc1066d 100644 --- a/src/tasks/historical-results-sync.task.ts +++ b/src/tasks/historical-results-sync.task.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { FeederService } from '../modules/feeder/feeder.service'; +import { Injectable, Logger } from "@nestjs/common"; +import { Cron } from "@nestjs/schedule"; +import { FeederService } from "../modules/feeder/feeder.service"; @Injectable() export class HistoricalResultsSyncTask { @@ -9,7 +9,7 @@ export class HistoricalResultsSyncTask { constructor(private readonly feederService: FeederService) {} private shouldSkipInHistoricalMode(jobName: string): boolean { - if (process.env.FEEDER_MODE === 'historical') { + if (process.env.FEEDER_MODE === "historical") { this.logger.debug(`Skipping ${jobName} in historical feeder mode`); return true; } @@ -19,19 +19,19 @@ export class HistoricalResultsSyncTask { /** * Pull yesterday's completed matches into the permanent matches table. */ - @Cron('0 8 * * *', { timeZone: 'Europe/Istanbul' }) + @Cron("0 8 * * *", { timeZone: "Europe/Istanbul" }) async syncPreviousDayCompletedMatches() { - if (this.shouldSkipInHistoricalMode('syncPreviousDayCompletedMatches')) { + if (this.shouldSkipInHistoricalMode("syncPreviousDayCompletedMatches")) { return; } this.logger.log( - 'Starting previous-day completed match sync for football and basketball...', + "Starting previous-day completed match sync for football and basketball...", ); try { await this.feederService.runPreviousDayCompletedMatchesScan(); - this.logger.log('Previous-day completed match sync finished'); + 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 index c97a680..0a547af 100755 --- a/src/tasks/limit-resetter.task.ts +++ b/src/tasks/limit-resetter.task.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { PrismaService } from '../database/prisma.service'; +import { Injectable, Logger } from "@nestjs/common"; +import { Cron } from "@nestjs/schedule"; +import { PrismaService } from "../database/prisma.service"; @Injectable() export class LimitResetterTask { @@ -9,7 +9,7 @@ export class LimitResetterTask { constructor(private readonly prisma: PrismaService) {} private shouldSkipInHistoricalMode(jobName: string): boolean { - if (process.env.FEEDER_MODE === 'historical') { + if (process.env.FEEDER_MODE === "historical") { this.logger.debug(`Skipping ${jobName} in historical feeder mode`); return true; } @@ -19,10 +19,10 @@ export class LimitResetterTask { /** * Reset usage limits daily at 03:00 (Europe/Istanbul) */ - @Cron('0 3 * * *', { timeZone: 'Europe/Istanbul' }) + @Cron("0 3 * * *", { timeZone: "Europe/Istanbul" }) async resetUsageLimits() { - if (this.shouldSkipInHistoricalMode('resetUsageLimits')) return; - this.logger.log('Starting daily usage limit reset job...'); + if (this.shouldSkipInHistoricalMode("resetUsageLimits")) return; + this.logger.log("Starting daily usage limit reset job..."); try { const today = new Date(); @@ -45,7 +45,7 @@ export class LimitResetterTask { `Usage limits for ${result.count} users have been reset`, ); } else { - this.logger.log('No user limits needed resetting'); + this.logger.log("No user limits needed resetting"); } } catch (error: any) { this.logger.error(`Limit reset job failed: ${error.message}`); @@ -55,10 +55,10 @@ export class LimitResetterTask { /** * Clean up old predictions (older than 30 days) */ - @Cron('0 4 * * *', { timeZone: 'Europe/Istanbul' }) + @Cron("0 4 * * *", { timeZone: "Europe/Istanbul" }) async cleanupOldData() { - if (this.shouldSkipInHistoricalMode('cleanupOldData')) return; - this.logger.log('Starting data cleanup job...'); + if (this.shouldSkipInHistoricalMode("cleanupOldData")) return; + this.logger.log("Starting data cleanup job..."); try { const thirtyDaysAgo = new Date(); @@ -78,7 +78,7 @@ export class LimitResetterTask { const deletedLiveMatches = await this.prisma.liveMatch.deleteMany({ where: { - state: 'Finished', + state: "Finished", updatedAt: { lt: oneDayAgo }, }, }); @@ -94,21 +94,21 @@ export class LimitResetterTask { /** * Reset subscription status for expired users */ - @Cron('0 0 * * *', { timeZone: 'Europe/Istanbul' }) + @Cron("0 0 * * *", { timeZone: "Europe/Istanbul" }) async checkSubscriptions() { - if (this.shouldSkipInHistoricalMode('checkSubscriptions')) return; - this.logger.log('Checking expired subscriptions...'); + 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', + subscriptionStatus: "active", subscriptionExpiresAt: { lt: now }, }, data: { - subscriptionStatus: 'expired', + subscriptionStatus: "expired", }, }); diff --git a/src/tasks/live-updater.task.ts b/src/tasks/live-updater.task.ts deleted file mode 100755 index 7f5e1f9..0000000 --- a/src/tasks/live-updater.task.ts +++ /dev/null @@ -1,177 +0,0 @@ -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 index 63ff8d6..b9bfb00 100755 --- a/src/tasks/tasks.module.ts +++ b/src/tasks/tasks.module.ts @@ -1,12 +1,11 @@ -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'; +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 { DatabaseModule } from "../database/database.module"; +import { FeederModule } from "../modules/feeder/feeder.module"; @Module({ imports: [ @@ -14,24 +13,14 @@ import { FeederModule } from '../modules/feeder/feeder.module'; HttpModule.register({ timeout: 30000, headers: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + "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, - ], + providers: [DataFetcherTask, HistoricalResultsSyncTask, LimitResetterTask], + exports: [DataFetcherTask, HistoricalResultsSyncTask, LimitResetterTask], }) export class TasksModule {} diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 36852c5..76ccc7c 100755 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,10 +1,10 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import request from 'supertest'; -import { App } from 'supertest/types'; -import { AppModule } from './../src/app.module'; +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import { App } from "supertest/types"; +import { AppModule } from "./../src/app.module"; -describe('AppController (e2e)', () => { +describe("AppController (e2e)", () => { let app: INestApplication; beforeEach(async () => { @@ -16,10 +16,10 @@ describe('AppController (e2e)', () => { await app.init(); }); - it('/ (GET)', () => { + it("/ (GET)", () => { return request(app.getHttpServer()) - .get('/') + .get("/") .expect(200) - .expect('Hello World!'); + .expect("Hello World!"); }); });