Compare commits
3 Commits
249c57346e
...
cron
| Author | SHA1 | Date | |
|---|---|---|---|
| c8e7e4e927 | |||
| c8fa4c442d | |||
| 0f917695dd |
@@ -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
|
||||
@@ -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>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
describe("root", () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
expect(appController.getHello()).toBe("Hello World!");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+54
-54
@@ -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,
|
||||
};
|
||||
|
||||
+2
-2
@@ -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!";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T, CreateDto, UpdateDto> {
|
||||
|
||||
@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<ApiResponse<{ items: T[]; meta: any }>> {
|
||||
@@ -52,13 +52,13 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
|
||||
);
|
||||
}
|
||||
|
||||
@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<ApiResponse<T>> {
|
||||
const result = await this.service.findOne(id);
|
||||
return createSuccessResponse(
|
||||
@@ -69,9 +69,9 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
|
||||
|
||||
@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<ApiResponse<T>> {
|
||||
const result = await this.service.create(createDto);
|
||||
return createSuccessResponse(
|
||||
@@ -81,13 +81,13 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
|
||||
);
|
||||
}
|
||||
|
||||
@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<ApiResponse<T>> {
|
||||
const result = await this.service.update(id, updateDto);
|
||||
@@ -97,13 +97,13 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
|
||||
);
|
||||
}
|
||||
|
||||
@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<ApiResponse<T>> {
|
||||
const result = await this.service.delete(id);
|
||||
return createSuccessResponse(
|
||||
@@ -112,12 +112,12 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
|
||||
);
|
||||
}
|
||||
|
||||
@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<ApiResponse<T>> {
|
||||
const result = await this.service.restore(id);
|
||||
return createSuccessResponse(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './base.service';
|
||||
export * from './base.controller';
|
||||
export * from "./base.service";
|
||||
export * from "./base.controller";
|
||||
|
||||
@@ -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";
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<string, 'asc' | 'desc'> {
|
||||
return { [this.sortBy || 'createdAt']: this.sortOrder || 'desc' };
|
||||
get orderBy(): Record<string, "asc" | "desc"> {
|
||||
return { [this.sortBy || "createdAt"]: this.sortOrder || "desc" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<null> = createErrorResponse(
|
||||
message,
|
||||
status,
|
||||
|
||||
@@ -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<T> implements NestInterceptor<
|
||||
@@ -34,17 +34,17 @@ export class ResponseInterceptor<T> 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<T> implements NestInterceptor<
|
||||
}
|
||||
}
|
||||
|
||||
const message = this.i18n.translate('common.success', {
|
||||
const message = this.i18n.translate("common.success", {
|
||||
lang,
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ export class ResponseInterceptor<T> 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<T> 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<T> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<unknown> {
|
||||
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<string, unknown> = {};
|
||||
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, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface PaginationMeta {
|
||||
*/
|
||||
export function createSuccessResponse<T>(
|
||||
data: T,
|
||||
message = 'Success',
|
||||
message = "Success",
|
||||
status = 200,
|
||||
): ApiResponse<T> {
|
||||
return {
|
||||
@@ -72,7 +72,7 @@ export function createPaginatedResponse<T>(
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
message = 'Success',
|
||||
message = "Success",
|
||||
): ApiResponse<PaginatedData<T>> {
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
+29
-29
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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<string, unknown>): 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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+42
-42
@@ -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<number>('PORT', 3005);
|
||||
const nodeEnv = configService.get('NODE_ENV', 'development');
|
||||
const port = configService.get<number>("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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ApiResponse<PaginatedData<UserResponseDto>>> {
|
||||
@@ -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<ApiResponse<UserResponseDto>> {
|
||||
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<ApiResponse<UserResponseDto>> {
|
||||
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<ApiResponse<UserResponseDto>> {
|
||||
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<ApiResponse<UserResponseDto>> {
|
||||
@@ -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<ApiResponse<null>> {
|
||||
@Delete("users/:id")
|
||||
@ApiOperation({ summary: "Soft delete a user" })
|
||||
async deleteUser(@Param("id") id: string): Promise<ApiResponse<null>> {
|
||||
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<ApiResponse<Record<string, string>>> {
|
||||
const settings = await this.prisma.appSetting.findMany();
|
||||
const settingsMap: Record<string, string> = {};
|
||||
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<ApiResponse<{ key: string; value: string }>> {
|
||||
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<ApiResponse<{ count: number }>> {
|
||||
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(),
|
||||
]);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Exclude, Expose, Type } from 'class-transformer';
|
||||
import { Exclude, Expose, Type } from "class-transformer";
|
||||
|
||||
@Exclude()
|
||||
export class PermissionResponseDto {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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<ApiResponse<TokenResponseDto>> {
|
||||
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<ApiResponse<TokenResponseDto>> {
|
||||
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<ApiResponse<TokenResponseDto>> {
|
||||
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<ApiResponse<null>> {
|
||||
await this.authService.logout(dto.refreshToken);
|
||||
return createSuccessResponse(null, i18n.t('auth.logout_success'));
|
||||
return createSuccessResponse(null, i18n.t("auth.logout_success"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>('JWT_ACCESS_EXPIRATION') || '15m';
|
||||
configService.get<string>("JWT_ACCESS_EXPIRATION") || "15m";
|
||||
return {
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
secret: configService.get<string>("JWT_SECRET"),
|
||||
signOptions: {
|
||||
expiresIn: expiresIn as any,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Request>();
|
||||
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<Request>();
|
||||
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<Request>();
|
||||
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;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './auth.guards';
|
||||
export * from "./auth.guards";
|
||||
|
||||
@@ -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<string>('JWT_SECRET');
|
||||
const secret = configService.get<string>("JWT_SECRET");
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET is not defined');
|
||||
throw new Error("JWT_SECRET is not defined");
|
||||
}
|
||||
|
||||
super({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<SingleMatchPredictionPackage> {
|
||||
@@ -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<SmartCouponResult> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
// 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;
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<KeyEventsResponse['data'] | null> {
|
||||
): Promise<KeyEventsResponse["data"] | null> {
|
||||
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<KeyEventsResponse>(url, {
|
||||
params: {
|
||||
ajaxViewName: 'events',
|
||||
ajaxViewName: "events",
|
||||
matchId,
|
||||
seasonId: matchId, // Same as matchId
|
||||
},
|
||||
@@ -172,7 +172,7 @@ export class FeederScraperService {
|
||||
// ============================================
|
||||
async fetchStartingFormation(
|
||||
matchId: string,
|
||||
): Promise<MatchStatsResponse['data'] | null> {
|
||||
): Promise<MatchStatsResponse["data"] | null> {
|
||||
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<MatchStatsResponse>(url, {
|
||||
params: {
|
||||
ajaxViewName: 'starting-formation',
|
||||
ajaxViewName: "starting-formation",
|
||||
matchId,
|
||||
seasonId: matchId,
|
||||
},
|
||||
@@ -201,7 +201,7 @@ export class FeederScraperService {
|
||||
// ============================================
|
||||
async fetchSubstitutions(
|
||||
matchId: string,
|
||||
): Promise<MatchStatsResponse['data'] | null> {
|
||||
): Promise<MatchStatsResponse["data"] | null> {
|
||||
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<MatchStatsResponse>(url, {
|
||||
params: {
|
||||
ajaxViewName: 'substitutions',
|
||||
ajaxViewName: "substitutions",
|
||||
matchId,
|
||||
seasonId: matchId,
|
||||
},
|
||||
@@ -230,7 +230,7 @@ export class FeederScraperService {
|
||||
// ============================================
|
||||
async fetchGameStats(
|
||||
matchId: string,
|
||||
): Promise<GameStatsResponse['data'] | null> {
|
||||
): Promise<GameStatsResponse["data"] | null> {
|
||||
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<ManagerResponse['data'] | null> {
|
||||
async fetchManager(matchId: string): Promise<ManagerResponse["data"] | null> {
|
||||
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<ManagerResponse>(url, {
|
||||
params: {
|
||||
ajaxViewName: 'manager',
|
||||
ajaxViewName: "manager",
|
||||
matchId,
|
||||
seasonId: matchId,
|
||||
},
|
||||
@@ -287,10 +287,10 @@ export class FeederScraperService {
|
||||
|
||||
try {
|
||||
const response = await this.axios.get<IddaaMarketsHtmlResponse>(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<BasketballBoxScoreResponse['data'] | null> {
|
||||
): Promise<BasketballBoxScoreResponse["data"] | null> {
|
||||
// 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<BasketballBoxScoreResponse>(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<BasketballPlayerStats>[] = [];
|
||||
|
||||
// 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<IddaaMarketsHtmlResponse>(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<any>,
|
||||
): 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
|
||||
};
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<ProcessResult> {
|
||||
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<string, Competition> = {
|
||||
[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<string, Competition>,
|
||||
sport: Sport,
|
||||
force: boolean = false,
|
||||
scope: 'all' | 'lineups' | 'odds' = 'all', // Add scope flag
|
||||
scope: "all" | "lineups" | "odds" = "all", // Add scope flag
|
||||
): Promise<ProcessResult> {
|
||||
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`);
|
||||
|
||||
@@ -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<string, string> = {
|
||||
// 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<string, string> = {
|
||||
// ============================================
|
||||
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;
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<boolean>('gemini.enabled', false);
|
||||
this.isEnabled = this.configService.get<boolean>("gemini.enabled", false);
|
||||
this.defaultModel = this.configService.get<string>(
|
||||
'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<string>('gemini.apiKey');
|
||||
const apiKey = this.configService.get<string>("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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ActiveLeagueDto[]> {
|
||||
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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<string, LeagueWithMatchesDto>();
|
||||
|
||||
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<string, Record<string, number>>;
|
||||
for (const [marketName, selections] of Object.entries(oddsObj)) {
|
||||
const structuredSelections: Record<string, { odd: string }> = {};
|
||||
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<string, Record<string, number>>;
|
||||
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 },
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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<AIHealthDto> {
|
||||
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<UpcomingPredictionsDto> {
|
||||
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<ValueBetDto[]> {
|
||||
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<PredictionHistoryResponseDto> {
|
||||
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<MatchPredictionDto> {
|
||||
// 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<any> {
|
||||
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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<string>();
|
||||
private readonly reasonTranslations: Record<string, string> = {
|
||||
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<AIHealthDto> {
|
||||
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<MatchPredictionDto | null> {
|
||||
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<string, unknown>;
|
||||
const matchInfo = (out?.match_info || {}) as Record<string, unknown>;
|
||||
@@ -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<string> {
|
||||
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<string>();
|
||||
}
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8'));
|
||||
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
|
||||
if (!Array.isArray(raw)) {
|
||||
return new Set<string>();
|
||||
}
|
||||
@@ -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) =>
|
||||
? response.supporting_picks
|
||||
.map((pick) =>
|
||||
this.enrichPick(pick, response, matchContext, marketBoard),
|
||||
).filter((pick): pick is NonNullable<typeof pick> => pick !== null)
|
||||
)
|
||||
.filter((pick): pick is NonNullable<typeof pick> => 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<string, unknown>, pickName),
|
||||
probability: this.lookupProbability(
|
||||
record.probs as Record<string, unknown>,
|
||||
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<string, unknown>,
|
||||
matchContext: MatchContext,
|
||||
marketBoard: Record<string, unknown>,
|
||||
): 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<string, unknown>,
|
||||
matchContext: MatchContext,
|
||||
marketBoard: Record<string, unknown>,
|
||||
): 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<string, unknown>,
|
||||
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<string, number> = {
|
||||
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<string, unknown> {
|
||||
const entry = this.asRecord(marketBoard[market]);
|
||||
const probs = entry.probs;
|
||||
return probs && typeof probs === 'object'
|
||||
return probs && typeof probs === "object"
|
||||
? (probs as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
@@ -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<string, any> {
|
||||
return value && typeof value === 'object'
|
||||
return value && typeof value === "object"
|
||||
? (value as Record<string, any>)
|
||||
: {};
|
||||
}
|
||||
|
||||
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<ValueBetDto[]> {
|
||||
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<any> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>).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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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<typeof axios>;
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<any, any, string>): Promise<any> {
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<string, any>;
|
||||
|
||||
@@ -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, "/")}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>('META_PAGE_ACCESS_TOKEN') || '';
|
||||
this.pageId = this.configService.get<string>('META_PAGE_ID') || '';
|
||||
this.igUserId = this.configService.get<string>('META_IG_USER_ID') || '';
|
||||
this.configService.get<string>("META_PAGE_ACCESS_TOKEN") || "";
|
||||
this.pageId = this.configService.get<string>("META_PAGE_ID") || "";
|
||||
this.igUserId = this.configService.get<string>("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<string | null> {
|
||||
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<string | null> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string>('AI_ENGINE_URL') ||
|
||||
'http://localhost:8000';
|
||||
this.configService.get<string>("AI_ENGINE_URL") ||
|
||||
"http://localhost:8000";
|
||||
this.appBaseUrl =
|
||||
this.configService.get<string>('APP_BASE_URL') || 'http://localhost:3000';
|
||||
this.configService.get<string>("APP_BASE_URL") || "http://localhost:3000";
|
||||
this.isEnabled =
|
||||
this.configService.get<string>('SOCIAL_POSTER_ENABLED') === 'true';
|
||||
this.configService.get<string>("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<string, { tr: string; en: string }> = {
|
||||
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);
|
||||
|
||||
@@ -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<string>('TWITTER_API_KEY');
|
||||
const apiSecret = this.configService.get<string>('TWITTER_API_SECRET');
|
||||
const accessToken = this.configService.get<string>('TWITTER_ACCESS_TOKEN');
|
||||
const apiKey = this.configService.get<string>("TWITTER_API_KEY");
|
||||
const apiSecret = this.configService.get<string>("TWITTER_API_SECRET");
|
||||
const accessToken = this.configService.get<string>("TWITTER_ACCESS_TOKEN");
|
||||
const accessSecret = this.configService.get<string>(
|
||||
'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<string | null> {
|
||||
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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<SporTotoApiResponse | null> {
|
||||
try {
|
||||
this.logger.log('Fetching current Spor Toto bulletin...');
|
||||
this.logger.log("Fetching current Spor Toto bulletin...");
|
||||
const response = await axios.get<SporTotoApiResponse>(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;
|
||||
}
|
||||
|
||||
@@ -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<PredictionResult> {
|
||||
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<Array<{ id: string }>>(
|
||||
`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<PredictionResult['evReport']> {
|
||||
): Promise<PredictionResult["evReport"]> {
|
||||
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.`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<User, CreateUserDto, UpdateUserDto>['create']
|
||||
BaseController<User, CreateUserDto, UpdateUserDto>["create"]
|
||||
>
|
||||
) {
|
||||
return super.create(...args);
|
||||
}
|
||||
|
||||
// Override delete to require admin role
|
||||
@Roles('admin')
|
||||
@Roles("admin")
|
||||
async delete(
|
||||
...args: Parameters<
|
||||
BaseController<User, CreateUserDto, UpdateUserDto>['delete']
|
||||
BaseController<User, CreateUserDto, UpdateUserDto>["delete"]
|
||||
>
|
||||
) {
|
||||
return super.delete(...args);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown> | 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<BacktestResult[]> {
|
||||
|
||||
// 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<BacktestResult[]> {
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
async function runBacktest(): Promise<void> {
|
||||
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<void> {
|
||||
});
|
||||
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<TestMatch[]>`
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<string, { correct: number; total: number }> = {
|
||||
'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<void> {
|
||||
);
|
||||
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<void> {
|
||||
` 🏆 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<void> {
|
||||
` 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<void> {
|
||||
` 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<void> {
|
||||
` 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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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("---------------------------------------------------");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
scoreAway: true,
|
||||
mstUtc: true,
|
||||
},
|
||||
orderBy: { mstUtc: 'asc' },
|
||||
orderBy: { mstUtc: "asc" },
|
||||
});
|
||||
|
||||
console.log(
|
||||
@@ -228,7 +228,7 @@ async function computeEloRatings(): Promise<void> {
|
||||
);
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
.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();
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
type SwaggerPaths = Record<string, Record<string, JsonRecord>>;
|
||||
@@ -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<JsonRecord>((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'
|
||||
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<JsonRecord> {
|
||||
: [];
|
||||
|
||||
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<JsonRecord> {
|
||||
function buildHeaders(operation: JsonRecord): Array<JsonRecord> {
|
||||
const headers: Array<JsonRecord> = [
|
||||
{
|
||||
key: 'Content-Type',
|
||||
value: 'application/json',
|
||||
key: "Content-Type",
|
||||
value: "application/json",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -268,8 +270,8 @@ function buildHeaders(operation: JsonRecord): Array<JsonRecord> {
|
||||
: [];
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
};
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
writeFileSync(outputFile, JSON.stringify(collection, null, 2), 'utf8');
|
||||
writeFileSync(outputFile, JSON.stringify(collection, null, 2), "utf8");
|
||||
|
||||
await app.close();
|
||||
|
||||
|
||||
@@ -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<string, HttpMethod> = {
|
||||
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<string, TsMethodMeta> {
|
||||
const modulesDir = path.join(projectRoot, 'src', 'modules');
|
||||
const modulesDir = path.join(projectRoot, "src", "modules");
|
||||
const controllerFiles = collectControllerFiles(modulesDir);
|
||||
const metadataByOperationId = new Map<string, TsMethodMeta>();
|
||||
|
||||
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<string>(),
|
||||
): Set<string> {
|
||||
if (!value || typeof value !== 'object') {
|
||||
if (!value || typeof value !== "object") {
|
||||
return refs;
|
||||
}
|
||||
|
||||
const recordValue = value as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
if (typeof requestBodyObj.$ref === 'string') {
|
||||
if (typeof requestBodyObj.$ref === "string") {
|
||||
return {
|
||||
required: false,
|
||||
contentTypes: [],
|
||||
@@ -378,9 +378,9 @@ function normalizeResponses(responses: Record<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
|
||||
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<Record<string, unknown>> = [];
|
||||
const seenOperationIds = new Set<string>();
|
||||
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<string, unknown>;
|
||||
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<string, number>();
|
||||
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<string, unknown> | 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);
|
||||
|
||||
@@ -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<Map<string, OddsData>> {
|
||||
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<string, LeagueStats> {
|
||||
const leagueMap = new Map<string, LeagueStats>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
scoreAway: true,
|
||||
mstUtc: true,
|
||||
},
|
||||
orderBy: { mstUtc: 'asc' },
|
||||
orderBy: { mstUtc: "asc" },
|
||||
});
|
||||
|
||||
const matches: MatchRow[] = rawMatches.map((m) => ({
|
||||
@@ -559,31 +559,31 @@ async function populateFeatureStore(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
),
|
||||
// Meta
|
||||
missingPlayersImpact: 0,
|
||||
calculatorVer: 'v2.0',
|
||||
calculatorVer: "v2.0",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -749,7 +749,7 @@ async function populateFeatureStore(): Promise<void> {
|
||||
}
|
||||
|
||||
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<void> {
|
||||
// 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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user