cron #1
@@ -1,26 +0,0 @@
|
|||||||
name: Check Docker Pi
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [check-docker]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Get Docker Info
|
|
||||||
run: |
|
|
||||||
date > docker_info.txt
|
|
||||||
echo "==== DOCKER PS ====" >> docker_info.txt
|
|
||||||
docker ps -a >> docker_info.txt
|
|
||||||
echo "==== DOCKER STATS ====" >> docker_info.txt
|
|
||||||
docker stats --no-stream >> docker_info.txt
|
|
||||||
|
|
||||||
git config --global user.name "Gitea Actions"
|
|
||||||
git config --global user.email "actions@gitea.local"
|
|
||||||
git add docker_info.txt
|
|
||||||
git commit -m "chore: add docker info" || true
|
|
||||||
git push origin check-docker
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from "./app.controller";
|
||||||
import { AppService } from './app.service';
|
import { AppService } from "./app.service";
|
||||||
|
|
||||||
describe('AppController', () => {
|
describe("AppController", () => {
|
||||||
let appController: AppController;
|
let appController: AppController;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -14,9 +14,9 @@ describe('AppController', () => {
|
|||||||
appController = app.get<AppController>(AppController);
|
appController = app.get<AppController>(AppController);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('root', () => {
|
describe("root", () => {
|
||||||
it('should return "Hello World!"', () => {
|
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 { Controller, Get } from "@nestjs/common";
|
||||||
import { AppService } from './app.service';
|
import { AppService } from "./app.service";
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
|
|||||||
+54
-54
@@ -1,19 +1,19 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
|
||||||
import { CacheModule } from '@nestjs/cache-manager';
|
import { CacheModule } from "@nestjs/cache-manager";
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { redisStore } from 'cache-manager-redis-yet';
|
import { redisStore } from "cache-manager-redis-yet";
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from "nestjs-pino";
|
||||||
import {
|
import {
|
||||||
I18nModule,
|
I18nModule,
|
||||||
AcceptLanguageResolver,
|
AcceptLanguageResolver,
|
||||||
HeaderResolver,
|
HeaderResolver,
|
||||||
QueryResolver,
|
QueryResolver,
|
||||||
} from 'nestjs-i18n';
|
} from "nestjs-i18n";
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from "@nestjs/serve-static";
|
||||||
import * as path from 'path';
|
import * as path from "path";
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
import {
|
import {
|
||||||
@@ -24,52 +24,52 @@ import {
|
|||||||
i18nConfig,
|
i18nConfig,
|
||||||
featuresConfig,
|
featuresConfig,
|
||||||
throttleConfig,
|
throttleConfig,
|
||||||
} from './config/configuration';
|
} from "./config/configuration";
|
||||||
import { geminiConfig } from './modules/gemini/gemini.config';
|
import { geminiConfig } from "./modules/gemini/gemini.config";
|
||||||
import { validateEnv } from './config/env.validation';
|
import { validateEnv } from "./config/env.validation";
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from "./common/filters/global-exception.filter";
|
||||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
import { ResponseInterceptor } from "./common/interceptors/response.interceptor";
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from "./database/database.module";
|
||||||
|
|
||||||
// Core Modules
|
// Core Modules
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from "./modules/auth/auth.module";
|
||||||
import { UsersModule } from './modules/users/users.module';
|
import { UsersModule } from "./modules/users/users.module";
|
||||||
import { AdminModule } from './modules/admin/admin.module';
|
import { AdminModule } from "./modules/admin/admin.module";
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from "./modules/health/health.module";
|
||||||
import { GeminiModule } from './modules/gemini/gemini.module';
|
import { GeminiModule } from "./modules/gemini/gemini.module";
|
||||||
import { SocialPosterModule } from './modules/social-poster/social-poster.module';
|
import { SocialPosterModule } from "./modules/social-poster/social-poster.module";
|
||||||
|
|
||||||
// Sports Domain Modules
|
// Sports Domain Modules
|
||||||
import { MatchesModule } from './modules/matches/matches.module';
|
import { MatchesModule } from "./modules/matches/matches.module";
|
||||||
import { PredictionsModule } from './modules/predictions/predictions.module';
|
import { PredictionsModule } from "./modules/predictions/predictions.module";
|
||||||
import { LeaguesModule } from './modules/leagues/leagues.module';
|
import { LeaguesModule } from "./modules/leagues/leagues.module";
|
||||||
import { AnalysisModule } from './modules/analysis/analysis.module';
|
import { AnalysisModule } from "./modules/analysis/analysis.module";
|
||||||
import { CouponsModule } from './modules/coupons/coupons.module';
|
import { CouponsModule } from "./modules/coupons/coupons.module";
|
||||||
import { SporTotoModule } from './modules/spor-toto/spor-toto.module';
|
import { SporTotoModule } from "./modules/spor-toto/spor-toto.module";
|
||||||
|
|
||||||
// Services and Tasks
|
// Services and Tasks
|
||||||
import { ServicesModule } from './services/services.module';
|
import { ServicesModule } from "./services/services.module";
|
||||||
import { TasksModule } from './tasks/tasks.module';
|
import { TasksModule } from "./tasks/tasks.module";
|
||||||
|
|
||||||
// Feeder Module (Historical Data Scraping)
|
// Feeder Module (Historical Data Scraping)
|
||||||
import { FeederModule } from './modules/feeder/feeder.module';
|
import { FeederModule } from "./modules/feeder/feeder.module";
|
||||||
|
|
||||||
// Guards
|
// Guards
|
||||||
import {
|
import {
|
||||||
JwtAuthGuard,
|
JwtAuthGuard,
|
||||||
RolesGuard,
|
RolesGuard,
|
||||||
PermissionsGuard,
|
PermissionsGuard,
|
||||||
} from './modules/auth/guards';
|
} from "./modules/auth/guards";
|
||||||
|
|
||||||
// Queue
|
// Queue
|
||||||
import { QueueModule } from './common/queues/queue.module';
|
import { QueueModule } from "./common/queues/queue.module";
|
||||||
|
|
||||||
const redisEnabled = process.env.REDIS_ENABLED === 'true';
|
const redisEnabled = process.env.REDIS_ENABLED === "true";
|
||||||
const historicalFeederMode = process.env.FEEDER_MODE === 'historical';
|
const historicalFeederMode = process.env.FEEDER_MODE === "historical";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -94,8 +94,8 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical';
|
|||||||
|
|
||||||
// Static Assets (Images, Uploads)
|
// Static Assets (Images, Uploads)
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
rootPath: path.join(__dirname, '..', 'public'),
|
rootPath: path.join(__dirname, "..", "public"),
|
||||||
serveRoot: '/', // This means public/uploads/x.png -> /uploads/x.png
|
serveRoot: "/", // This means public/uploads/x.png -> /uploads/x.png
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Logger (Structured Logging with Pino)
|
// Logger (Structured Logging with Pino)
|
||||||
@@ -105,10 +105,10 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical';
|
|||||||
useFactory: (configService: ConfigService) => {
|
useFactory: (configService: ConfigService) => {
|
||||||
return {
|
return {
|
||||||
pinoHttp: {
|
pinoHttp: {
|
||||||
level: configService.get('app.isDevelopment') ? 'debug' : 'info',
|
level: configService.get("app.isDevelopment") ? "debug" : "info",
|
||||||
transport: configService.get('app.isDevelopment')
|
transport: configService.get("app.isDevelopment")
|
||||||
? {
|
? {
|
||||||
target: 'pino-pretty',
|
target: "pino-pretty",
|
||||||
options: {
|
options: {
|
||||||
singleLine: true,
|
singleLine: true,
|
||||||
},
|
},
|
||||||
@@ -122,15 +122,15 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical';
|
|||||||
// i18n
|
// i18n
|
||||||
I18nModule.forRootAsync({
|
I18nModule.forRootAsync({
|
||||||
useFactory: (configService: ConfigService) => ({
|
useFactory: (configService: ConfigService) => ({
|
||||||
fallbackLanguage: configService.get('i18n.fallbackLanguage', 'en'),
|
fallbackLanguage: configService.get("i18n.fallbackLanguage", "en"),
|
||||||
loaderOptions: {
|
loaderOptions: {
|
||||||
path: path.join(__dirname, '../i18n/'),
|
path: path.join(__dirname, "../i18n/"),
|
||||||
watch: configService.get('app.isDevelopment', true),
|
watch: configService.get("app.isDevelopment", true),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
resolvers: [
|
resolvers: [
|
||||||
new HeaderResolver(['x-lang']),
|
new HeaderResolver(["x-lang"]),
|
||||||
new QueryResolver(['lang']),
|
new QueryResolver(["lang"]),
|
||||||
AcceptLanguageResolver,
|
AcceptLanguageResolver,
|
||||||
],
|
],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
@@ -141,8 +141,8 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical';
|
|||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (configService: ConfigService) => [
|
useFactory: (configService: ConfigService) => [
|
||||||
{
|
{
|
||||||
ttl: configService.get('throttle.ttl', 60000),
|
ttl: configService.get("throttle.ttl", 60000),
|
||||||
limit: configService.get('throttle.limit', 100),
|
limit: configService.get("throttle.limit", 100),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -153,29 +153,29 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical';
|
|||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: async (configService: ConfigService) => {
|
useFactory: async (configService: ConfigService) => {
|
||||||
// FORCE DISABLE REDIS if user doesn't want it
|
// 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) {
|
if (useRedis) {
|
||||||
try {
|
try {
|
||||||
const store = await redisStore({
|
const store = await redisStore({
|
||||||
socket: {
|
socket: {
|
||||||
host: configService.get('redis.host', 'localhost'),
|
host: configService.get("redis.host", "localhost"),
|
||||||
port: configService.get('redis.port', 6379),
|
port: configService.get("redis.port", 6379),
|
||||||
},
|
},
|
||||||
ttl: 60 * 1000, // 1 minute default
|
ttl: 60 * 1000, // 1 minute default
|
||||||
});
|
});
|
||||||
console.log('✅ Redis cache connected');
|
console.log("✅ Redis cache connected");
|
||||||
return {
|
return {
|
||||||
store: store as unknown as any,
|
store: store as unknown as any,
|
||||||
ttl: 60 * 1000,
|
ttl: 60 * 1000,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('⚠️ Redis connection failed, using in-memory cache');
|
console.warn("⚠️ Redis connection failed, using in-memory cache");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to in-memory cache
|
// Fallback to in-memory cache
|
||||||
console.log('📦 Using in-memory cache');
|
console.log("📦 Using in-memory cache");
|
||||||
return {
|
return {
|
||||||
ttl: 60 * 1000,
|
ttl: 60 * 1000,
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppService {
|
export class AppService {
|
||||||
getHello(): string {
|
getHello(): string {
|
||||||
return 'Hello World!';
|
return "Hello World!";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,20 +8,20 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiNotFoundResponse,
|
ApiNotFoundResponse,
|
||||||
ApiBadRequestResponse,
|
ApiBadRequestResponse,
|
||||||
} from '@nestjs/swagger';
|
} from "@nestjs/swagger";
|
||||||
import { BaseService } from './base.service';
|
import { BaseService } from "./base.service";
|
||||||
import { PaginationDto } from '../dto/pagination.dto';
|
import { PaginationDto } from "../dto/pagination.dto";
|
||||||
import {
|
import {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
createSuccessResponse,
|
createSuccessResponse,
|
||||||
createPaginatedResponse,
|
createPaginatedResponse,
|
||||||
} from '../types/api-response.type';
|
} from "../types/api-response.type";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic base controller with common CRUD endpoints
|
* Generic base controller with common CRUD endpoints
|
||||||
@@ -37,8 +37,8 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Get all records with pagination' })
|
@ApiOperation({ summary: "Get all records with pagination" })
|
||||||
@ApiOkResponse({ description: 'Records retrieved successfully' })
|
@ApiOkResponse({ description: "Records retrieved successfully" })
|
||||||
async findAll(
|
async findAll(
|
||||||
@Query() pagination: PaginationDto,
|
@Query() pagination: PaginationDto,
|
||||||
): Promise<ApiResponse<{ items: T[]; meta: any }>> {
|
): Promise<ApiResponse<{ items: T[]; meta: any }>> {
|
||||||
@@ -52,13 +52,13 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(":id")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Get a record by ID' })
|
@ApiOperation({ summary: "Get a record by ID" })
|
||||||
@ApiOkResponse({ description: 'Record retrieved successfully' })
|
@ApiOkResponse({ description: "Record retrieved successfully" })
|
||||||
@ApiNotFoundResponse({ description: 'Record not found' })
|
@ApiNotFoundResponse({ description: "Record not found" })
|
||||||
async findOne(
|
async findOne(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
const result = await this.service.findOne(id);
|
const result = await this.service.findOne(id);
|
||||||
return createSuccessResponse(
|
return createSuccessResponse(
|
||||||
@@ -69,9 +69,9 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Create a new record' })
|
@ApiOperation({ summary: "Create a new record" })
|
||||||
@ApiOkResponse({ description: 'Record created successfully' })
|
@ApiOkResponse({ description: "Record created successfully" })
|
||||||
@ApiBadRequestResponse({ description: 'Validation failed' })
|
@ApiBadRequestResponse({ description: "Validation failed" })
|
||||||
async create(@Body() createDto: CreateDto): Promise<ApiResponse<T>> {
|
async create(@Body() createDto: CreateDto): Promise<ApiResponse<T>> {
|
||||||
const result = await this.service.create(createDto);
|
const result = await this.service.create(createDto);
|
||||||
return createSuccessResponse(
|
return createSuccessResponse(
|
||||||
@@ -81,13 +81,13 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(":id")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Update an existing record' })
|
@ApiOperation({ summary: "Update an existing record" })
|
||||||
@ApiOkResponse({ description: 'Record updated successfully' })
|
@ApiOkResponse({ description: "Record updated successfully" })
|
||||||
@ApiNotFoundResponse({ description: 'Record not found' })
|
@ApiNotFoundResponse({ description: "Record not found" })
|
||||||
async update(
|
async update(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() updateDto: UpdateDto,
|
@Body() updateDto: UpdateDto,
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
const result = await this.service.update(id, updateDto);
|
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)
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Delete a record (soft delete)' })
|
@ApiOperation({ summary: "Delete a record (soft delete)" })
|
||||||
@ApiOkResponse({ description: 'Record deleted successfully' })
|
@ApiOkResponse({ description: "Record deleted successfully" })
|
||||||
@ApiNotFoundResponse({ description: 'Record not found' })
|
@ApiNotFoundResponse({ description: "Record not found" })
|
||||||
async delete(
|
async delete(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
const result = await this.service.delete(id);
|
const result = await this.service.delete(id);
|
||||||
return createSuccessResponse(
|
return createSuccessResponse(
|
||||||
@@ -112,12 +112,12 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/restore')
|
@Post(":id/restore")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Restore a soft-deleted record' })
|
@ApiOperation({ summary: "Restore a soft-deleted record" })
|
||||||
@ApiOkResponse({ description: 'Record restored successfully' })
|
@ApiOkResponse({ description: "Record restored successfully" })
|
||||||
async restore(
|
async restore(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
const result = await this.service.restore(id);
|
const result = await this.service.restore(id);
|
||||||
return createSuccessResponse(
|
return createSuccessResponse(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NotFoundException, Logger } from '@nestjs/common';
|
import { NotFoundException, Logger } from "@nestjs/common";
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import { PaginationDto } from '../dto/pagination.dto';
|
import { PaginationDto } from "../dto/pagination.dto";
|
||||||
import { PaginationMeta } from '../types/api-response.type';
|
import { PaginationMeta } from "../types/api-response.type";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic base service with common CRUD operations
|
* Generic base service with common CRUD operations
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './base.service';
|
export * from "./base.service";
|
||||||
export * from './base.controller';
|
export * from "./base.controller";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
createParamDecorator,
|
createParamDecorator,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
SetMetadata,
|
SetMetadata,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current authenticated user from request
|
* Get the current authenticated user from request
|
||||||
@@ -23,19 +23,19 @@ export const CurrentUser = createParamDecorator(
|
|||||||
/**
|
/**
|
||||||
* Mark a route as public (no authentication required)
|
* 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);
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Require specific roles to access a route
|
* 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);
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Require specific permissions to access a route
|
* Require specific permissions to access a route
|
||||||
*/
|
*/
|
||||||
export const PERMISSIONS_KEY = 'permissions';
|
export const PERMISSIONS_KEY = "permissions";
|
||||||
export const RequirePermissions = (...permissions: string[]) =>
|
export const RequirePermissions = (...permissions: string[]) =>
|
||||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||||
|
|
||||||
@@ -55,6 +55,6 @@ export const CurrentTenant = createParamDecorator(
|
|||||||
export const CurrentLang = createParamDecorator(
|
export const CurrentLang = createParamDecorator(
|
||||||
(data: unknown, ctx: ExecutionContext) => {
|
(data: unknown, ctx: ExecutionContext) => {
|
||||||
const request = ctx.switchToHttp().getRequest();
|
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 { IsOptional, IsInt, Min, Max, IsString, IsIn } from "class-validator";
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from "class-transformer";
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
|
||||||
export class PaginationDto {
|
export class PaginationDto {
|
||||||
@ApiPropertyOptional({ default: 1, minimum: 1, description: 'Page number' })
|
@ApiPropertyOptional({ default: 1, minimum: 1, description: "Page number" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => parseInt(value, 10))
|
@Transform(({ value }) => parseInt(value, 10))
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -14,7 +14,7 @@ export class PaginationDto {
|
|||||||
default: 10,
|
default: 10,
|
||||||
minimum: 1,
|
minimum: 1,
|
||||||
maximum: 100,
|
maximum: 100,
|
||||||
description: 'Items per page',
|
description: "Items per page",
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => parseInt(value, 10))
|
@Transform(({ value }) => parseInt(value, 10))
|
||||||
@@ -23,21 +23,21 @@ export class PaginationDto {
|
|||||||
@Max(100)
|
@Max(100)
|
||||||
limit?: number = 10;
|
limit?: number = 10;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Field to sort by' })
|
@ApiPropertyOptional({ description: "Field to sort by" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
sortBy?: string = 'createdAt';
|
sortBy?: string = "createdAt";
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: ['asc', 'desc'],
|
enum: ["asc", "desc"],
|
||||||
default: 'desc',
|
default: "desc",
|
||||||
description: 'Sort order',
|
description: "Sort order",
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['asc', 'desc'])
|
@IsIn(["asc", "desc"])
|
||||||
sortOrder?: 'asc' | 'desc' = 'desc';
|
sortOrder?: "asc" | "desc" = "desc";
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Search query' })
|
@ApiPropertyOptional({ description: "Search query" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -59,7 +59,7 @@ export class PaginationDto {
|
|||||||
/**
|
/**
|
||||||
* Get orderBy object for Prisma
|
* Get orderBy object for Prisma
|
||||||
*/
|
*/
|
||||||
get orderBy(): Record<string, 'asc' | 'desc'> {
|
get orderBy(): Record<string, "asc" | "desc"> {
|
||||||
return { [this.sortBy || 'createdAt']: this.sortOrder || 'desc' };
|
return { [this.sortBy || "createdAt"]: this.sortOrder || "desc" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from "express";
|
||||||
import { I18nService, I18nContext } from 'nestjs-i18n';
|
import { I18nService, I18nContext } from "nestjs-i18n";
|
||||||
import { ApiResponse, createErrorResponse } from '../types/api-response.type';
|
import { ApiResponse, createErrorResponse } from "../types/api-response.type";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global exception filter that catches all exceptions
|
* Global exception filter that catches all exceptions
|
||||||
@@ -27,23 +27,23 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
|||||||
|
|
||||||
// Determine status and message
|
// Determine status and message
|
||||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
let message = 'Internal server error';
|
let message = "Internal server error";
|
||||||
let errors: string[] = [];
|
let errors: string[] = [];
|
||||||
|
|
||||||
if (exception instanceof HttpException) {
|
if (exception instanceof HttpException) {
|
||||||
status = exception.getStatus();
|
status = exception.getStatus();
|
||||||
const exceptionResponse = exception.getResponse();
|
const exceptionResponse = exception.getResponse();
|
||||||
|
|
||||||
if (typeof exceptionResponse === 'string') {
|
if (typeof exceptionResponse === "string") {
|
||||||
message = exceptionResponse;
|
message = exceptionResponse;
|
||||||
} else if (typeof exceptionResponse === 'object') {
|
} else if (typeof exceptionResponse === "object") {
|
||||||
const responseObj = exceptionResponse as Record<string, unknown>;
|
const responseObj = exceptionResponse as Record<string, unknown>;
|
||||||
message = (responseObj.message as string) || exception.message;
|
message = (responseObj.message as string) || exception.message;
|
||||||
|
|
||||||
// Handle validation errors (class-validator)
|
// Handle validation errors (class-validator)
|
||||||
if (Array.isArray(responseObj.message)) {
|
if (Array.isArray(responseObj.message)) {
|
||||||
errors = responseObj.message as string[];
|
errors = responseObj.message as string[];
|
||||||
message = 'VALIDATION_FAILED';
|
message = "VALIDATION_FAILED";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (exception instanceof Error) {
|
} else if (exception instanceof Error) {
|
||||||
@@ -57,22 +57,22 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
|||||||
let lang = i18nContext?.lang;
|
let lang = i18nContext?.lang;
|
||||||
|
|
||||||
if (!lang) {
|
if (!lang) {
|
||||||
const acceptLanguage = request.headers['accept-language'];
|
const acceptLanguage = request.headers["accept-language"];
|
||||||
const xLang = request.headers['x-lang'];
|
const xLang = request.headers["x-lang"];
|
||||||
|
|
||||||
if (xLang) {
|
if (xLang) {
|
||||||
lang = Array.isArray(xLang) ? xLang[0] : xLang;
|
lang = Array.isArray(xLang) ? xLang[0] : xLang;
|
||||||
} else if (acceptLanguage) {
|
} else if (acceptLanguage) {
|
||||||
// Take first preferred language: "tr-TR,en;q=0.9" -> "tr"
|
// 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
|
// Translate validation error specially
|
||||||
if (message === 'VALIDATION_FAILED') {
|
if (message === "VALIDATION_FAILED") {
|
||||||
message = this.i18n.translate('errors.VALIDATION_FAILED', { lang });
|
message = this.i18n.translate("errors.VALIDATION_FAILED", { lang });
|
||||||
} else {
|
} else {
|
||||||
// Try dynamic translation
|
// Try dynamic translation
|
||||||
const translatedMessage = this.i18n.translate(`errors.${message}`, {
|
const translatedMessage = this.i18n.translate(`errors.${message}`, {
|
||||||
@@ -95,7 +95,7 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Build response
|
// Build response
|
||||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
const isDevelopment = process.env.NODE_ENV === "development";
|
||||||
const errorResponse: ApiResponse<null> = createErrorResponse(
|
const errorResponse: ApiResponse<null> = createErrorResponse(
|
||||||
message,
|
message,
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import {
|
|||||||
NestInterceptor,
|
NestInterceptor,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
CallHandler,
|
CallHandler,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from "rxjs";
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from "rxjs/operators";
|
||||||
import { ApiResponse, createSuccessResponse } from '../types/api-response.type';
|
import { ApiResponse, createSuccessResponse } from "../types/api-response.type";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response interceptor that wraps all successful responses
|
* Response interceptor that wraps all successful responses
|
||||||
* in the standard ApiResponse format
|
* in the standard ApiResponse format
|
||||||
*/
|
*/
|
||||||
import { I18nService, I18nContext } from 'nestjs-i18n';
|
import { I18nService, I18nContext } from "nestjs-i18n";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ResponseInterceptor<T> implements NestInterceptor<
|
export class ResponseInterceptor<T> implements NestInterceptor<
|
||||||
@@ -34,17 +34,17 @@ export class ResponseInterceptor<T> implements NestInterceptor<
|
|||||||
let lang = i18nContext?.lang;
|
let lang = i18nContext?.lang;
|
||||||
|
|
||||||
if (!lang) {
|
if (!lang) {
|
||||||
const acceptLanguage = request.headers['accept-language'];
|
const acceptLanguage = request.headers["accept-language"];
|
||||||
const xLang = request.headers['x-lang'];
|
const xLang = request.headers["x-lang"];
|
||||||
|
|
||||||
if (xLang) {
|
if (xLang) {
|
||||||
lang = Array.isArray(xLang) ? xLang[0] : xLang;
|
lang = Array.isArray(xLang) ? xLang[0] : xLang;
|
||||||
} else if (acceptLanguage) {
|
} 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
|
// 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
|
// 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,
|
lang,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ export class ResponseInterceptor<T> implements NestInterceptor<
|
|||||||
}
|
}
|
||||||
|
|
||||||
private translateReasons(data: any, lang: string) {
|
private translateReasons(data: any, lang: string) {
|
||||||
if (!data || typeof data !== 'object') {
|
if (!data || typeof data !== "object") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,44 +91,44 @@ export class ResponseInterceptor<T> implements NestInterceptor<
|
|||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
const val = data[key];
|
const val = data[key];
|
||||||
if (
|
if (
|
||||||
(key === 'reasons' ||
|
(key === "reasons" ||
|
||||||
key === 'decision_reasons' ||
|
key === "decision_reasons" ||
|
||||||
key === 'reasoning_factors') &&
|
key === "reasoning_factors") &&
|
||||||
Array.isArray(val)
|
Array.isArray(val)
|
||||||
) {
|
) {
|
||||||
data[key] = val.map((r: any) => {
|
data[key] = val.map((r: any) => {
|
||||||
if (typeof r !== 'string') return r;
|
if (typeof r !== "string") return r;
|
||||||
const translationKey = `predictions.reasons.${r}`;
|
const translationKey = `predictions.reasons.${r}`;
|
||||||
const translated = this.i18n.translate(translationKey, {
|
const translated = this.i18n.translate(translationKey, {
|
||||||
lang,
|
lang,
|
||||||
});
|
});
|
||||||
return translated === translationKey ? r : translated;
|
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 translationKey = `predictions.reasons.${val}`;
|
||||||
const translated = this.i18n.translate(translationKey, {
|
const translated = this.i18n.translate(translationKey, {
|
||||||
lang,
|
lang,
|
||||||
});
|
});
|
||||||
data[key] = translated === translationKey ? val : translated;
|
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) => {
|
data[key] = val.map((r: any) => {
|
||||||
if (typeof r !== 'string') return r;
|
if (typeof r !== "string") return r;
|
||||||
const translationKey = `predictions.flags.${r}`;
|
const translationKey = `predictions.flags.${r}`;
|
||||||
const translated = this.i18n.translate(translationKey, {
|
const translated = this.i18n.translate(translationKey, {
|
||||||
lang,
|
lang,
|
||||||
});
|
});
|
||||||
return translated === translationKey ? r : translated;
|
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) => {
|
data[key] = val.map((r: any) => {
|
||||||
if (typeof r !== 'string') return r;
|
if (typeof r !== "string") return r;
|
||||||
const translationKey = `predictions.warnings.${r}`;
|
const translationKey = `predictions.warnings.${r}`;
|
||||||
const translated = this.i18n.translate(translationKey, {
|
const translated = this.i18n.translate(translationKey, {
|
||||||
lang,
|
lang,
|
||||||
});
|
});
|
||||||
return translated === translationKey ? r : translated;
|
return translated === translationKey ? r : translated;
|
||||||
});
|
});
|
||||||
} else if (typeof val === 'object' && val !== null) {
|
} else if (typeof val === "object" && val !== null) {
|
||||||
this.translateReasons(val, lang);
|
this.translateReasons(val, lang);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -137,11 +137,11 @@ export class ResponseInterceptor<T> implements NestInterceptor<
|
|||||||
private isApiResponse(data: unknown): boolean {
|
private isApiResponse(data: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
data !== null &&
|
data !== null &&
|
||||||
typeof data === 'object' &&
|
typeof data === "object" &&
|
||||||
'success' in data &&
|
"success" in data &&
|
||||||
'status' in data &&
|
"status" in data &&
|
||||||
'message' in data &&
|
"message" in data &&
|
||||||
'data' in data
|
"data" in data
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
NestInterceptor,
|
NestInterceptor,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
CallHandler,
|
CallHandler,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips HTML/script tags from all string values in the request body.
|
* 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> {
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
const request = context.switchToHttp().getRequest();
|
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);
|
request.body = this.sanitize(request.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export class SanitizeInterceptor implements NestInterceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sanitize(value: unknown): unknown {
|
private sanitize(value: unknown): unknown {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === "string") {
|
||||||
return this.stripTags(value);
|
return this.stripTags(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export class SanitizeInterceptor implements NestInterceptor {
|
|||||||
return value.map((item) => this.sanitize(item));
|
return value.map((item) => this.sanitize(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value !== null && typeof value === 'object') {
|
if (value !== null && typeof value === "object") {
|
||||||
const sanitized: Record<string, unknown> = {};
|
const sanitized: Record<string, unknown> = {};
|
||||||
for (const [key, val] of Object.entries(value)) {
|
for (const [key, val] of Object.entries(value)) {
|
||||||
sanitized[key] = this.sanitize(val);
|
sanitized[key] = this.sanitize(val);
|
||||||
@@ -43,6 +43,6 @@ export class SanitizeInterceptor implements NestInterceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private stripTags(input: string): string {
|
private stripTags(input: string): string {
|
||||||
return input.replace(/<[^>]*>/g, '');
|
return input.replace(/<[^>]*>/g, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Module, Global } from '@nestjs/common';
|
import { Module, Global } from "@nestjs/common";
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from "@nestjs/bullmq";
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -9,14 +9,14 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
|||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: (configService: ConfigService) => ({
|
useFactory: (configService: ConfigService) => ({
|
||||||
connection: {
|
connection: {
|
||||||
host: configService.get('redis.host', 'localhost'),
|
host: configService.get("redis.host", "localhost"),
|
||||||
port: configService.get('redis.port', 6379),
|
port: configService.get("redis.port", 6379),
|
||||||
password: configService.get('redis.password'),
|
password: configService.get("redis.password"),
|
||||||
},
|
},
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
backoff: {
|
backoff: {
|
||||||
type: 'exponential',
|
type: "exponential",
|
||||||
delay: 1000,
|
delay: 1000,
|
||||||
},
|
},
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export interface PaginationMeta {
|
|||||||
*/
|
*/
|
||||||
export function createSuccessResponse<T>(
|
export function createSuccessResponse<T>(
|
||||||
data: T,
|
data: T,
|
||||||
message = 'Success',
|
message = "Success",
|
||||||
status = 200,
|
status = 200,
|
||||||
): ApiResponse<T> {
|
): ApiResponse<T> {
|
||||||
return {
|
return {
|
||||||
@@ -72,7 +72,7 @@ export function createPaginatedResponse<T>(
|
|||||||
total: number,
|
total: number,
|
||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
message = 'Success',
|
message = "Success",
|
||||||
): ApiResponse<PaginatedData<T>> {
|
): ApiResponse<PaginatedData<T>> {
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { existsSync, createWriteStream, mkdirSync } from 'fs';
|
import { existsSync, createWriteStream, mkdirSync } from "fs";
|
||||||
import { dirname } from 'path';
|
import { dirname } from "path";
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from "@nestjs/common";
|
||||||
|
|
||||||
export class ImageUtils {
|
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.
|
* Downloads an image from a URL and saves it to a local path.
|
||||||
@@ -26,8 +26,8 @@ export class ImageUtils {
|
|||||||
// Download
|
// Download
|
||||||
const response = await axios({
|
const response = await axios({
|
||||||
url,
|
url,
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
responseType: 'stream',
|
responseType: "stream",
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
validateStatus: (status) => status === 200, // Only save if 200 OK
|
validateStatus: (status) => status === 200, // Only save if 200 OK
|
||||||
});
|
});
|
||||||
@@ -37,8 +37,8 @@ export class ImageUtils {
|
|||||||
response.data.pipe(writer);
|
response.data.pipe(writer);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
writer.on('finish', () => resolve(true));
|
writer.on("finish", () => resolve(true));
|
||||||
writer.on('error', (err) => {
|
writer.on("error", (err) => {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Failed to write image to ${localPath}: ${err.message}`,
|
`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', () => ({
|
export const appConfig = registerAs("app", () => ({
|
||||||
env: process.env.NODE_ENV || 'development',
|
env: process.env.NODE_ENV || "development",
|
||||||
port: parseInt(process.env.PORT || '3005', 10),
|
port: parseInt(process.env.PORT || "3005", 10),
|
||||||
isDevelopment: process.env.NODE_ENV === 'development',
|
isDevelopment: process.env.NODE_ENV === "development",
|
||||||
isProduction: process.env.NODE_ENV === 'production',
|
isProduction: process.env.NODE_ENV === "production",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const databaseConfig = registerAs('database', () => ({
|
export const databaseConfig = registerAs("database", () => ({
|
||||||
url: process.env.DATABASE_URL,
|
url: process.env.DATABASE_URL,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const jwtConfig = registerAs('jwt', () => ({
|
export const jwtConfig = registerAs("jwt", () => ({
|
||||||
secret: process.env.JWT_SECRET,
|
secret: process.env.JWT_SECRET,
|
||||||
accessExpiration: process.env.JWT_ACCESS_EXPIRATION || '15m',
|
accessExpiration: process.env.JWT_ACCESS_EXPIRATION || "15m",
|
||||||
refreshExpiration: process.env.JWT_REFRESH_EXPIRATION || '7d',
|
refreshExpiration: process.env.JWT_REFRESH_EXPIRATION || "7d",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const redisConfig = registerAs('redis', () => ({
|
export const redisConfig = registerAs("redis", () => ({
|
||||||
enabled: process.env.REDIS_ENABLED === 'true',
|
enabled: process.env.REDIS_ENABLED === "true",
|
||||||
host: process.env.REDIS_HOST || 'localhost',
|
host: process.env.REDIS_HOST || "localhost",
|
||||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
port: parseInt(process.env.REDIS_PORT || "6379", 10),
|
||||||
password: process.env.REDIS_PASSWORD || undefined,
|
password: process.env.REDIS_PASSWORD || undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const i18nConfig = registerAs('i18n', () => ({
|
export const i18nConfig = registerAs("i18n", () => ({
|
||||||
defaultLanguage: process.env.DEFAULT_LANGUAGE || 'en',
|
defaultLanguage: process.env.DEFAULT_LANGUAGE || "en",
|
||||||
fallbackLanguage: process.env.FALLBACK_LANGUAGE || 'en',
|
fallbackLanguage: process.env.FALLBACK_LANGUAGE || "en",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const featuresConfig = registerAs('features', () => ({
|
export const featuresConfig = registerAs("features", () => ({
|
||||||
mail: process.env.ENABLE_MAIL === 'true',
|
mail: process.env.ENABLE_MAIL === "true",
|
||||||
s3: process.env.ENABLE_S3 === 'true',
|
s3: process.env.ENABLE_S3 === "true",
|
||||||
websocket: process.env.ENABLE_WEBSOCKET === 'true',
|
websocket: process.env.ENABLE_WEBSOCKET === "true",
|
||||||
multiTenancy: process.env.ENABLE_MULTI_TENANCY === 'true',
|
multiTenancy: process.env.ENABLE_MULTI_TENANCY === "true",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const mailConfig = registerAs('mail', () => ({
|
export const mailConfig = registerAs("mail", () => ({
|
||||||
host: process.env.MAIL_HOST,
|
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,
|
user: process.env.MAIL_USER,
|
||||||
password: process.env.MAIL_PASSWORD,
|
password: process.env.MAIL_PASSWORD,
|
||||||
from: process.env.MAIL_FROM,
|
from: process.env.MAIL_FROM,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const s3Config = registerAs('s3', () => ({
|
export const s3Config = registerAs("s3", () => ({
|
||||||
endpoint: process.env.S3_ENDPOINT,
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
accessKey: process.env.S3_ACCESS_KEY,
|
accessKey: process.env.S3_ACCESS_KEY,
|
||||||
secretKey: process.env.S3_SECRET_KEY,
|
secretKey: process.env.S3_SECRET_KEY,
|
||||||
bucket: process.env.S3_BUCKET,
|
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', () => ({
|
export const throttleConfig = registerAs("throttle", () => ({
|
||||||
ttl: parseInt(process.env.THROTTLE_TTL || '60000', 10),
|
ttl: parseInt(process.env.THROTTLE_TTL || "60000", 10),
|
||||||
limit: parseInt(process.env.THROTTLE_LIMIT || '100', 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
|
* Helper to parse boolean from string
|
||||||
@@ -6,8 +6,8 @@ import { z } from 'zod';
|
|||||||
const booleanString = z
|
const booleanString = z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.default('false')
|
.default("false")
|
||||||
.transform((val) => val === 'true');
|
.transform((val) => val === "true");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Environment variables schema validation using Zod
|
* Environment variables schema validation using Zod
|
||||||
@@ -15,46 +15,46 @@ const booleanString = z
|
|||||||
export const envSchema = z.object({
|
export const envSchema = z.object({
|
||||||
// Environment
|
// Environment
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(['development', 'production', 'test'])
|
.enum(["development", "production", "test"])
|
||||||
.default('development'),
|
.default("development"),
|
||||||
PORT: z.coerce.number().default(3005),
|
PORT: z.coerce.number().default(3005),
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
// AI Engine
|
// AI Engine
|
||||||
AI_ENGINE_URL: z.string().url().default('http://localhost:8000'),
|
AI_ENGINE_URL: z.string().url().default("http://localhost:8000"),
|
||||||
|
|
||||||
// JWT
|
// JWT
|
||||||
JWT_SECRET: z.string().min(32),
|
JWT_SECRET: z.string().min(32),
|
||||||
JWT_ACCESS_EXPIRATION: z.string().default('15m'),
|
JWT_ACCESS_EXPIRATION: z.string().default("15m"),
|
||||||
JWT_REFRESH_EXPIRATION: z.string().default('7d'),
|
JWT_REFRESH_EXPIRATION: z.string().default("7d"),
|
||||||
|
|
||||||
// Redis
|
// Redis
|
||||||
REDIS_ENABLED: z
|
REDIS_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((val) => val === 'true')
|
.transform((val) => val === "true")
|
||||||
.default('false' as any),
|
.default("false" as any),
|
||||||
REDIS_HOST: z.string().default('localhost'),
|
REDIS_HOST: z.string().default("localhost"),
|
||||||
REDIS_PORT: z.coerce.number().default(6379),
|
REDIS_PORT: z.coerce.number().default(6379),
|
||||||
REDIS_PASSWORD: z.string().optional(),
|
REDIS_PASSWORD: z.string().optional(),
|
||||||
|
|
||||||
// i18n
|
// i18n
|
||||||
DEFAULT_LANGUAGE: z.string().default('en'),
|
DEFAULT_LANGUAGE: z.string().default("en"),
|
||||||
FALLBACK_LANGUAGE: z.string().default('en'),
|
FALLBACK_LANGUAGE: z.string().default("en"),
|
||||||
|
|
||||||
// Gemini AI
|
// Gemini AI
|
||||||
ENABLE_GEMINI: z
|
ENABLE_GEMINI: z
|
||||||
.string()
|
.string()
|
||||||
.transform((val) => val === 'true')
|
.transform((val) => val === "true")
|
||||||
.default('false' as any),
|
.default("false" as any),
|
||||||
GOOGLE_API_KEY: z.string().optional(),
|
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
|
||||||
SOCIAL_POSTER_ENABLED: z
|
SOCIAL_POSTER_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((val) => val === 'true')
|
.transform((val) => val === "true")
|
||||||
.default('false' as any),
|
.default("false" as any),
|
||||||
TWITTER_API_KEY: z.string().optional(),
|
TWITTER_API_KEY: z.string().optional(),
|
||||||
TWITTER_API_SECRET: z.string().optional(),
|
TWITTER_API_SECRET: z.string().optional(),
|
||||||
TWITTER_ACCESS_TOKEN: z.string().optional(),
|
TWITTER_ACCESS_TOKEN: z.string().optional(),
|
||||||
@@ -98,9 +98,9 @@ export function validateEnv(config: Record<string, unknown>): EnvConfig {
|
|||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const errors = result.error.issues.map(
|
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;
|
return result.data;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from "@nestjs/common";
|
||||||
import { PrismaService } from './prisma.service';
|
import { PrismaService } from "./prisma.service";
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import {
|
|||||||
OnModuleInit,
|
OnModuleInit,
|
||||||
OnModuleDestroy,
|
OnModuleDestroy,
|
||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
// Models that support soft delete
|
// 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
|
// Type for Prisma model delegate with common operations
|
||||||
interface PrismaDelegate {
|
interface PrismaDelegate {
|
||||||
@@ -29,20 +29,20 @@ export class PrismaService
|
|||||||
constructor() {
|
constructor() {
|
||||||
super({
|
super({
|
||||||
log: [
|
log: [
|
||||||
{ emit: 'event', level: 'query' },
|
{ emit: "event", level: "query" },
|
||||||
{ emit: 'event', level: 'error' },
|
{ emit: "event", level: "error" },
|
||||||
{ emit: 'event', level: 'warn' },
|
{ emit: "event", level: "warn" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
this.logger.log(
|
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
|
); // Mask password
|
||||||
try {
|
try {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
this.logger.log('✅ Database connected successfully');
|
this.logger.log("✅ Database connected successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`❌ Database connection failed: ${error.message}`,
|
`❌ Database connection failed: ${error.message}`,
|
||||||
@@ -54,7 +54,7 @@ export class PrismaService
|
|||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
await this.$disconnect();
|
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 { NestFactory } from "@nestjs/core";
|
||||||
import { ValidationPipe, Logger as NestLogger } from '@nestjs/common';
|
import { ValidationPipe, Logger as NestLogger } from "@nestjs/common";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from "./app.module";
|
||||||
import helmet from 'helmet';
|
import helmet from "helmet";
|
||||||
import * as express from 'express';
|
import * as express from "express";
|
||||||
import { Logger, LoggerErrorInterceptor } from 'nestjs-pino';
|
import { Logger, LoggerErrorInterceptor } from "nestjs-pino";
|
||||||
import { SanitizeInterceptor } from './common/interceptors/sanitize.interceptor';
|
import { SanitizeInterceptor } from "./common/interceptors/sanitize.interceptor";
|
||||||
|
|
||||||
// BigInt serialization polyfill — Prisma returns BigInt for mstUtc etc.
|
// BigInt serialization polyfill — Prisma returns BigInt for mstUtc etc.
|
||||||
(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function () {
|
(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function () {
|
||||||
@@ -14,9 +14,9 @@ import { SanitizeInterceptor } from './common/interceptors/sanitize.interceptor'
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function bootstrap() {
|
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 });
|
const app = await NestFactory.create(AppModule, { bufferLogs: false });
|
||||||
|
|
||||||
@@ -31,33 +31,33 @@ async function bootstrap() {
|
|||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
|
|
||||||
// Request payload size limit
|
// Request payload size limit
|
||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: "1mb" }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
app.use(express.urlencoded({ extended: true, limit: "1mb" }));
|
||||||
|
|
||||||
// Graceful Shutdown (Prisma & Docker)
|
// Graceful Shutdown (Prisma & Docker)
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
// Get config service
|
// Get config service
|
||||||
const configService = app.get(ConfigService);
|
const configService = app.get(ConfigService);
|
||||||
const port = configService.get<number>('PORT', 3005);
|
const port = configService.get<number>("PORT", 3005);
|
||||||
const nodeEnv = configService.get('NODE_ENV', 'development');
|
const nodeEnv = configService.get("NODE_ENV", "development");
|
||||||
|
|
||||||
// Enable CORS
|
// Enable CORS
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin:
|
origin:
|
||||||
nodeEnv === 'production'
|
nodeEnv === "production"
|
||||||
? [
|
? [
|
||||||
'https://ui-suggestbet.bilgich.com',
|
"https://ui-suggestbet.bilgich.com",
|
||||||
'https://suggestbet.bilgich.com',
|
"https://suggestbet.bilgich.com",
|
||||||
'https://iddaai.com',
|
"https://iddaai.com",
|
||||||
'https://www.iddaai.com',
|
"https://www.iddaai.com",
|
||||||
]
|
]
|
||||||
: true,
|
: true,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global prefix
|
// Global prefix
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix("api");
|
||||||
|
|
||||||
// Validation pipe (Strict)
|
// Validation pipe (Strict)
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
@@ -72,47 +72,47 @@ async function bootstrap() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Swagger setup — hidden in production
|
// Swagger setup — hidden in production
|
||||||
if (nodeEnv !== 'production') {
|
if (nodeEnv !== "production") {
|
||||||
const swaggerConfig = new DocumentBuilder()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
.setTitle('Suggest-Bet API')
|
.setTitle("Suggest-Bet API")
|
||||||
.setDescription(
|
.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()
|
.addBearerAuth()
|
||||||
.addTag('Auth', 'Authentication endpoints')
|
.addTag("Auth", "Authentication endpoints")
|
||||||
.addTag('Users', 'User management endpoints')
|
.addTag("Users", "User management endpoints")
|
||||||
.addTag('Admin', 'Admin management endpoints')
|
.addTag("Admin", "Admin management endpoints")
|
||||||
.addTag('Health', 'Health check endpoints')
|
.addTag("Health", "Health check endpoints")
|
||||||
.addTag('Matches', 'Match listing and detail endpoints')
|
.addTag("Matches", "Match listing and detail endpoints")
|
||||||
.addTag('Leagues', 'League, country, and team discovery endpoints')
|
.addTag("Leagues", "League, country, and team discovery endpoints")
|
||||||
.addTag('Analysis', 'AI analysis and analysis history endpoints')
|
.addTag("Analysis", "AI analysis and analysis history endpoints")
|
||||||
.addTag('Coupon', 'Coupon generation and coupon management endpoints')
|
.addTag("Coupon", "Coupon generation and coupon management endpoints")
|
||||||
.addTag('Predictions', 'Prediction and smart-coupon endpoints')
|
.addTag("Predictions", "Prediction and smart-coupon endpoints")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
logger.log('Initializing Swagger...');
|
logger.log("Initializing Swagger...");
|
||||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
SwaggerModule.setup('api/docs', app, document, {
|
SwaggerModule.setup("api/docs", app, document, {
|
||||||
swaggerOptions: {
|
swaggerOptions: {
|
||||||
persistAuthorization: true,
|
persistAuthorization: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logger.log('Swagger initialized');
|
logger.log("Swagger initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log(`Attempting to listen on port ${port}...`);
|
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(`🚀 Server is running on: http://localhost:${port}/api`);
|
||||||
logger.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`);
|
logger.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`);
|
||||||
logger.log(`💚 Health check: http://localhost:${port}/api/health`);
|
logger.log(`💚 Health check: http://localhost:${port}/api/health`);
|
||||||
logger.log(`🌍 Environment: ${nodeEnv.toUpperCase()}`);
|
logger.log(`🌍 Environment: ${nodeEnv.toUpperCase()}`);
|
||||||
logger.log('═══════════════════════════════════════════════════════════');
|
logger.log("═══════════════════════════════════════════════════════════");
|
||||||
|
|
||||||
if (nodeEnv === 'development') {
|
if (nodeEnv === "development") {
|
||||||
logger.warn('⚠️ Running in development mode');
|
logger.warn("⚠️ Running in development mode");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,32 +10,32 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
Inject,
|
Inject,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
CacheInterceptor,
|
CacheInterceptor,
|
||||||
CacheKey,
|
CacheKey,
|
||||||
CacheTTL,
|
CacheTTL,
|
||||||
CACHE_MANAGER,
|
CACHE_MANAGER,
|
||||||
} from '@nestjs/cache-manager';
|
} from "@nestjs/cache-manager";
|
||||||
import * as cacheManager from 'cache-manager';
|
import * as cacheManager from "cache-manager";
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation } from "@nestjs/swagger";
|
||||||
import { Roles } from '../../common/decorators';
|
import { Roles } from "../../common/decorators";
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
import { PaginationDto } from "../../common/dto/pagination.dto";
|
||||||
import {
|
import {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
createSuccessResponse,
|
createSuccessResponse,
|
||||||
createPaginatedResponse,
|
createPaginatedResponse,
|
||||||
PaginatedData,
|
PaginatedData,
|
||||||
} from '../../common/types/api-response.type';
|
} from "../../common/types/api-response.type";
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from "class-transformer";
|
||||||
import { UserResponseDto } from '../users/dto/user.dto';
|
import { UserResponseDto } from "../users/dto/user.dto";
|
||||||
import { UserRole } from '@prisma/client';
|
import { UserRole } from "@prisma/client";
|
||||||
|
|
||||||
@ApiTags('Admin')
|
@ApiTags("Admin")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('admin')
|
@Controller("admin")
|
||||||
@Roles('superadmin')
|
@Roles("superadmin")
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@@ -44,8 +44,8 @@ export class AdminController {
|
|||||||
|
|
||||||
// ================== Users Management ==================
|
// ================== Users Management ==================
|
||||||
|
|
||||||
@Get('users')
|
@Get("users")
|
||||||
@ApiOperation({ summary: 'Get all users (admin)' })
|
@ApiOperation({ summary: "Get all users (admin)" })
|
||||||
async getAllUsers(
|
async getAllUsers(
|
||||||
@Query() pagination: PaginationDto,
|
@Query() pagination: PaginationDto,
|
||||||
): Promise<ApiResponse<PaginatedData<UserResponseDto>>> {
|
): Promise<ApiResponse<PaginatedData<UserResponseDto>>> {
|
||||||
@@ -73,10 +73,10 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('users/:id')
|
@Get("users/:id")
|
||||||
@ApiOperation({ summary: 'Get user by ID' })
|
@ApiOperation({ summary: "Get user by ID" })
|
||||||
async getUserById(
|
async getUserById(
|
||||||
@Param('id') id: string,
|
@Param("id") id: string,
|
||||||
): Promise<ApiResponse<UserResponseDto>> {
|
): Promise<ApiResponse<UserResponseDto>> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -84,27 +84,27 @@ export class AdminController {
|
|||||||
usageLimit: true,
|
usageLimit: true,
|
||||||
analyses: {
|
analyses: {
|
||||||
take: 5,
|
take: 5,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: "desc" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException("User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return createSuccessResponse(plainToInstance(UserResponseDto, user));
|
return createSuccessResponse(plainToInstance(UserResponseDto, user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('users/:id/toggle-active')
|
@Put("users/:id/toggle-active")
|
||||||
@ApiOperation({ summary: 'Toggle user active status' })
|
@ApiOperation({ summary: "Toggle user active status" })
|
||||||
async toggleUserActive(
|
async toggleUserActive(
|
||||||
@Param('id') id: string,
|
@Param("id") id: string,
|
||||||
): Promise<ApiResponse<UserResponseDto>> {
|
): Promise<ApiResponse<UserResponseDto>> {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException("User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.prisma.user.update({
|
const updated = await this.prisma.user.update({
|
||||||
@@ -114,14 +114,14 @@ export class AdminController {
|
|||||||
|
|
||||||
return createSuccessResponse(
|
return createSuccessResponse(
|
||||||
plainToInstance(UserResponseDto, updated),
|
plainToInstance(UserResponseDto, updated),
|
||||||
'User status updated',
|
"User status updated",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('users/:id/role')
|
@Put("users/:id/role")
|
||||||
@ApiOperation({ summary: 'Update user role' })
|
@ApiOperation({ summary: "Update user role" })
|
||||||
async updateUserRole(
|
async updateUserRole(
|
||||||
@Param('id') id: string,
|
@Param("id") id: string,
|
||||||
@Body() data: { role: UserRole },
|
@Body() data: { role: UserRole },
|
||||||
): Promise<ApiResponse<UserResponseDto>> {
|
): Promise<ApiResponse<UserResponseDto>> {
|
||||||
const user = await this.prisma.user.update({
|
const user = await this.prisma.user.update({
|
||||||
@@ -131,14 +131,14 @@ export class AdminController {
|
|||||||
|
|
||||||
return createSuccessResponse(
|
return createSuccessResponse(
|
||||||
plainToInstance(UserResponseDto, user),
|
plainToInstance(UserResponseDto, user),
|
||||||
'User role updated',
|
"User role updated",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('users/:id/subscription')
|
@Put("users/:id/subscription")
|
||||||
@ApiOperation({ summary: 'Update user subscription' })
|
@ApiOperation({ summary: "Update user subscription" })
|
||||||
async updateUserSubscription(
|
async updateUserSubscription(
|
||||||
@Param('id') id: string,
|
@Param("id") id: string,
|
||||||
@Body()
|
@Body()
|
||||||
data: { subscriptionStatus: string; subscriptionExpiresAt?: string },
|
data: { subscriptionStatus: string; subscriptionExpiresAt?: string },
|
||||||
): Promise<ApiResponse<UserResponseDto>> {
|
): Promise<ApiResponse<UserResponseDto>> {
|
||||||
@@ -154,40 +154,40 @@ export class AdminController {
|
|||||||
|
|
||||||
return createSuccessResponse(
|
return createSuccessResponse(
|
||||||
plainToInstance(UserResponseDto, user),
|
plainToInstance(UserResponseDto, user),
|
||||||
'User subscription updated',
|
"User subscription updated",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('users/:id')
|
@Delete("users/:id")
|
||||||
@ApiOperation({ summary: 'Soft delete a user' })
|
@ApiOperation({ summary: "Soft delete a user" })
|
||||||
async deleteUser(@Param('id') id: string): Promise<ApiResponse<null>> {
|
async deleteUser(@Param("id") id: string): Promise<ApiResponse<null>> {
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { deletedAt: new Date() },
|
data: { deletedAt: new Date() },
|
||||||
});
|
});
|
||||||
return createSuccessResponse(null, 'User deleted');
|
return createSuccessResponse(null, "User deleted");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== App Settings ==================
|
// ================== App Settings ==================
|
||||||
|
|
||||||
@Get('settings')
|
@Get("settings")
|
||||||
@UseInterceptors(CacheInterceptor)
|
@UseInterceptors(CacheInterceptor)
|
||||||
@CacheKey('app_settings')
|
@CacheKey("app_settings")
|
||||||
@CacheTTL(60 * 1000)
|
@CacheTTL(60 * 1000)
|
||||||
@ApiOperation({ summary: 'Get all app settings' })
|
@ApiOperation({ summary: "Get all app settings" })
|
||||||
async getAllSettings(): Promise<ApiResponse<Record<string, string>>> {
|
async getAllSettings(): Promise<ApiResponse<Record<string, string>>> {
|
||||||
const settings = await this.prisma.appSetting.findMany();
|
const settings = await this.prisma.appSetting.findMany();
|
||||||
const settingsMap: Record<string, string> = {};
|
const settingsMap: Record<string, string> = {};
|
||||||
for (const s of settings) {
|
for (const s of settings) {
|
||||||
settingsMap[s.key] = s.value || '';
|
settingsMap[s.key] = s.value || "";
|
||||||
}
|
}
|
||||||
return createSuccessResponse(settingsMap);
|
return createSuccessResponse(settingsMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('settings/:key')
|
@Put("settings/:key")
|
||||||
@ApiOperation({ summary: 'Update an app setting' })
|
@ApiOperation({ summary: "Update an app setting" })
|
||||||
async updateSetting(
|
async updateSetting(
|
||||||
@Param('key') key: string,
|
@Param("key") key: string,
|
||||||
@Body() data: { value: string },
|
@Body() data: { value: string },
|
||||||
): Promise<ApiResponse<{ key: string; value: string }>> {
|
): Promise<ApiResponse<{ key: string; value: string }>> {
|
||||||
const setting = await this.prisma.appSetting.upsert({
|
const setting = await this.prisma.appSetting.upsert({
|
||||||
@@ -195,17 +195,17 @@ export class AdminController {
|
|||||||
update: { value: data.value },
|
update: { value: data.value },
|
||||||
create: { key, value: data.value },
|
create: { key, value: data.value },
|
||||||
});
|
});
|
||||||
await this.cacheManager.del('app_settings');
|
await this.cacheManager.del("app_settings");
|
||||||
return createSuccessResponse(
|
return createSuccessResponse(
|
||||||
{ key: setting.key, value: setting.value || '' },
|
{ key: setting.key, value: setting.value || "" },
|
||||||
'Setting updated',
|
"Setting updated",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== Usage Limits ==================
|
// ================== Usage Limits ==================
|
||||||
|
|
||||||
@Get('usage-limits')
|
@Get("usage-limits")
|
||||||
@ApiOperation({ summary: 'Get all usage limits' })
|
@ApiOperation({ summary: "Get all usage limits" })
|
||||||
async getAllUsageLimits(@Query() pagination: PaginationDto) {
|
async getAllUsageLimits(@Query() pagination: PaginationDto) {
|
||||||
const { skip, take } = pagination;
|
const { skip, take } = pagination;
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ export class AdminController {
|
|||||||
select: { id: true, email: true, firstName: true, lastName: true },
|
select: { id: true, email: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { lastResetDate: 'desc' },
|
orderBy: { lastResetDate: "desc" },
|
||||||
}),
|
}),
|
||||||
this.prisma.usageLimit.count(),
|
this.prisma.usageLimit.count(),
|
||||||
]);
|
]);
|
||||||
@@ -231,8 +231,8 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('usage-limits/reset-all')
|
@Post("usage-limits/reset-all")
|
||||||
@ApiOperation({ summary: 'Reset all usage limits' })
|
@ApiOperation({ summary: "Reset all usage limits" })
|
||||||
async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> {
|
async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> {
|
||||||
const result = await this.prisma.usageLimit.updateMany({
|
const result = await this.prisma.usageLimit.updateMany({
|
||||||
data: {
|
data: {
|
||||||
@@ -244,14 +244,14 @@ export class AdminController {
|
|||||||
|
|
||||||
return createSuccessResponse(
|
return createSuccessResponse(
|
||||||
{ count: result.count },
|
{ count: result.count },
|
||||||
'All usage limits reset',
|
"All usage limits reset",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== Analytics ==================
|
// ================== Analytics ==================
|
||||||
|
|
||||||
@Get('analytics/overview')
|
@Get("analytics/overview")
|
||||||
@ApiOperation({ summary: 'Get system analytics overview' })
|
@ApiOperation({ summary: "Get system analytics overview" })
|
||||||
async getAnalyticsOverview() {
|
async getAnalyticsOverview() {
|
||||||
const [
|
const [
|
||||||
totalUsers,
|
totalUsers,
|
||||||
@@ -262,7 +262,7 @@ export class AdminController {
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.prisma.user.count(),
|
this.prisma.user.count(),
|
||||||
this.prisma.user.count({ where: { isActive: true } }),
|
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.match.count(),
|
||||||
this.prisma.prediction.count(),
|
this.prisma.prediction.count(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from "./admin.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Exclude, Expose, Type } from 'class-transformer';
|
import { Exclude, Expose, Type } from "class-transformer";
|
||||||
|
|
||||||
@Exclude()
|
@Exclude()
|
||||||
export class PermissionResponseDto {
|
export class PermissionResponseDto {
|
||||||
|
|||||||
@@ -6,20 +6,20 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
} from '@nestjs/swagger';
|
} from "@nestjs/swagger";
|
||||||
import { AnalysisService } from './analysis.service';
|
import { AnalysisService } from "./analysis.service";
|
||||||
import { AnalyzeMatchesDto } from './dto/analysis-request.dto';
|
import { AnalyzeMatchesDto } from "./dto/analysis-request.dto";
|
||||||
import { CurrentUser } from '../../common/decorators';
|
import { CurrentUser } from "../../common/decorators";
|
||||||
|
|
||||||
@ApiTags('Analysis')
|
@ApiTags("Analysis")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('analysis')
|
@Controller("analysis")
|
||||||
export class AnalysisController {
|
export class AnalysisController {
|
||||||
constructor(private readonly analysisService: AnalysisService) {}
|
constructor(private readonly analysisService: AnalysisService) {}
|
||||||
|
|
||||||
@@ -27,12 +27,12 @@ export class AnalysisController {
|
|||||||
* POST /analysis/analyze-matches
|
* POST /analysis/analyze-matches
|
||||||
* Analyze multiple matches (coupon generation)
|
* Analyze multiple matches (coupon generation)
|
||||||
*/
|
*/
|
||||||
@Post('analyze-matches')
|
@Post("analyze-matches")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Analyze multiple matches for coupon' })
|
@ApiOperation({ summary: "Analyze multiple matches for coupon" })
|
||||||
@ApiResponse({ status: 200, description: 'Analysis successful' })
|
@ApiResponse({ status: 200, description: "Analysis successful" })
|
||||||
@ApiResponse({ status: 400, description: 'Invalid input' })
|
@ApiResponse({ status: 400, description: "Invalid input" })
|
||||||
@ApiResponse({ status: 429, description: 'Usage limit exceeded' })
|
@ApiResponse({ status: 429, description: "Usage limit exceeded" })
|
||||||
async analyzeMatches(
|
async analyzeMatches(
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
@Body() dto: AnalyzeMatchesDto,
|
@Body() dto: AnalyzeMatchesDto,
|
||||||
@@ -48,7 +48,7 @@ export class AnalysisController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!canProceed) {
|
if (!canProceed) {
|
||||||
throw new ForbiddenException('You have exceeded your daily usage limit');
|
throw new ForbiddenException("You have exceeded your daily usage limit");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run analysis
|
// Run analysis
|
||||||
@@ -57,7 +57,7 @@ export class AnalysisController {
|
|||||||
if (!result) {
|
if (!result) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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 /analysis/analyze (alias for /analyze-matches - frontend compatibility)
|
||||||
*/
|
*/
|
||||||
@Post('analyze')
|
@Post("analyze")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Analyze multiple matches for coupon (alias)',
|
summary: "Analyze multiple matches for coupon (alias)",
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
})
|
})
|
||||||
async analyzeMatchesAlias(
|
async analyzeMatchesAlias(
|
||||||
@@ -90,9 +90,9 @@ export class AnalysisController {
|
|||||||
* GET /analysis/history
|
* GET /analysis/history
|
||||||
* Get user's analysis history
|
* Get user's analysis history
|
||||||
*/
|
*/
|
||||||
@Get('history')
|
@Get("history")
|
||||||
@ApiOperation({ summary: 'Get analysis history' })
|
@ApiOperation({ summary: "Get analysis history" })
|
||||||
@ApiResponse({ status: 200, description: 'History retrieved' })
|
@ApiResponse({ status: 200, description: "History retrieved" })
|
||||||
async getHistory(@CurrentUser() user: any) {
|
async getHistory(@CurrentUser() user: any) {
|
||||||
const history = await this.analysisService.getAnalysisHistory(user.id);
|
const history = await this.analysisService.getAnalysisHistory(user.id);
|
||||||
return { success: true, data: history };
|
return { success: true, data: history };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { AnalysisController } from './analysis.controller';
|
import { AnalysisController } from "./analysis.controller";
|
||||||
import { AnalysisService } from './analysis.service';
|
import { AnalysisService } from "./analysis.service";
|
||||||
import { DatabaseModule } from '../../database/database.module';
|
import { DatabaseModule } from "../../database/database.module";
|
||||||
import { ServicesModule } from '../../services/services.module';
|
import { ServicesModule } from "../../services/services.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, ServicesModule],
|
imports: [DatabaseModule, ServicesModule],
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import {
|
import {
|
||||||
MatchAnalysisService,
|
MatchAnalysisService,
|
||||||
AnalysisResult,
|
AnalysisResult,
|
||||||
} from '../../services/match-analysis.service';
|
} from "../../services/match-analysis.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AnalysisService {
|
export class AnalysisService {
|
||||||
@@ -50,9 +50,9 @@ export class AnalysisService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build URL for analysis
|
// 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 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
|
// Run analysis
|
||||||
const result = await this.matchAnalysisService.analyzeMatch(
|
const result = await this.matchAnalysisService.analyzeMatch(
|
||||||
@@ -110,7 +110,7 @@ export class AnalysisService {
|
|||||||
|
|
||||||
// Check limits (default: 10 analyses, 3 coupons per day)
|
// Check limits (default: 10 analyses, 3 coupons per day)
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
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 maxAnalyses = isPremium ? 50 : 10;
|
||||||
const maxCoupons = isPremium ? 10 : 3;
|
const maxCoupons = isPremium ? 10 : 3;
|
||||||
@@ -145,7 +145,7 @@ export class AnalysisService {
|
|||||||
async getAnalysisHistory(userId: string, limit: number = 20) {
|
async getAnalysisHistory(userId: string, limit: number = 20) {
|
||||||
return this.prisma.analysis.findMany({
|
return this.prisma.analysis.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { IsArray, IsString, ArrayMinSize, ArrayMaxSize } from 'class-validator';
|
import { IsArray, IsString, ArrayMinSize, ArrayMaxSize } from "class-validator";
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
|
||||||
export class AnalyzeMatchesDto {
|
export class AnalyzeMatchesDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'List of match IDs to analyze',
|
description: "List of match IDs to analyze",
|
||||||
example: ['match-1', 'match-2'],
|
example: ["match-1", "match-2"],
|
||||||
minItems: 1,
|
minItems: 1,
|
||||||
maxItems: 20,
|
maxItems: 20,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
|
import { Controller, Post, Body, HttpCode } from "@nestjs/common";
|
||||||
import { I18n, I18nContext } from 'nestjs-i18n';
|
import { I18n, I18nContext } from "nestjs-i18n";
|
||||||
import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiOkResponse } from "@nestjs/swagger";
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from "./auth.service";
|
||||||
import {
|
import {
|
||||||
RegisterDto,
|
RegisterDto,
|
||||||
LoginDto,
|
LoginDto,
|
||||||
RefreshTokenDto,
|
RefreshTokenDto,
|
||||||
TokenResponseDto,
|
TokenResponseDto,
|
||||||
} from './dto/auth.dto';
|
} from "./dto/auth.dto";
|
||||||
import { Public } from '../../common/decorators';
|
import { Public } from "../../common/decorators";
|
||||||
import {
|
import {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
createSuccessResponse,
|
createSuccessResponse,
|
||||||
} from '../../common/types/api-response.type';
|
} from "../../common/types/api-response.type";
|
||||||
|
|
||||||
@ApiTags('Auth')
|
@ApiTags("Auth")
|
||||||
@Controller('auth')
|
@Controller("auth")
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
@Post('register')
|
@Post("register")
|
||||||
@Public()
|
@Public()
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Register a new user' })
|
@ApiOperation({ summary: "Register a new user" })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description: 'User registered successfully',
|
description: "User registered successfully",
|
||||||
type: TokenResponseDto,
|
type: TokenResponseDto,
|
||||||
})
|
})
|
||||||
async register(
|
async register(
|
||||||
@@ -32,28 +32,28 @@ export class AuthController {
|
|||||||
@I18n() i18n: I18nContext,
|
@I18n() i18n: I18nContext,
|
||||||
): Promise<ApiResponse<TokenResponseDto>> {
|
): Promise<ApiResponse<TokenResponseDto>> {
|
||||||
const result = await this.authService.register(dto);
|
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()
|
@Public()
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Login with email and password' })
|
@ApiOperation({ summary: "Login with email and password" })
|
||||||
@ApiOkResponse({ description: 'Login successful', type: TokenResponseDto })
|
@ApiOkResponse({ description: "Login successful", type: TokenResponseDto })
|
||||||
async login(
|
async login(
|
||||||
@Body() dto: LoginDto,
|
@Body() dto: LoginDto,
|
||||||
@I18n() i18n: I18nContext,
|
@I18n() i18n: I18nContext,
|
||||||
): Promise<ApiResponse<TokenResponseDto>> {
|
): Promise<ApiResponse<TokenResponseDto>> {
|
||||||
const result = await this.authService.login(dto);
|
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()
|
@Public()
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Refresh access token' })
|
@ApiOperation({ summary: "Refresh access token" })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description: 'Token refreshed successfully',
|
description: "Token refreshed successfully",
|
||||||
type: TokenResponseDto,
|
type: TokenResponseDto,
|
||||||
})
|
})
|
||||||
async refreshToken(
|
async refreshToken(
|
||||||
@@ -61,18 +61,18 @@ export class AuthController {
|
|||||||
@I18n() i18n: I18nContext,
|
@I18n() i18n: I18nContext,
|
||||||
): Promise<ApiResponse<TokenResponseDto>> {
|
): Promise<ApiResponse<TokenResponseDto>> {
|
||||||
const result = await this.authService.refreshToken(dto.refreshToken);
|
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)
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: 'Logout and invalidate refresh token' })
|
@ApiOperation({ summary: "Logout and invalidate refresh token" })
|
||||||
@ApiOkResponse({ description: 'Logout successful' })
|
@ApiOkResponse({ description: "Logout successful" })
|
||||||
async logout(
|
async logout(
|
||||||
@Body() dto: RefreshTokenDto,
|
@Body() dto: RefreshTokenDto,
|
||||||
@I18n() i18n: I18nContext,
|
@I18n() i18n: I18nContext,
|
||||||
): Promise<ApiResponse<null>> {
|
): Promise<ApiResponse<null>> {
|
||||||
await this.authService.logout(dto.refreshToken);
|
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 { Module } from "@nestjs/common";
|
||||||
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
|
import { JwtModule, JwtModuleOptions } from "@nestjs/jwt";
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from "@nestjs/passport";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from "./auth.service";
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from "./strategies/jwt.strategy";
|
||||||
import { JwtAuthGuard, RolesGuard, PermissionsGuard } from './guards';
|
import { JwtAuthGuard, RolesGuard, PermissionsGuard } from "./guards";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: "jwt" }),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (configService: ConfigService): JwtModuleOptions => {
|
useFactory: (configService: ConfigService): JwtModuleOptions => {
|
||||||
const expiresIn =
|
const expiresIn =
|
||||||
configService.get<string>('JWT_ACCESS_EXPIRATION') || '15m';
|
configService.get<string>("JWT_ACCESS_EXPIRATION") || "15m";
|
||||||
return {
|
return {
|
||||||
secret: configService.get<string>('JWT_SECRET'),
|
secret: configService.get<string>("JWT_SECRET"),
|
||||||
signOptions: {
|
signOptions: {
|
||||||
expiresIn: expiresIn as any,
|
expiresIn: expiresIn as any,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from "bcrypt";
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from "crypto";
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import { RegisterDto, LoginDto, TokenResponseDto } from './dto/auth.dto';
|
import { RegisterDto, LoginDto, TokenResponseDto } from "./dto/auth.dto";
|
||||||
import { User, UserRole } from '@prisma/client';
|
import { User, UserRole } from "@prisma/client";
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
@@ -36,7 +36,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
throw new ConflictException('EMAIL_ALREADY_EXISTS');
|
throw new ConflictException("EMAIL_ALREADY_EXISTS");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash password
|
// Hash password
|
||||||
@@ -76,7 +76,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('INVALID_CREDENTIALS');
|
throw new UnauthorizedException("INVALID_CREDENTIALS");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
@@ -86,11 +86,11 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
throw new UnauthorizedException('INVALID_CREDENTIALS');
|
throw new UnauthorizedException("INVALID_CREDENTIALS");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.isActive) {
|
if (!user.isActive) {
|
||||||
throw new UnauthorizedException('ACCOUNT_DISABLED');
|
throw new UnauthorizedException("ACCOUNT_DISABLED");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.generateTokens(user);
|
return this.generateTokens(user);
|
||||||
@@ -109,7 +109,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!storedToken) {
|
if (!storedToken) {
|
||||||
throw new UnauthorizedException('INVALID_REFRESH_TOKEN');
|
throw new UnauthorizedException("INVALID_REFRESH_TOKEN");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storedToken.expiresAt < new Date()) {
|
if (storedToken.expiresAt < new Date()) {
|
||||||
@@ -117,7 +117,7 @@ export class AuthService {
|
|||||||
await this.prisma.refreshToken.delete({
|
await this.prisma.refreshToken.delete({
|
||||||
where: { id: storedToken.id },
|
where: { id: storedToken.id },
|
||||||
});
|
});
|
||||||
throw new UnauthorizedException('INVALID_REFRESH_TOKEN');
|
throw new UnauthorizedException("INVALID_REFRESH_TOKEN");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete old refresh token
|
// Delete old refresh token
|
||||||
@@ -167,13 +167,13 @@ export class AuthService {
|
|||||||
|
|
||||||
// Generate access token
|
// Generate access token
|
||||||
const accessToken = this.jwtService.sign(payload, {
|
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
|
// Generate refresh token
|
||||||
const refreshTokenValue = crypto.randomUUID();
|
const refreshTokenValue = crypto.randomUUID();
|
||||||
const refreshExpiration = this.parseExpiration(
|
const refreshExpiration = this.parseExpiration(
|
||||||
this.configService.get('JWT_REFRESH_EXPIRATION', '7d'),
|
this.configService.get("JWT_REFRESH_EXPIRATION", "7d"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store refresh token
|
// Store refresh token
|
||||||
@@ -190,7 +190,7 @@ export class AuthService {
|
|||||||
refreshToken: refreshTokenValue,
|
refreshToken: refreshTokenValue,
|
||||||
expiresIn:
|
expiresIn:
|
||||||
this.parseExpiration(
|
this.parseExpiration(
|
||||||
this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
|
this.configService.get("JWT_ACCESS_EXPIRATION", "15m"),
|
||||||
) / 1000, // Convert to seconds
|
) / 1000, // Convert to seconds
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -233,13 +233,13 @@ export class AuthService {
|
|||||||
const unit = match[2];
|
const unit = match[2];
|
||||||
|
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case 's':
|
case "s":
|
||||||
return value * 1000;
|
return value * 1000;
|
||||||
case 'm':
|
case "m":
|
||||||
return value * 60 * 1000;
|
return value * 60 * 1000;
|
||||||
case 'h':
|
case "h":
|
||||||
return value * 60 * 60 * 1000;
|
return value * 60 * 60 * 1000;
|
||||||
case 'd':
|
case "d":
|
||||||
return value * 24 * 60 * 60 * 1000;
|
return value * 24 * 60 * 60 * 1000;
|
||||||
default:
|
default:
|
||||||
return 15 * 60 * 1000;
|
return 15 * 60 * 1000;
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
import { IsEmail, IsString, MinLength, IsOptional } from "class-validator";
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
|
||||||
export class RegisterDto {
|
export class RegisterDto {
|
||||||
@ApiProperty({ example: 'user@example.com' })
|
@ApiProperty({ example: "user@example.com" })
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'password123', minLength: 8 })
|
@ApiProperty({ example: "password123", minLength: 8 })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8)
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'John' })
|
@ApiPropertyOptional({ example: "John" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Doe' })
|
@ApiPropertyOptional({ example: "Doe" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LoginDto {
|
export class LoginDto {
|
||||||
@ApiProperty({ example: 'user@example.com' })
|
@ApiProperty({ example: "user@example.com" })
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'password123' })
|
@ApiProperty({ example: "password123" })
|
||||||
@IsString()
|
@IsString()
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import {
|
|||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from "@nestjs/core";
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from "@nestjs/passport";
|
||||||
import { Request } from 'express';
|
import { Request } from "express";
|
||||||
import {
|
import {
|
||||||
IS_PUBLIC_KEY,
|
IS_PUBLIC_KEY,
|
||||||
ROLES_KEY,
|
ROLES_KEY,
|
||||||
PERMISSIONS_KEY,
|
PERMISSIONS_KEY,
|
||||||
} from '../../../common/decorators';
|
} from "../../../common/decorators";
|
||||||
|
|
||||||
interface AuthenticatedUser {
|
interface AuthenticatedUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,14 +25,14 @@ interface AuthenticatedUser {
|
|||||||
* JWT Auth Guard - Validates JWT token
|
* JWT Auth Guard - Validates JWT token
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
export class JwtAuthGuard extends AuthGuard("jwt") {
|
||||||
constructor(private reflector: Reflector) {
|
constructor(private reflector: Reflector) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext) {
|
canActivate(context: ExecutionContext) {
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
if (request?.method === 'OPTIONS') {
|
if (request?.method === "OPTIONS") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +55,10 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
|||||||
info: any,
|
info: any,
|
||||||
): TUser {
|
): TUser {
|
||||||
if (err || !user) {
|
if (err || !user) {
|
||||||
if (info?.name === 'TokenExpiredError') {
|
if (info?.name === "TokenExpiredError") {
|
||||||
throw new UnauthorizedException('TOKEN_EXPIRED');
|
throw new UnauthorizedException("TOKEN_EXPIRED");
|
||||||
}
|
}
|
||||||
throw err || new UnauthorizedException('AUTH_REQUIRED');
|
throw err || new UnauthorizedException("AUTH_REQUIRED");
|
||||||
}
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ export class RolesGuard implements CanActivate {
|
|||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const req = context.switchToHttp().getRequest<Request>();
|
const req = context.switchToHttp().getRequest<Request>();
|
||||||
if (req?.method === 'OPTIONS') {
|
if (req?.method === "OPTIONS") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ export class RolesGuard implements CanActivate {
|
|||||||
|
|
||||||
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
|
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
|
||||||
if (!hasRole) {
|
if (!hasRole) {
|
||||||
throw new ForbiddenException('PERMISSION_DENIED');
|
throw new ForbiddenException("PERMISSION_DENIED");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -110,7 +110,7 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const req = context.switchToHttp().getRequest<Request>();
|
const req = context.switchToHttp().getRequest<Request>();
|
||||||
if (req?.method === 'OPTIONS') {
|
if (req?.method === "OPTIONS") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
throw new ForbiddenException('PERMISSION_DENIED');
|
throw new ForbiddenException("PERMISSION_DENIED");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from './auth.guards';
|
export * from "./auth.guards";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from "@nestjs/common";
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { AuthService, JwtPayload } from '../auth.service';
|
import { AuthService, JwtPayload } from "../auth.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
@@ -10,9 +10,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
) {
|
) {
|
||||||
const secret = configService.get<string>('JWT_SECRET');
|
const secret = configService.get<string>("JWT_SECRET");
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error('JWT_SECRET is not defined');
|
throw new Error("JWT_SECRET is not defined");
|
||||||
}
|
}
|
||||||
|
|
||||||
super({
|
super({
|
||||||
|
|||||||
@@ -9,31 +9,31 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
Req,
|
Req,
|
||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
} from '@nestjs/swagger';
|
} from "@nestjs/swagger";
|
||||||
import { CouponsService } from './coupons.service';
|
import { CouponsService } from "./coupons.service";
|
||||||
import { MatchesService } from '../matches/matches.service';
|
import { MatchesService } from "../matches/matches.service";
|
||||||
import { SmartCouponService } from './services/smart-coupon.service';
|
import { SmartCouponService } from "./services/smart-coupon.service";
|
||||||
import {
|
import {
|
||||||
UserCouponService,
|
UserCouponService,
|
||||||
CreateCouponDto,
|
CreateCouponDto,
|
||||||
} from './services/user-coupon.service';
|
} from "./services/user-coupon.service";
|
||||||
import {
|
import {
|
||||||
AnalyzeMatchDto,
|
AnalyzeMatchDto,
|
||||||
DailyBankoDto,
|
DailyBankoDto,
|
||||||
SuggestCouponDto,
|
SuggestCouponDto,
|
||||||
} from './dto/coupons-request.dto';
|
} from "./dto/coupons-request.dto";
|
||||||
import { Public } from '../../common/decorators';
|
import { Public } from "../../common/decorators";
|
||||||
import { JwtAuthGuard } from '../auth/guards/auth.guards'; // Assuming standard guard
|
import { JwtAuthGuard } from "../auth/guards/auth.guards"; // Assuming standard guard
|
||||||
import { Sport } from '../matches/dto';
|
import { Sport } from "../matches/dto";
|
||||||
|
|
||||||
@ApiTags('Coupon')
|
@ApiTags("Coupon")
|
||||||
@Controller('coupon')
|
@Controller("coupon")
|
||||||
export class CouponsController {
|
export class CouponsController {
|
||||||
private readonly logger = new Logger(CouponsController.name);
|
private readonly logger = new Logger(CouponsController.name);
|
||||||
|
|
||||||
@@ -48,15 +48,15 @@ export class CouponsController {
|
|||||||
* POST /coupon/analyze-match
|
* POST /coupon/analyze-match
|
||||||
* Analyze a single match with V20+ single-match package
|
* Analyze a single match with V20+ single-match package
|
||||||
*/
|
*/
|
||||||
@Post('analyze-match')
|
@Post("analyze-match")
|
||||||
@Public()
|
@Public()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Analyze single match with V20 model' })
|
@ApiOperation({ summary: "Analyze single match with V20 model" })
|
||||||
@ApiResponse({ status: 200, description: 'Match analysis' })
|
@ApiResponse({ status: 200, description: "Match analysis" })
|
||||||
async analyzeMatch(@Body() dto: AnalyzeMatchDto) {
|
async analyzeMatch(@Body() dto: AnalyzeMatchDto) {
|
||||||
const analysis = await this.smartCouponService.analyzeMatch(dto.matchId);
|
const analysis = await this.smartCouponService.analyzeMatch(dto.matchId);
|
||||||
if (!analysis) {
|
if (!analysis) {
|
||||||
return { success: false, message: 'Analiz yapılamadı.' };
|
return { success: false, message: "Analiz yapılamadı." };
|
||||||
}
|
}
|
||||||
return { success: true, data: analysis };
|
return { success: true, data: analysis };
|
||||||
}
|
}
|
||||||
@@ -64,11 +64,11 @@ export class CouponsController {
|
|||||||
/**
|
/**
|
||||||
* POST /coupon/analyze (alias for /analyze-match - frontend compatibility)
|
* POST /coupon/analyze (alias for /analyze-match - frontend compatibility)
|
||||||
*/
|
*/
|
||||||
@Post('analyze')
|
@Post("analyze")
|
||||||
@Public()
|
@Public()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Analyze single match with V20 model (alias)',
|
summary: "Analyze single match with V20 model (alias)",
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
})
|
})
|
||||||
async analyzeMatchAlias(@Body() dto: AnalyzeMatchDto) {
|
async analyzeMatchAlias(@Body() dto: AnalyzeMatchDto) {
|
||||||
@@ -83,7 +83,7 @@ export class CouponsController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@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) {
|
async createCouponAlias(@Body() dto: CreateCouponDto, @Req() req: any) {
|
||||||
return this.createCoupon(dto, req);
|
return this.createCoupon(dto, req);
|
||||||
}
|
}
|
||||||
@@ -92,11 +92,11 @@ export class CouponsController {
|
|||||||
* POST /coupon/daily-banko
|
* POST /coupon/daily-banko
|
||||||
* Generate a high-confidence banko combo (2 matches)
|
* Generate a high-confidence banko combo (2 matches)
|
||||||
*/
|
*/
|
||||||
@Post('daily-banko')
|
@Post("daily-banko")
|
||||||
@Public()
|
@Public()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Generate a high-confidence banko combo (2 matches)',
|
summary: "Generate a high-confidence banko combo (2 matches)",
|
||||||
})
|
})
|
||||||
async getDailyBanko(@Body() dto: DailyBankoDto) {
|
async getDailyBanko(@Body() dto: DailyBankoDto) {
|
||||||
// If no match IDs provided, fetch from system (top 50 upcoming)
|
// If no match IDs provided, fetch from system (top 50 upcoming)
|
||||||
@@ -122,7 +122,7 @@ export class CouponsController {
|
|||||||
if (candidateMatches.length === 0) {
|
if (candidateMatches.length === 0) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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) {
|
if (!coupon) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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 };
|
return { success: true, data: coupon };
|
||||||
@@ -141,11 +141,11 @@ export class CouponsController {
|
|||||||
* POST /coupon/suggest
|
* POST /coupon/suggest
|
||||||
* Generate Smart Coupon
|
* Generate Smart Coupon
|
||||||
*/
|
*/
|
||||||
@Post('suggest')
|
@Post("suggest")
|
||||||
@Public()
|
@Public()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Suggest Smart Coupon' })
|
@ApiOperation({ summary: "Suggest Smart Coupon" })
|
||||||
@ApiResponse({ status: 200, description: 'Smart Coupon generated' })
|
@ApiResponse({ status: 200, description: "Smart Coupon generated" })
|
||||||
async suggestCoupon(@Body() dto: SuggestCouponDto) {
|
async suggestCoupon(@Body() dto: SuggestCouponDto) {
|
||||||
// If no match IDs provided, fetch from system (top 50 upcoming)
|
// If no match IDs provided, fetch from system (top 50 upcoming)
|
||||||
let candidateMatches = dto.matchIds || [];
|
let candidateMatches = dto.matchIds || [];
|
||||||
@@ -170,7 +170,7 @@ export class CouponsController {
|
|||||||
if (candidateMatches.length === 0) {
|
if (candidateMatches.length === 0) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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) {
|
if (!coupon) {
|
||||||
return { success: false, message: 'Kupon oluşturulamadı.' };
|
return { success: false, message: "Kupon oluşturulamadı." };
|
||||||
}
|
}
|
||||||
return { success: true, data: coupon };
|
return { success: true, data: coupon };
|
||||||
}
|
}
|
||||||
@@ -196,11 +196,11 @@ export class CouponsController {
|
|||||||
* POST /coupon/create
|
* POST /coupon/create
|
||||||
* Save a user generated coupon
|
* Save a user generated coupon
|
||||||
*/
|
*/
|
||||||
@Post('create')
|
@Post("create")
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@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) {
|
async createCoupon(@Body() dto: CreateCouponDto, @Req() req: any) {
|
||||||
// req.user is populated by JwtAuthGuard
|
// req.user is populated by JwtAuthGuard
|
||||||
const coupon = await this.userCouponService.createCoupon(req.user, dto);
|
const coupon = await this.userCouponService.createCoupon(req.user, dto);
|
||||||
@@ -211,10 +211,10 @@ export class CouponsController {
|
|||||||
* GET /coupon/my-stats
|
* GET /coupon/my-stats
|
||||||
* Get user betting statistics (ROI, Win Rate)
|
* Get user betting statistics (ROI, Win Rate)
|
||||||
*/
|
*/
|
||||||
@Get('my-stats')
|
@Get("my-stats")
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get user betting statistics' })
|
@ApiOperation({ summary: "Get user betting statistics" })
|
||||||
async getUserStats(@Req() req: any) {
|
async getUserStats(@Req() req: any) {
|
||||||
const stats = await this.userCouponService.getUserStatistics(req.user.id);
|
const stats = await this.userCouponService.getUserStatistics(req.user.id);
|
||||||
return { success: true, data: stats };
|
return { success: true, data: stats };
|
||||||
@@ -224,11 +224,11 @@ export class CouponsController {
|
|||||||
* GET /coupon/history
|
* GET /coupon/history
|
||||||
* Get coupon history (Public/System coupons)
|
* Get coupon history (Public/System coupons)
|
||||||
*/
|
*/
|
||||||
@Get('history')
|
@Get("history")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get coupon history' })
|
@ApiOperation({ summary: "Get coupon history" })
|
||||||
@ApiResponse({ status: 200, description: 'History retrieved' })
|
@ApiResponse({ status: 200, description: "History retrieved" })
|
||||||
async getHistory(@Query('limit') limit?: string) {
|
async getHistory(@Query("limit") limit?: string) {
|
||||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||||
const results = await this.couponsService.getCouponHistory(
|
const results = await this.couponsService.getCouponHistory(
|
||||||
Number(limit) || 10,
|
Number(limit) || 10,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { CouponsController } from './coupons.controller';
|
import { CouponsController } from "./coupons.controller";
|
||||||
import { SmartCouponService } from './services/smart-coupon.service';
|
import { SmartCouponService } from "./services/smart-coupon.service";
|
||||||
import { UserCouponService } from './services/user-coupon.service';
|
import { UserCouponService } from "./services/user-coupon.service";
|
||||||
import { CouponsService } from './coupons.service';
|
import { CouponsService } from "./coupons.service";
|
||||||
import { DatabaseModule } from '../../database/database.module';
|
import { DatabaseModule } from "../../database/database.module";
|
||||||
import { ServicesModule } from '../../services/services.module';
|
import { ServicesModule } from "../../services/services.module";
|
||||||
import { MatchesModule } from '../matches/matches.module';
|
import { MatchesModule } from "../matches/matches.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, ServicesModule, MatchesModule],
|
imports: [DatabaseModule, ServicesModule, MatchesModule],
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import { AiService } from '../../services/ai.service';
|
import { AiService } from "../../services/ai.service";
|
||||||
// [REMOVED V16 IMPORTS]
|
// [REMOVED V16 IMPORTS]
|
||||||
|
|
||||||
export type RiskLevel = 'banko' | 'safe' | 'value';
|
export type RiskLevel = "banko" | "safe" | "value";
|
||||||
|
|
||||||
export interface CouponMatch {
|
export interface CouponMatch {
|
||||||
matchId: string;
|
matchId: string;
|
||||||
|
|||||||
@@ -8,19 +8,19 @@ import {
|
|||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
Min,
|
Min,
|
||||||
Max,
|
Max,
|
||||||
} from 'class-validator';
|
} from "class-validator";
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
|
||||||
export enum CouponStrategyEnum {
|
export enum CouponStrategyEnum {
|
||||||
SAFE = 'SAFE',
|
SAFE = "SAFE",
|
||||||
BALANCED = 'BALANCED',
|
BALANCED = "BALANCED",
|
||||||
AGGRESSIVE = 'AGGRESSIVE',
|
AGGRESSIVE = "AGGRESSIVE",
|
||||||
VALUE = 'VALUE',
|
VALUE = "VALUE",
|
||||||
MIRACLE = 'MIRACLE',
|
MIRACLE = "MIRACLE",
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnalyzeMatchDto {
|
export class AnalyzeMatchDto {
|
||||||
@ApiProperty({ description: 'Match ID to analyze' })
|
@ApiProperty({ description: "Match ID to analyze" })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
matchId: string;
|
matchId: string;
|
||||||
@@ -28,8 +28,8 @@ export class AnalyzeMatchDto {
|
|||||||
|
|
||||||
export class DailyBankoDto {
|
export class DailyBankoDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Optional match IDs — system fetches if empty',
|
description: "Optional match IDs — system fetches if empty",
|
||||||
example: ['match-1', 'match-2'],
|
example: ["match-1", "match-2"],
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@@ -40,8 +40,8 @@ export class DailyBankoDto {
|
|||||||
|
|
||||||
export class SuggestCouponDto {
|
export class SuggestCouponDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Match IDs — system fetches if empty',
|
description: "Match IDs — system fetches if empty",
|
||||||
example: ['match-1', 'match-2'],
|
example: ["match-1", "match-2"],
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@@ -57,7 +57,7 @@ export class SuggestCouponDto {
|
|||||||
@IsEnum(CouponStrategyEnum)
|
@IsEnum(CouponStrategyEnum)
|
||||||
strategy?: CouponStrategyEnum;
|
strategy?: CouponStrategyEnum;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 })
|
@ApiPropertyOptional({ description: "Maximum matches in coupon", example: 5 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@@ -65,7 +65,7 @@ export class SuggestCouponDto {
|
|||||||
maxMatches?: number;
|
maxMatches?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Minimum confidence threshold (0-100)',
|
description: "Minimum confidence threshold (0-100)",
|
||||||
example: 60,
|
example: 60,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
|
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
import { GeminiService } from '../../gemini/gemini.service';
|
import { GeminiService } from "../../gemini/gemini.service";
|
||||||
|
|
||||||
export type PredictionRiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
|
export type PredictionRiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
|
||||||
export type PredictionDataQuality = 'HIGH' | 'MEDIUM' | 'LOW';
|
export type PredictionDataQuality = "HIGH" | "MEDIUM" | "LOW";
|
||||||
export type BetGrade = 'A' | 'B' | 'C' | 'PASS';
|
export type BetGrade = "A" | "B" | "C" | "PASS";
|
||||||
|
|
||||||
export interface PredictionPickRow {
|
export interface PredictionPickRow {
|
||||||
market: string;
|
market: string;
|
||||||
@@ -128,7 +128,7 @@ export class SmartCouponService {
|
|||||||
private readonly aiEngineUrl: string;
|
private readonly aiEngineUrl: string;
|
||||||
|
|
||||||
constructor(private readonly geminiService: GeminiService) {
|
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> {
|
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
|
||||||
@@ -147,7 +147,7 @@ export class SmartCouponService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
'AI analyze failed',
|
"AI analyze failed",
|
||||||
HttpStatus.SERVICE_UNAVAILABLE,
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -168,7 +168,7 @@ export class SmartCouponService {
|
|||||||
const result = await this.geminiService.generateText(
|
const result = await this.geminiService.generateText(
|
||||||
JSON.stringify(prediction, null, 2),
|
JSON.stringify(prediction, null, 2),
|
||||||
{
|
{
|
||||||
model: 'gemini-2.0-flash',
|
model: "gemini-2.0-flash",
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
maxTokens: 600,
|
maxTokens: 600,
|
||||||
systemPrompt: MATCH_COMMENTARY_SYSTEM_PROMPT,
|
systemPrompt: MATCH_COMMENTARY_SYSTEM_PROMPT,
|
||||||
@@ -176,7 +176,7 @@ export class SmartCouponService {
|
|||||||
);
|
);
|
||||||
return result.text || null;
|
return result.text || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn('AI commentary generation failed, skipping', error);
|
this.logger.warn("AI commentary generation failed, skipping", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ export class SmartCouponService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getSmartCoupon(matchIds, 'SAFE', {
|
return this.getSmartCoupon(matchIds, "SAFE", {
|
||||||
maxMatches: 2,
|
maxMatches: 2,
|
||||||
minConfidence: 78,
|
minConfidence: 78,
|
||||||
});
|
});
|
||||||
@@ -197,11 +197,11 @@ export class SmartCouponService {
|
|||||||
async getSmartCoupon(
|
async getSmartCoupon(
|
||||||
matchIds: string[],
|
matchIds: string[],
|
||||||
strategy:
|
strategy:
|
||||||
| 'SAFE'
|
| "SAFE"
|
||||||
| 'BALANCED'
|
| "BALANCED"
|
||||||
| 'AGGRESSIVE'
|
| "AGGRESSIVE"
|
||||||
| 'VALUE'
|
| "VALUE"
|
||||||
| 'MIRACLE' = 'BALANCED',
|
| "MIRACLE" = "BALANCED",
|
||||||
options: { maxMatches?: number; minConfidence?: number } = {},
|
options: { maxMatches?: number; minConfidence?: number } = {},
|
||||||
): Promise<SmartCouponResult> {
|
): Promise<SmartCouponResult> {
|
||||||
try {
|
try {
|
||||||
@@ -216,7 +216,7 @@ export class SmartCouponService {
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to generate smart coupon', error);
|
this.logger.error("Failed to generate smart coupon", error);
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const detail = error.response?.data?.detail || error.message;
|
const detail = error.response?.data?.detail || error.message;
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@@ -225,7 +225,7 @@ export class SmartCouponService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
'Coupon generation failed',
|
"Coupon generation failed",
|
||||||
HttpStatus.SERVICE_UNAVAILABLE,
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaService } from '../../../database/prisma.service';
|
import { PrismaService } from "../../../database/prisma.service";
|
||||||
import { User, UserCoupon, Match } from '@prisma/client';
|
import { User, UserCoupon, Match } from "@prisma/client";
|
||||||
|
|
||||||
export class CreateCouponDto {
|
export class CreateCouponDto {
|
||||||
strategy: string; // 'SAFE', 'VALUE', 'CUSTOM'
|
strategy: string; // 'SAFE', 'VALUE', 'CUSTOM'
|
||||||
@@ -39,7 +39,7 @@ export class UserCouponService {
|
|||||||
strategy: dto.strategy,
|
strategy: dto.strategy,
|
||||||
totalOdds: parseFloat(totalOdds.toFixed(2)),
|
totalOdds: parseFloat(totalOdds.toFixed(2)),
|
||||||
isPublic: dto.isPublic || false,
|
isPublic: dto.isPublic || false,
|
||||||
status: 'PENDING',
|
status: "PENDING",
|
||||||
couponItems: {
|
couponItems: {
|
||||||
create: dto.items.map((item) => ({
|
create: dto.items.map((item) => ({
|
||||||
matchId: item.matchId,
|
matchId: item.matchId,
|
||||||
@@ -66,7 +66,7 @@ export class UserCouponService {
|
|||||||
async updatePendingCoupons(): Promise<void> {
|
async updatePendingCoupons(): Promise<void> {
|
||||||
// Sadece bitmiş (FT) maçları içeren PENDING kuponları çek
|
// Sadece bitmiş (FT) maçları içeren PENDING kuponları çek
|
||||||
const pendingCoupons = await this.prisma.userCoupon.findMany({
|
const pendingCoupons = await this.prisma.userCoupon.findMany({
|
||||||
where: { status: 'PENDING' },
|
where: { status: "PENDING" },
|
||||||
include: {
|
include: {
|
||||||
couponItems: {
|
couponItems: {
|
||||||
include: { match: true },
|
include: { match: true },
|
||||||
@@ -80,7 +80,7 @@ export class UserCouponService {
|
|||||||
let allMatchesFinished = true;
|
let allMatchesFinished = true;
|
||||||
|
|
||||||
for (const item of coupon.couponItems) {
|
for (const item of coupon.couponItems) {
|
||||||
if (item.match.status !== 'FT') {
|
if (item.match.status !== "FT") {
|
||||||
allMatchesFinished = false;
|
allMatchesFinished = false;
|
||||||
break; // Henüz bitmemiş maç var, kuponu güncelleme
|
break; // Henüz bitmemiş maç var, kuponu güncelleme
|
||||||
}
|
}
|
||||||
@@ -104,12 +104,12 @@ export class UserCouponService {
|
|||||||
if (isCouponLost) {
|
if (isCouponLost) {
|
||||||
await this.prisma.userCoupon.update({
|
await this.prisma.userCoupon.update({
|
||||||
where: { id: coupon.id },
|
where: { id: coupon.id },
|
||||||
data: { status: 'LOST' },
|
data: { status: "LOST" },
|
||||||
});
|
});
|
||||||
} else if (allMatchesFinished && isCouponWon) {
|
} else if (allMatchesFinished && isCouponWon) {
|
||||||
await this.prisma.userCoupon.update({
|
await this.prisma.userCoupon.update({
|
||||||
where: { id: coupon.id },
|
where: { id: coupon.id },
|
||||||
data: { status: 'WON' },
|
data: { status: "WON" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,23 +125,23 @@ export class UserCouponService {
|
|||||||
const total = home + away;
|
const total = home + away;
|
||||||
|
|
||||||
switch (selection) {
|
switch (selection) {
|
||||||
case 'MS 1':
|
case "MS 1":
|
||||||
return home > away;
|
return home > away;
|
||||||
case 'MS X':
|
case "MS X":
|
||||||
return home === away;
|
return home === away;
|
||||||
case 'MS 2':
|
case "MS 2":
|
||||||
return away > home;
|
return away > home;
|
||||||
case '1.5 UST':
|
case "1.5 UST":
|
||||||
return total > 1.5;
|
return total > 1.5;
|
||||||
case '2.5 UST':
|
case "2.5 UST":
|
||||||
return total > 2.5;
|
return total > 2.5;
|
||||||
case '3.5 UST':
|
case "3.5 UST":
|
||||||
return total > 3.5;
|
return total > 3.5;
|
||||||
case '2.5 ALT':
|
case "2.5 ALT":
|
||||||
return total < 2.5;
|
return total < 2.5;
|
||||||
case 'KG VAR':
|
case "KG VAR":
|
||||||
return home > 0 && away > 0;
|
return home > 0 && away > 0;
|
||||||
case 'KG YOK':
|
case "KG YOK":
|
||||||
return home === 0 || away === 0;
|
return home === 0 || away === 0;
|
||||||
default:
|
default:
|
||||||
return false; // Bilinmeyen market
|
return false; // Bilinmeyen market
|
||||||
@@ -155,7 +155,7 @@ export class UserCouponService {
|
|||||||
const coupons = await this.prisma.userCoupon.findMany({
|
const coupons = await this.prisma.userCoupon.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
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 totalInvested = totalCoupons; // Her kupona 1 birim yatırıldığını varsayıyoruz
|
||||||
const totalReturn = wonCoupons.reduce((acc, c) => acc + c.totalOdds, 0);
|
const totalReturn = wonCoupons.reduce((acc, c) => acc + c.totalOdds, 0);
|
||||||
const winRate = (wonCoupons.length / totalCoupons) * 100;
|
const winRate = (wonCoupons.length / totalCoupons) * 100;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* Database operations using Prisma
|
* Database operations using Prisma
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import {
|
import {
|
||||||
Sport,
|
Sport,
|
||||||
MatchSummary,
|
MatchSummary,
|
||||||
@@ -20,8 +20,8 @@ import {
|
|||||||
DbEventPayload,
|
DbEventPayload,
|
||||||
DbMarketPayload,
|
DbMarketPayload,
|
||||||
BasketballTeamStats,
|
BasketballTeamStats,
|
||||||
} from './feeder.types';
|
} from "./feeder.types";
|
||||||
import { ImageUtils } from '../../common/utils/image.util';
|
import { ImageUtils } from "../../common/utils/image.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FeederPersistenceService {
|
export class FeederPersistenceService {
|
||||||
@@ -33,7 +33,7 @@ export class FeederPersistenceService {
|
|||||||
// HELPER FUNCTIONS
|
// HELPER FUNCTIONS
|
||||||
// ============================================
|
// ============================================
|
||||||
private safeString(value: any): string | null {
|
private safeString(value: any): string | null {
|
||||||
return value === null || value === undefined || value === ''
|
return value === null || value === undefined || value === ""
|
||||||
? null
|
? null
|
||||||
: String(value);
|
: String(value);
|
||||||
}
|
}
|
||||||
@@ -51,12 +51,12 @@ export class FeederPersistenceService {
|
|||||||
private mapPositionToEnum(position: string | null): any {
|
private mapPositionToEnum(position: string | null): any {
|
||||||
if (!position) return null;
|
if (!position) return null;
|
||||||
const pos = position.toLowerCase();
|
const pos = position.toLowerCase();
|
||||||
if (pos.includes('kaleci') || pos.includes('goalkeeper'))
|
if (pos.includes("kaleci") || pos.includes("goalkeeper"))
|
||||||
return 'goalkeeper';
|
return "goalkeeper";
|
||||||
if (pos.includes('defans') || pos.includes('defender')) return 'defender';
|
if (pos.includes("defans") || pos.includes("defender")) return "defender";
|
||||||
if (pos.includes('orta saha') || pos.includes('midfielder'))
|
if (pos.includes("orta saha") || pos.includes("midfielder"))
|
||||||
return 'midfielder';
|
return "midfielder";
|
||||||
if (pos.includes('forvet') || pos.includes('striker')) return 'striker';
|
if (pos.includes("forvet") || pos.includes("striker")) return "striker";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ export class FeederPersistenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const s of market.selectionCollection) {
|
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 sName = this.safeString(s.name);
|
||||||
const sValue = this.safeString(s.odd);
|
const sValue = this.safeString(s.odd);
|
||||||
@@ -107,7 +107,7 @@ export class FeederPersistenceService {
|
|||||||
|
|
||||||
if (existingSel) {
|
if (existingSel) {
|
||||||
if (existingSel.oddValue !== sValue) {
|
if (existingSel.oddValue !== sValue) {
|
||||||
const oldVal = parseFloat(existingSel.oddValue || '0');
|
const oldVal = parseFloat(existingSel.oddValue || "0");
|
||||||
const newVal = parseFloat(sValue);
|
const newVal = parseFloat(sValue);
|
||||||
|
|
||||||
if (!isNaN(oldVal) && !isNaN(newVal)) {
|
if (!isNaN(oldVal) && !isNaN(newVal)) {
|
||||||
@@ -182,13 +182,13 @@ export class FeederPersistenceService {
|
|||||||
const teamsToUpsert = [
|
const teamsToUpsert = [
|
||||||
{
|
{
|
||||||
id: homeTeamId,
|
id: homeTeamId,
|
||||||
name: matchSummary.homeTeam?.name || 'Unknown',
|
name: matchSummary.homeTeam?.name || "Unknown",
|
||||||
slug: matchSummary.homeTeam?.slug || homeTeamId,
|
slug: matchSummary.homeTeam?.slug || homeTeamId,
|
||||||
sport: sport,
|
sport: sport,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: awayTeamId,
|
id: awayTeamId,
|
||||||
name: matchSummary.awayTeam?.name || 'Unknown',
|
name: matchSummary.awayTeam?.name || "Unknown",
|
||||||
slug: matchSummary.awayTeam?.slug || awayTeamId,
|
slug: matchSummary.awayTeam?.slug || awayTeamId,
|
||||||
sport: sport,
|
sport: sport,
|
||||||
},
|
},
|
||||||
@@ -221,18 +221,18 @@ export class FeederPersistenceService {
|
|||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
id: countryId,
|
id: countryId,
|
||||||
name: league.country.name || 'Unknown',
|
name: league.country.name || "Unknown",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} 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)
|
// 2. Save League (Handle ID changes by checking unique constraint)
|
||||||
let finalLeagueId = this.safeString(league.id);
|
let finalLeagueId = this.safeString(league.id);
|
||||||
if (finalLeagueId && countryId) {
|
if (finalLeagueId && countryId) {
|
||||||
const leagueName = league.name || 'Unknown';
|
const leagueName = league.name || "Unknown";
|
||||||
|
|
||||||
// Check if league exists by unique constraint (name + country + sport)
|
// Check if league exists by unique constraint (name + country + sport)
|
||||||
const existingLeague = await tx.league.findUnique({
|
const existingLeague = await tx.league.findUnique({
|
||||||
@@ -311,32 +311,32 @@ export class FeederPersistenceService {
|
|||||||
headerData?.htScoreAway ??
|
headerData?.htScoreAway ??
|
||||||
this.safeInt(matchSummary.score?.ht?.away);
|
this.safeInt(matchSummary.score?.ht?.away);
|
||||||
|
|
||||||
let status = 'NS';
|
let status = "NS";
|
||||||
if (headerData?.matchStatus) {
|
if (headerData?.matchStatus) {
|
||||||
if (
|
if (
|
||||||
headerData.matchStatus === 'postGame' ||
|
headerData.matchStatus === "postGame" ||
|
||||||
headerData.matchStatus === 'post'
|
headerData.matchStatus === "post"
|
||||||
) {
|
) {
|
||||||
status = 'FT';
|
status = "FT";
|
||||||
} else if (
|
} else if (
|
||||||
headerData.matchStatus === 'live' ||
|
headerData.matchStatus === "live" ||
|
||||||
headerData.matchStatus === 'liveGame'
|
headerData.matchStatus === "liveGame"
|
||||||
) {
|
) {
|
||||||
status = 'LIVE';
|
status = "LIVE";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Postponed Matches (ERT)
|
// Handle Postponed Matches (ERT)
|
||||||
if (matchSummary.statusBoxContent === 'ERT') {
|
if (matchSummary.statusBoxContent === "ERT") {
|
||||||
status = 'POSTPONED';
|
status = "POSTPONED";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
status === 'NS' &&
|
status === "NS" &&
|
||||||
finalScoreHome !== null &&
|
finalScoreHome !== null &&
|
||||||
finalScoreAway !== null
|
finalScoreAway !== null
|
||||||
) {
|
) {
|
||||||
status = 'FT';
|
status = "FT";
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.match.upsert({
|
await tx.match.upsert({
|
||||||
@@ -455,7 +455,7 @@ export class FeederPersistenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Save Team Stats (Football)
|
// 8. Save Team Stats (Football)
|
||||||
if (stats && sport === 'football') {
|
if (stats && sport === "football") {
|
||||||
const statsRows = [
|
const statsRows = [
|
||||||
{
|
{
|
||||||
matchId,
|
matchId,
|
||||||
@@ -499,7 +499,7 @@ export class FeederPersistenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8b. Save Team Stats (Basketball)
|
// 8b. Save Team Stats (Basketball)
|
||||||
if (basketballTeamStats && sport === 'basketball') {
|
if (basketballTeamStats && sport === "basketball") {
|
||||||
const teams = [
|
const teams = [
|
||||||
{ id: homeTeamId, data: basketballTeamStats.home },
|
{ id: homeTeamId, data: basketballTeamStats.home },
|
||||||
{ id: awayTeamId, data: basketballTeamStats.away },
|
{ id: awayTeamId, data: basketballTeamStats.away },
|
||||||
@@ -558,7 +558,7 @@ export class FeederPersistenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8c. Save Player Stats (Basketball)
|
// 8c. Save Player Stats (Basketball)
|
||||||
if (basketballPlayerStats.length > 0 && sport === 'basketball') {
|
if (basketballPlayerStats.length > 0 && sport === "basketball") {
|
||||||
await tx.basketballPlayerStats.deleteMany({ where: { matchId } });
|
await tx.basketballPlayerStats.deleteMany({ where: { matchId } });
|
||||||
|
|
||||||
for (const p of basketballPlayerStats) {
|
for (const p of basketballPlayerStats) {
|
||||||
@@ -592,12 +592,12 @@ export class FeederPersistenceService {
|
|||||||
await this.saveOddsInTransaction(tx, matchId, oddsArray);
|
await this.saveOddsInTransaction(tx, matchId, oddsArray);
|
||||||
|
|
||||||
// 10. Save Officials
|
// 10. Save Officials
|
||||||
if (sport === 'football' && officialsData.length > 0) {
|
if (sport === "football" && officialsData.length > 0) {
|
||||||
await tx.matchOfficial.deleteMany({ where: { matchId } });
|
await tx.matchOfficial.deleteMany({ where: { matchId } });
|
||||||
const processedOfficials = new Set<string>();
|
const processedOfficials = new Set<string>();
|
||||||
|
|
||||||
for (const o of officialsData) {
|
for (const o of officialsData) {
|
||||||
const roleName = o.role || 'Referee';
|
const roleName = o.role || "Referee";
|
||||||
const uniqueKey = `${o.name}_${roleName}`;
|
const uniqueKey = `${o.name}_${roleName}`;
|
||||||
|
|
||||||
if (processedOfficials.has(uniqueKey)) continue;
|
if (processedOfficials.has(uniqueKey)) continue;
|
||||||
@@ -798,10 +798,10 @@ export class FeederPersistenceService {
|
|||||||
const history = await this.prisma.match.findMany({
|
const history = await this.prisma.match.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }],
|
OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }],
|
||||||
status: 'FT',
|
status: "FT",
|
||||||
mstUtc: { lt: match.mstUtc },
|
mstUtc: { lt: match.mstUtc },
|
||||||
},
|
},
|
||||||
orderBy: { mstUtc: 'desc' },
|
orderBy: { mstUtc: "desc" },
|
||||||
take: 5,
|
take: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -840,8 +840,8 @@ export class FeederPersistenceService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
match_id: match.id,
|
match_id: match.id,
|
||||||
home_team: match.homeTeam?.name || 'Unknown',
|
home_team: match.homeTeam?.name || "Unknown",
|
||||||
away_team: match.awayTeam?.name || 'Unknown',
|
away_team: match.awayTeam?.name || "Unknown",
|
||||||
home_team_id: match.homeTeamId,
|
home_team_id: match.homeTeamId,
|
||||||
away_team_id: match.awayTeamId,
|
away_team_id: match.awayTeamId,
|
||||||
league_id: match.leagueId,
|
league_id: match.leagueId,
|
||||||
@@ -934,7 +934,7 @@ export class FeederPersistenceService {
|
|||||||
scoreHome: liveMatch.scoreHome,
|
scoreHome: liveMatch.scoreHome,
|
||||||
scoreAway: liveMatch.scoreAway,
|
scoreAway: liveMatch.scoreAway,
|
||||||
mstUtc: liveMatch.mstUtc,
|
mstUtc: liveMatch.mstUtc,
|
||||||
sport: liveMatch.sport || 'football',
|
sport: liveMatch.sport || "football",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
* HTTP requests with exact headers from working curl commands
|
* HTTP requests with exact headers from working curl commands
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import axios, { AxiosInstance } from 'axios';
|
import axios, { AxiosInstance } from "axios";
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from "cheerio";
|
||||||
import {
|
import {
|
||||||
Sport,
|
Sport,
|
||||||
SPORTS_CONFIG,
|
SPORTS_CONFIG,
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
SidelinedResponse,
|
SidelinedResponse,
|
||||||
SidelinedTeamData,
|
SidelinedTeamData,
|
||||||
SidelinedPlayer,
|
SidelinedPlayer,
|
||||||
} from './feeder.types';
|
} from "./feeder.types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FeederScraperService {
|
export class FeederScraperService {
|
||||||
@@ -43,13 +43,13 @@ export class FeederScraperService {
|
|||||||
this.axios.interceptors.response.use(
|
this.axios.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`✅ [${response.config.url?.split('?')[0]}] Status: ${response.status}`,
|
`✅ [${response.config.url?.split("?")[0]}] Status: ${response.status}`,
|
||||||
);
|
);
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
const status = error.response?.status || 'N/A';
|
const status = error.response?.status || "N/A";
|
||||||
const url = error.config?.url?.split('?')[0] || 'Unknown';
|
const url = error.config?.url?.split("?")[0] || "Unknown";
|
||||||
this.logger.error(`❌ [${url}] Status: ${status} - ${error.message}`);
|
this.logger.error(`❌ [${url}] Status: ${status} - ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
},
|
},
|
||||||
@@ -72,7 +72,7 @@ export class FeederScraperService {
|
|||||||
|
|
||||||
const response = await this.axios.get(url, {
|
const response = await this.axios.get(url, {
|
||||||
params: {
|
params: {
|
||||||
'sports[]': sportParam,
|
"sports[]": sportParam,
|
||||||
matchDate: dateString,
|
matchDate: dateString,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -80,11 +80,11 @@ export class FeederScraperService {
|
|||||||
const payload = response.data as unknown;
|
const payload = response.data as unknown;
|
||||||
if (
|
if (
|
||||||
!payload ||
|
!payload ||
|
||||||
typeof payload !== 'object' ||
|
typeof payload !== "object" ||
|
||||||
!('status' in payload) ||
|
!("status" in payload) ||
|
||||||
!('data' 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;
|
return payload as LivescoresApiResponse;
|
||||||
@@ -101,14 +101,14 @@ export class FeederScraperService {
|
|||||||
const response = await this.axios.get(url, {
|
const response = await this.axios.get(url, {
|
||||||
params: {
|
params: {
|
||||||
matchId,
|
matchId,
|
||||||
sdapiLanguageCode: 'tr-mk',
|
sdapiLanguageCode: "tr-mk",
|
||||||
ajaxViewName: 'match-details',
|
ajaxViewName: "match-details",
|
||||||
ajaxPartialViewName: 'match-details-status',
|
ajaxPartialViewName: "match-details-status",
|
||||||
displayMode: 'all',
|
displayMode: "all",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.parseMatchHeader(response.data.data?.html || '');
|
return this.parseMatchHeader(response.data.data?.html || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseMatchHeader(html: string): ParsedMatchHeader {
|
private parseMatchHeader(html: string): ParsedMatchHeader {
|
||||||
@@ -116,7 +116,7 @@ export class FeederScraperService {
|
|||||||
|
|
||||||
// Extract match-status from data attribute
|
// Extract match-status from data attribute
|
||||||
const matchStatus =
|
const matchStatus =
|
||||||
($('[data-match-status]').attr('data-match-status') as any) || 'postGame';
|
($("[data-match-status]").attr("data-match-status") as any) || "postGame";
|
||||||
|
|
||||||
// Extract scores
|
// Extract scores
|
||||||
const scoreHome = this.safeInt($('[data-slot="score-home"]').text().trim());
|
const scoreHome = this.safeInt($('[data-slot="score-home"]').text().trim());
|
||||||
@@ -126,7 +126,7 @@ export class FeederScraperService {
|
|||||||
let htScoreHome: number | null = null;
|
let htScoreHome: number | null = null;
|
||||||
let htScoreAway: 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()
|
.text()
|
||||||
.trim();
|
.trim();
|
||||||
const htMatch = detailedScore.match(/\(İY\s*(\d+)\s*-\s*(\d+)\)/);
|
const htMatch = detailedScore.match(/\(İY\s*(\d+)\s*-\s*(\d+)\)/);
|
||||||
@@ -143,7 +143,7 @@ export class FeederScraperService {
|
|||||||
// ============================================
|
// ============================================
|
||||||
async fetchKeyEvents(
|
async fetchKeyEvents(
|
||||||
matchId: string,
|
matchId: string,
|
||||||
): Promise<KeyEventsResponse['data'] | null> {
|
): Promise<KeyEventsResponse["data"] | null> {
|
||||||
const url = `https://www.mackolik.com/ajax/football/key-events`;
|
const url = `https://www.mackolik.com/ajax/football/key-events`;
|
||||||
|
|
||||||
this.logger.debug(`📡 [${matchId}] Fetching key events`);
|
this.logger.debug(`📡 [${matchId}] Fetching key events`);
|
||||||
@@ -151,7 +151,7 @@ export class FeederScraperService {
|
|||||||
try {
|
try {
|
||||||
const response = await this.axios.get<KeyEventsResponse>(url, {
|
const response = await this.axios.get<KeyEventsResponse>(url, {
|
||||||
params: {
|
params: {
|
||||||
ajaxViewName: 'events',
|
ajaxViewName: "events",
|
||||||
matchId,
|
matchId,
|
||||||
seasonId: matchId, // Same as matchId
|
seasonId: matchId, // Same as matchId
|
||||||
},
|
},
|
||||||
@@ -172,7 +172,7 @@ export class FeederScraperService {
|
|||||||
// ============================================
|
// ============================================
|
||||||
async fetchStartingFormation(
|
async fetchStartingFormation(
|
||||||
matchId: string,
|
matchId: string,
|
||||||
): Promise<MatchStatsResponse['data'] | null> {
|
): Promise<MatchStatsResponse["data"] | null> {
|
||||||
const url = `https://www.mackolik.com/ajax/football/match-stats`;
|
const url = `https://www.mackolik.com/ajax/football/match-stats`;
|
||||||
|
|
||||||
this.logger.debug(`📡 [${matchId}] Fetching starting formation`);
|
this.logger.debug(`📡 [${matchId}] Fetching starting formation`);
|
||||||
@@ -180,7 +180,7 @@ export class FeederScraperService {
|
|||||||
try {
|
try {
|
||||||
const response = await this.axios.get<MatchStatsResponse>(url, {
|
const response = await this.axios.get<MatchStatsResponse>(url, {
|
||||||
params: {
|
params: {
|
||||||
ajaxViewName: 'starting-formation',
|
ajaxViewName: "starting-formation",
|
||||||
matchId,
|
matchId,
|
||||||
seasonId: matchId,
|
seasonId: matchId,
|
||||||
},
|
},
|
||||||
@@ -201,7 +201,7 @@ export class FeederScraperService {
|
|||||||
// ============================================
|
// ============================================
|
||||||
async fetchSubstitutions(
|
async fetchSubstitutions(
|
||||||
matchId: string,
|
matchId: string,
|
||||||
): Promise<MatchStatsResponse['data'] | null> {
|
): Promise<MatchStatsResponse["data"] | null> {
|
||||||
const url = `https://www.mackolik.com/ajax/football/match-stats`;
|
const url = `https://www.mackolik.com/ajax/football/match-stats`;
|
||||||
|
|
||||||
this.logger.debug(`📡 [${matchId}] Fetching substitutions`);
|
this.logger.debug(`📡 [${matchId}] Fetching substitutions`);
|
||||||
@@ -209,7 +209,7 @@ export class FeederScraperService {
|
|||||||
try {
|
try {
|
||||||
const response = await this.axios.get<MatchStatsResponse>(url, {
|
const response = await this.axios.get<MatchStatsResponse>(url, {
|
||||||
params: {
|
params: {
|
||||||
ajaxViewName: 'substitutions',
|
ajaxViewName: "substitutions",
|
||||||
matchId,
|
matchId,
|
||||||
seasonId: matchId,
|
seasonId: matchId,
|
||||||
},
|
},
|
||||||
@@ -230,7 +230,7 @@ export class FeederScraperService {
|
|||||||
// ============================================
|
// ============================================
|
||||||
async fetchGameStats(
|
async fetchGameStats(
|
||||||
matchId: string,
|
matchId: string,
|
||||||
): Promise<GameStatsResponse['data'] | null> {
|
): Promise<GameStatsResponse["data"] | null> {
|
||||||
const url = `https://www.mackolik.com/ajax/soccer/match/gameStats`;
|
const url = `https://www.mackolik.com/ajax/soccer/match/gameStats`;
|
||||||
|
|
||||||
this.logger.debug(`📡 [${matchId}] Fetching game stats`);
|
this.logger.debug(`📡 [${matchId}] Fetching game stats`);
|
||||||
@@ -253,7 +253,7 @@ export class FeederScraperService {
|
|||||||
// ============================================
|
// ============================================
|
||||||
// MANAGER
|
// 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`;
|
const url = `https://www.mackolik.com/ajax/football/match-stats`;
|
||||||
|
|
||||||
this.logger.debug(`📡 [${matchId}] Fetching manager`);
|
this.logger.debug(`📡 [${matchId}] Fetching manager`);
|
||||||
@@ -261,7 +261,7 @@ export class FeederScraperService {
|
|||||||
try {
|
try {
|
||||||
const response = await this.axios.get<ManagerResponse>(url, {
|
const response = await this.axios.get<ManagerResponse>(url, {
|
||||||
params: {
|
params: {
|
||||||
ajaxViewName: 'manager',
|
ajaxViewName: "manager",
|
||||||
matchId,
|
matchId,
|
||||||
seasonId: matchId,
|
seasonId: matchId,
|
||||||
},
|
},
|
||||||
@@ -287,10 +287,10 @@ export class FeederScraperService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<IddaaMarketsHtmlResponse>(url, {
|
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) {
|
} catch (error: any) {
|
||||||
if (error.response?.status === 404) {
|
if (error.response?.status === 404) {
|
||||||
this.logger.warn(`[${matchId}] Iddaa markets not found (404)`);
|
this.logger.warn(`[${matchId}] Iddaa markets not found (404)`);
|
||||||
@@ -306,30 +306,30 @@ export class FeederScraperService {
|
|||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const markets: ParsedMarket[] = [];
|
const markets: ParsedMarket[] = [];
|
||||||
|
|
||||||
$('.widget-iddaa-markets__market-item').each((_, marketEl) => {
|
$(".widget-iddaa-markets__market-item").each((_, marketEl) => {
|
||||||
const $market = $(marketEl);
|
const $market = $(marketEl);
|
||||||
|
|
||||||
const marketId = $market.attr('data-market') || '';
|
const marketId = $market.attr("data-market") || "";
|
||||||
const marketName = $market
|
const marketName = $market
|
||||||
.find('.widget-iddaa-markets__header-text')
|
.find(".widget-iddaa-markets__header-text")
|
||||||
.text()
|
.text()
|
||||||
.trim();
|
.trim();
|
||||||
const iddaaCode = $market
|
const iddaaCode = $market
|
||||||
.find('.widget-iddaa-markets__iddaa-code')
|
.find(".widget-iddaa-markets__iddaa-code")
|
||||||
.text()
|
.text()
|
||||||
.trim();
|
.trim();
|
||||||
const mbc = $market.find('.widget-iddaa-markets__mbc').text().trim();
|
const mbc = $market.find(".widget-iddaa-markets__mbc").text().trim();
|
||||||
|
|
||||||
const selections: ParsedSelection[] = [];
|
const selections: ParsedSelection[] = [];
|
||||||
|
|
||||||
$market.find('.widget-iddaa-markets__option').each((_, optionEl) => {
|
$market.find(".widget-iddaa-markets__option").each((_, optionEl) => {
|
||||||
const $option = $(optionEl);
|
const $option = $(optionEl);
|
||||||
|
|
||||||
selections.push({
|
selections.push({
|
||||||
shortcode: $option.attr('data-shortcode') || '',
|
shortcode: $option.attr("data-shortcode") || "",
|
||||||
outcomeNo: $option.attr('data-outcome-no') || '',
|
outcomeNo: $option.attr("data-outcome-no") || "",
|
||||||
label: $option.find('.widget-iddaa-markets__label').text().trim(),
|
label: $option.find(".widget-iddaa-markets__label").text().trim(),
|
||||||
value: $option.find('.widget-iddaa-markets__value').text().trim(),
|
value: $option.find(".widget-iddaa-markets__value").text().trim(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -347,7 +347,7 @@ export class FeederScraperService {
|
|||||||
// ============================================
|
// ============================================
|
||||||
async fetchBasketballBoxScore(
|
async fetchBasketballBoxScore(
|
||||||
matchId: string,
|
matchId: string,
|
||||||
): Promise<BasketballBoxScoreResponse['data'] | null> {
|
): Promise<BasketballBoxScoreResponse["data"] | null> {
|
||||||
// Updated URL based on user request
|
// Updated URL based on user request
|
||||||
const url = `https://www.mackolik.com/ajax/basketball/match/box-score`;
|
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, {
|
const response = await this.axios.get<BasketballBoxScoreResponse>(url, {
|
||||||
params: { matchId },
|
params: { matchId },
|
||||||
headers: {
|
headers: {
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
'User-Agent': DEFAULT_HEADERS['User-Agent'],
|
"User-Agent": DEFAULT_HEADERS["User-Agent"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -382,25 +382,25 @@ export class FeederScraperService {
|
|||||||
const players: Partial<BasketballPlayerStats>[] = [];
|
const players: Partial<BasketballPlayerStats>[] = [];
|
||||||
|
|
||||||
// Parse individual players from widget rows
|
// 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);
|
const row = $(elem);
|
||||||
// Skip if no player name found
|
// 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;
|
if (!nameElem.length) return;
|
||||||
|
|
||||||
const name = nameElem.text().trim();
|
const name = nameElem.text().trim();
|
||||||
// Indices based on User HTML:
|
// 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
|
// 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)
|
// Check if it's a valid player row (should have enough columns)
|
||||||
if (values.length < 10) return;
|
if (values.length < 10) return;
|
||||||
|
|
||||||
// Extract ID from link if possible
|
// Extract ID from link if possible
|
||||||
let playerId = '';
|
let playerId = "";
|
||||||
const link = nameElem.find('a').attr('href');
|
const link = nameElem.find("a").attr("href");
|
||||||
if (link) {
|
if (link) {
|
||||||
playerId = this.extractPlayerIdFromUrl(link) || '';
|
playerId = this.extractPlayerIdFromUrl(link) || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
players.push({
|
players.push({
|
||||||
@@ -410,16 +410,16 @@ export class FeederScraperService {
|
|||||||
points: this.safeInt(values.eq(2).text().trim()) || 0,
|
points: this.safeInt(values.eq(2).text().trim()) || 0,
|
||||||
rebounds: this.safeInt(values.eq(3).text().trim()) || 0,
|
rebounds: this.safeInt(values.eq(3).text().trim()) || 0,
|
||||||
assists: this.safeInt(values.eq(4).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:
|
fgAttempted:
|
||||||
this.safeInt(values.eq(5).text().trim().split('/')[1]) || 0,
|
this.safeInt(values.eq(5).text().trim().split("/")[1]) || 0,
|
||||||
threePtMade:
|
threePtMade:
|
||||||
this.safeInt(values.eq(6).text().trim().split('/')[0]) || 0,
|
this.safeInt(values.eq(6).text().trim().split("/")[0]) || 0,
|
||||||
threePtAttempted:
|
threePtAttempted:
|
||||||
this.safeInt(values.eq(6).text().trim().split('/')[1]) || 0,
|
this.safeInt(values.eq(6).text().trim().split("/")[1]) || 0,
|
||||||
ftMade: this.safeInt(values.eq(7).text().trim().split('/')[0]) || 0,
|
ftMade: this.safeInt(values.eq(7).text().trim().split("/")[0]) || 0,
|
||||||
ftAttempted:
|
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,
|
fouls: this.safeInt(values.eq(8).text().trim()) || 0,
|
||||||
blocks: this.safeInt(values.eq(9).text().trim()) || 0,
|
blocks: this.safeInt(values.eq(9).text().trim()) || 0,
|
||||||
steals: this.safeInt(values.eq(10).text().trim()) || 0,
|
steals: this.safeInt(values.eq(10).text().trim()) || 0,
|
||||||
@@ -428,7 +428,7 @@ export class FeederScraperService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Parse Team Totals from Footer
|
// 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 = {};
|
let teamTotals: any = {};
|
||||||
|
|
||||||
if (footerRow.length > 5) {
|
if (footerRow.length > 5) {
|
||||||
@@ -438,16 +438,16 @@ export class FeederScraperService {
|
|||||||
points: this.safeInt(footerRow.eq(2).text().trim()) || 0,
|
points: this.safeInt(footerRow.eq(2).text().trim()) || 0,
|
||||||
rebounds: this.safeInt(footerRow.eq(3).text().trim()) || 0,
|
rebounds: this.safeInt(footerRow.eq(3).text().trim()) || 0,
|
||||||
assists: this.safeInt(footerRow.eq(4).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:
|
fgAttempted:
|
||||||
this.safeInt(footerRow.eq(5).text().trim().split('/')[1]) || 0,
|
this.safeInt(footerRow.eq(5).text().trim().split("/")[1]) || 0,
|
||||||
threePtMade:
|
threePtMade:
|
||||||
this.safeInt(footerRow.eq(6).text().trim().split('/')[0]) || 0,
|
this.safeInt(footerRow.eq(6).text().trim().split("/")[0]) || 0,
|
||||||
threePtAttempted:
|
threePtAttempted:
|
||||||
this.safeInt(footerRow.eq(6).text().trim().split('/')[1]) || 0,
|
this.safeInt(footerRow.eq(6).text().trim().split("/")[1]) || 0,
|
||||||
ftMade: this.safeInt(footerRow.eq(7).text().trim().split('/')[0]) || 0,
|
ftMade: this.safeInt(footerRow.eq(7).text().trim().split("/")[0]) || 0,
|
||||||
ftAttempted:
|
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,
|
fouls: this.safeInt(footerRow.eq(8).text().trim()) || 0,
|
||||||
blocks: this.safeInt(footerRow.eq(9).text().trim()) || 0,
|
blocks: this.safeInt(footerRow.eq(9).text().trim()) || 0,
|
||||||
steals: this.safeInt(footerRow.eq(10).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
|
// For HTML pages, we DON'T send X-Requested-With header
|
||||||
const response = await this.axios.get(url, {
|
const response = await this.axios.get(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': DEFAULT_HEADERS['User-Agent'],
|
"User-Agent": DEFAULT_HEADERS["User-Agent"],
|
||||||
Referer: DEFAULT_HEADERS['Referer'],
|
Referer: DEFAULT_HEADERS["Referer"],
|
||||||
'Accept-Language': DEFAULT_HEADERS['Accept-Language'],
|
"Accept-Language": DEFAULT_HEADERS["Accept-Language"],
|
||||||
Accept:
|
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!
|
// NO X-Requested-With for HTML pages!
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -507,8 +507,8 @@ export class FeederScraperService {
|
|||||||
const response = await this.axios.get(url, {
|
const response = await this.axios.get(url, {
|
||||||
params: { matchId },
|
params: { matchId },
|
||||||
headers: {
|
headers: {
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
'User-Agent': DEFAULT_HEADERS['User-Agent'],
|
"User-Agent": DEFAULT_HEADERS["User-Agent"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -532,12 +532,12 @@ export class FeederScraperService {
|
|||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
const rows = $(
|
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;
|
if (rows.length < 2) return null;
|
||||||
|
|
||||||
const parseRow = (row: any) => {
|
const parseRow = (row: any) => {
|
||||||
const cols = $(row).find('td');
|
const cols = $(row).find("td");
|
||||||
// Format: TeamName, Q1, Q2, Q3, Q4, Final
|
// Format: TeamName, Q1, Q2, Q3, Q4, Final
|
||||||
// Values are inside .widget-basketball-match-details-header__score-part (just the quarter score)
|
// Values are inside .widget-basketball-match-details-header__score-part (just the quarter score)
|
||||||
// or direct text if simple table.
|
// or direct text if simple table.
|
||||||
@@ -545,7 +545,7 @@ export class FeederScraperService {
|
|||||||
const getScore = (index: number) => {
|
const getScore = (index: number) => {
|
||||||
const cell = cols.eq(index);
|
const cell = cols.eq(index);
|
||||||
const part = cell.find(
|
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();
|
const val = part.length ? part.text() : cell.text();
|
||||||
return this.safeInt(val.trim());
|
return this.safeInt(val.trim());
|
||||||
@@ -580,10 +580,10 @@ export class FeederScraperService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<IddaaMarketsHtmlResponse>(url, {
|
const response = await this.axios.get<IddaaMarketsHtmlResponse>(url, {
|
||||||
params: { template: 'all' },
|
params: { template: "all" },
|
||||||
headers: {
|
headers: {
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
'User-Agent': DEFAULT_HEADERS['User-Agent'],
|
"User-Agent": DEFAULT_HEADERS["User-Agent"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -602,7 +602,7 @@ export class FeederScraperService {
|
|||||||
|
|
||||||
extractPlayerIdFromUrl(url: string | undefined): string | null {
|
extractPlayerIdFromUrl(url: string | undefined): string | null {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
const parts = url.split('/');
|
const parts = url.split("/");
|
||||||
return parts[parts.length - 1] || null;
|
return parts[parts.length - 1] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,12 +620,12 @@ export class FeederScraperService {
|
|||||||
try {
|
try {
|
||||||
const response = await this.axios.get(url, {
|
const response = await this.axios.get(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent':
|
"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',
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
Accept:
|
Accept:
|
||||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
"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',
|
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||||
Referer: 'https://www.mackolik.com',
|
Referer: "https://www.mackolik.com",
|
||||||
},
|
},
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
@@ -652,24 +652,24 @@ export class FeederScraperService {
|
|||||||
$: cheerio.CheerioAPI,
|
$: cheerio.CheerioAPI,
|
||||||
teamIndex: number,
|
teamIndex: number,
|
||||||
): SidelinedTeamData {
|
): SidelinedTeamData {
|
||||||
const sidelinedWidgets = $('.widget-sidelined-players');
|
const sidelinedWidgets = $(".widget-sidelined-players");
|
||||||
|
|
||||||
if (sidelinedWidgets.length <= teamIndex) {
|
if (sidelinedWidgets.length <= teamIndex) {
|
||||||
return { teamName: '', teamId: '', totalSidelined: 0, players: [] };
|
return { teamName: "", teamId: "", totalSidelined: 0, players: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const widget = sidelinedWidgets.eq(teamIndex);
|
const widget = sidelinedWidgets.eq(teamIndex);
|
||||||
|
|
||||||
const teamCrest = widget.find('.widget-sidelined-players__header-crest');
|
const teamCrest = widget.find(".widget-sidelined-players__header-crest");
|
||||||
const teamCrestSrc = teamCrest.attr('src') || '';
|
const teamCrestSrc = teamCrest.attr("src") || "";
|
||||||
const teamId = teamCrestSrc.split('/').pop() || '';
|
const teamId = teamCrestSrc.split("/").pop() || "";
|
||||||
const teamName = widget
|
const teamName = widget
|
||||||
.find('.widget-sidelined-players__header-text')
|
.find(".widget-sidelined-players__header-text")
|
||||||
.text()
|
.text()
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const players: SidelinedPlayer[] = [];
|
const players: SidelinedPlayer[] = [];
|
||||||
widget.find('.widget-sidelined-players__item').each((_, element) => {
|
widget.find(".widget-sidelined-players__item").each((_, element) => {
|
||||||
const playerData = this._parsePlayerItem($, $(element));
|
const playerData = this._parsePlayerItem($, $(element));
|
||||||
if (playerData) {
|
if (playerData) {
|
||||||
players.push(playerData);
|
players.push(playerData);
|
||||||
@@ -689,44 +689,44 @@ export class FeederScraperService {
|
|||||||
$item: cheerio.Cheerio<any>,
|
$item: cheerio.Cheerio<any>,
|
||||||
): SidelinedPlayer | null {
|
): SidelinedPlayer | null {
|
||||||
try {
|
try {
|
||||||
const nameElem = $item.find('.widget-sidelined-players__name');
|
const nameElem = $item.find(".widget-sidelined-players__name");
|
||||||
const playerName = nameElem.text().trim();
|
const playerName = nameElem.text().trim();
|
||||||
const playerUrl = nameElem.attr('href') || '';
|
const playerUrl = nameElem.attr("href") || "";
|
||||||
const playerId = playerUrl.split('/').pop() || '';
|
const playerId = playerUrl.split("/").pop() || "";
|
||||||
|
|
||||||
const positionElem = $item.find('.widget-sidelined-players__position');
|
const positionElem = $item.find(".widget-sidelined-players__position");
|
||||||
const position = positionElem.attr('title') || '';
|
const position = positionElem.attr("title") || "";
|
||||||
const positionShort = positionElem.text().trim();
|
const positionShort = positionElem.text().trim();
|
||||||
|
|
||||||
const reasonImg = $item.find('.widget-sidelined-players__reason img');
|
const reasonImg = $item.find(".widget-sidelined-players__reason img");
|
||||||
const reasonIcon = reasonImg.attr('src') || '';
|
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)
|
// 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 =
|
const matchesMissedText =
|
||||||
numbers.length > 0 ? numbers.eq(0).text().trim() : '';
|
numbers.length > 0 ? numbers.eq(0).text().trim() : "";
|
||||||
const matchesMissed = matchesMissedText
|
const matchesMissed = matchesMissedText
|
||||||
? parseInt(matchesMissedText, 10)
|
? parseInt(matchesMissedText, 10)
|
||||||
: null;
|
: 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 average = averageText ? parseInt(averageText, 10) : null;
|
||||||
|
|
||||||
const description = $item
|
const description = $item
|
||||||
.find('.widget-sidelined-players__value')
|
.find(".widget-sidelined-players__value")
|
||||||
.text()
|
.text()
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const type = reasonIcon.includes('shortage_1.png')
|
const type = reasonIcon.includes("shortage_1.png")
|
||||||
? 'injury'
|
? "injury"
|
||||||
: reasonIcon.includes('suspension')
|
: reasonIcon.includes("suspension")
|
||||||
? 'suspension'
|
? "suspension"
|
||||||
: 'other';
|
: "other";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
playerId,
|
playerId,
|
||||||
playerName,
|
playerName,
|
||||||
playerUrl: playerUrl.startsWith('http')
|
playerUrl: playerUrl.startsWith("http")
|
||||||
? playerUrl
|
? playerUrl
|
||||||
: `https://www.mackolik.com${playerUrl}`,
|
: `https://www.mackolik.com${playerUrl}`,
|
||||||
position,
|
position,
|
||||||
@@ -735,7 +735,7 @@ export class FeederScraperService {
|
|||||||
description,
|
description,
|
||||||
matchesMissed: isNaN(matchesMissed as number) ? null : matchesMissed,
|
matchesMissed: isNaN(matchesMissed as number) ? null : matchesMissed,
|
||||||
average: isNaN(average as number) ? null : average,
|
average: isNaN(average as number) ? null : average,
|
||||||
reasonIcon: reasonIcon.startsWith('http')
|
reasonIcon: reasonIcon.startsWith("http")
|
||||||
? reasonIcon
|
? reasonIcon
|
||||||
: `https://www.mackolik.com${reasonIcon}`, // Keep safer URL construction but stick closer to logic
|
: `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
|
* Transforms raw API data into database-ready formats
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from "cheerio";
|
||||||
import {
|
import {
|
||||||
RawKeyEvent,
|
RawKeyEvent,
|
||||||
TransformedEvent,
|
TransformedEvent,
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
GameStatsResponse,
|
GameStatsResponse,
|
||||||
DbEventPayload,
|
DbEventPayload,
|
||||||
DbMarketPayload,
|
DbMarketPayload,
|
||||||
} from './feeder.types';
|
} from "./feeder.types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FeederTransformerService {
|
export class FeederTransformerService {
|
||||||
@@ -28,7 +28,7 @@ export class FeederTransformerService {
|
|||||||
// HELPER FUNCTIONS
|
// HELPER FUNCTIONS
|
||||||
// ============================================
|
// ============================================
|
||||||
private safeString(value: any): string | null {
|
private safeString(value: any): string | null {
|
||||||
return value === null || value === undefined || value === ''
|
return value === null || value === undefined || value === ""
|
||||||
? null
|
? null
|
||||||
: String(value);
|
: String(value);
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ export class FeederTransformerService {
|
|||||||
|
|
||||||
private extractPlayerIdFromUrl(url: string | undefined): string | null {
|
private extractPlayerIdFromUrl(url: string | undefined): string | null {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
const parts = url.split('/');
|
const parts = url.split("/");
|
||||||
return parts[parts.length - 1] || null;
|
return parts[parts.length - 1] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export class FeederTransformerService {
|
|||||||
matchId: string,
|
matchId: string,
|
||||||
): TransformedEvent[] {
|
): TransformedEvent[] {
|
||||||
return rawEvents.map((e) => {
|
return rawEvents.map((e) => {
|
||||||
const playerId = this.extractPlayerIdFromUrl(e.playerUrl) || '';
|
const playerId = this.extractPlayerIdFromUrl(e.playerUrl) || "";
|
||||||
const assistPlayerId = e.assistPlayerUrl
|
const assistPlayerId = e.assistPlayerUrl
|
||||||
? this.extractPlayerIdFromUrl(e.assistPlayerUrl)
|
? this.extractPlayerIdFromUrl(e.assistPlayerUrl)
|
||||||
: null;
|
: null;
|
||||||
@@ -68,16 +68,16 @@ export class FeederTransformerService {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Determine event type
|
// Determine event type
|
||||||
let eventType: 'goal' | 'card' | 'substitute' | 'other' = 'other';
|
let eventType: "goal" | "card" | "substitute" | "other" = "other";
|
||||||
if (e.type === 'goal') eventType = 'goal';
|
if (e.type === "goal") eventType = "goal";
|
||||||
else if (e.type === 'card') eventType = 'card';
|
else if (e.type === "card") eventType = "card";
|
||||||
else if (e.type === 'substitute') eventType = 'substitute';
|
else if (e.type === "substitute") eventType = "substitute";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
matchId,
|
matchId,
|
||||||
playerId,
|
playerId,
|
||||||
playerName: e.playerName,
|
playerName: e.playerName,
|
||||||
teamId: e.position === 'home' ? homeTeamId : awayTeamId,
|
teamId: e.position === "home" ? homeTeamId : awayTeamId,
|
||||||
eventType,
|
eventType,
|
||||||
eventSubtype: e.subType || null,
|
eventSubtype: e.subType || null,
|
||||||
timeMinute: e.timeMin,
|
timeMinute: e.timeMin,
|
||||||
@@ -136,7 +136,7 @@ export class FeederTransformerService {
|
|||||||
// GAME STATS TRANSFORMER
|
// GAME STATS TRANSFORMER
|
||||||
// ============================================
|
// ============================================
|
||||||
transformGameStats(
|
transformGameStats(
|
||||||
data: GameStatsResponse['data'] | null,
|
data: GameStatsResponse["data"] | null,
|
||||||
): TransformedMatchStats | null {
|
): TransformedMatchStats | null {
|
||||||
if (!data || !data.home) return null;
|
if (!data || !data.home) return null;
|
||||||
|
|
||||||
@@ -173,20 +173,20 @@ export class FeederTransformerService {
|
|||||||
// MATCH STATE TO STATUS MAPPER
|
// MATCH STATE TO STATUS MAPPER
|
||||||
// ============================================
|
// ============================================
|
||||||
mapMatchStateToStatus(state: MatchState | undefined): string {
|
mapMatchStateToStatus(state: MatchState | undefined): string {
|
||||||
if (!state) return 'NS';
|
if (!state) return "NS";
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'postGame':
|
case "postGame":
|
||||||
case 'post':
|
case "post":
|
||||||
return 'FT';
|
return "FT";
|
||||||
case 'preGame':
|
case "preGame":
|
||||||
case 'pre':
|
case "pre":
|
||||||
return 'NS';
|
return "NS";
|
||||||
case 'live':
|
case "live":
|
||||||
case 'liveGame':
|
case "liveGame":
|
||||||
return 'LIVE';
|
return "LIVE";
|
||||||
default:
|
default:
|
||||||
return 'NS';
|
return "NS";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,28 +200,28 @@ export class FeederTransformerService {
|
|||||||
const officials: MatchOfficial[] = [];
|
const officials: MatchOfficial[] = [];
|
||||||
|
|
||||||
// Try standard officials component
|
// Try standard officials component
|
||||||
$('.p0c-match-officials__official-list-item').each((_, elem) => {
|
$(".p0c-match-officials__official-list-item").each((_, elem) => {
|
||||||
const name = $(elem)
|
const name = $(elem)
|
||||||
.find('.p0c-match-officials__official-name')
|
.find(".p0c-match-officials__official-name")
|
||||||
.text()
|
.text()
|
||||||
.trim();
|
.trim();
|
||||||
const role = $(elem)
|
const role = $(elem)
|
||||||
.find('.p0c-match-officials__official-group-title')
|
.find(".p0c-match-officials__official-group-title")
|
||||||
.text()
|
.text()
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
officials.push({ name, role: role || 'Referee' });
|
officials.push({ name, role: role || "Referee" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fallback: look for referee info in match info section
|
// Fallback: look for referee info in match info section
|
||||||
if (officials.length === 0) {
|
if (officials.length === 0) {
|
||||||
// Try alternative selectors
|
// 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();
|
const name = $(elem).text().trim();
|
||||||
if (name) {
|
if (name) {
|
||||||
officials.push({ name, role: 'Referee' });
|
officials.push({ name, role: "Referee" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -331,8 +331,8 @@ export class FeederTransformerService {
|
|||||||
(
|
(
|
||||||
e,
|
e,
|
||||||
): e is TransformedEvent & {
|
): e is TransformedEvent & {
|
||||||
eventType: 'goal' | 'card' | 'substitute';
|
eventType: "goal" | "card" | "substitute";
|
||||||
} => e.eventType !== 'other' && !!e.playerId,
|
} => e.eventType !== "other" && !!e.playerId,
|
||||||
)
|
)
|
||||||
.map((e) => ({
|
.map((e) => ({
|
||||||
match_id: e.matchId,
|
match_id: e.matchId,
|
||||||
@@ -354,6 +354,6 @@ export class FeederTransformerService {
|
|||||||
// BASKETBALL PLAYER ID GENERATOR
|
// BASKETBALL PLAYER ID GENERATOR
|
||||||
// ============================================
|
// ============================================
|
||||||
generateBasketballPlayerId(teamId: string, playerName: string): string {
|
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
|
* Feeder Module - Senior Level Implementation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { FeederService } from './feeder.service';
|
import { FeederService } from "./feeder.service";
|
||||||
import { FeederScraperService } from './feeder-scraper.service';
|
import { FeederScraperService } from "./feeder-scraper.service";
|
||||||
import { FeederTransformerService } from './feeder-transformer.service';
|
import { FeederTransformerService } from "./feeder-transformer.service";
|
||||||
import { FeederPersistenceService } from './feeder-persistence.service';
|
import { FeederPersistenceService } from "./feeder-persistence.service";
|
||||||
import { DatabaseModule } from '../../database/database.module';
|
import { DatabaseModule } from "../../database/database.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule],
|
imports: [DatabaseModule],
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
* Main orchestration service for historical data scanning
|
* Main orchestration service for historical data scanning
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { FeederScraperService } from './feeder-scraper.service';
|
import { FeederScraperService } from "./feeder-scraper.service";
|
||||||
import { FeederTransformerService } from './feeder-transformer.service';
|
import { FeederTransformerService } from "./feeder-transformer.service";
|
||||||
import { FeederPersistenceService } from './feeder-persistence.service';
|
import { FeederPersistenceService } from "./feeder-persistence.service";
|
||||||
import {
|
import {
|
||||||
Sport,
|
Sport,
|
||||||
MatchSummary,
|
MatchSummary,
|
||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
ParsedMarket,
|
ParsedMarket,
|
||||||
DbEventPayload,
|
DbEventPayload,
|
||||||
DbMarketPayload,
|
DbMarketPayload,
|
||||||
} from './feeder.types';
|
} from "./feeder.types";
|
||||||
|
|
||||||
interface ProcessDateOptions {
|
interface ProcessDateOptions {
|
||||||
onlyCompletedMatches?: boolean;
|
onlyCompletedMatches?: boolean;
|
||||||
@@ -37,10 +37,10 @@ export class FeederService {
|
|||||||
// Configuration - Adjust these based on rate limiting behavior
|
// Configuration - Adjust these based on rate limiting behavior
|
||||||
private readonly CONCURRENCY_LIMIT = 20; // Increased for maximum speed on EC2
|
private readonly CONCURRENCY_LIMIT = 20; // Increased for maximum speed on EC2
|
||||||
private readonly REQUEST_DELAY_MS = 50; // Minimal delay to respect basics
|
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 HISTORICAL_START_DATE = "2023-06-01"; // 2 years of data
|
||||||
private readonly SPORTS: Sport[] = ['football', 'basketball'];
|
private readonly SPORTS: Sport[] = ["football", "basketball"];
|
||||||
private readonly MAX_RETRIES = 50;
|
private readonly MAX_RETRIES = 50;
|
||||||
private readonly DAILY_SYNC_TIME_ZONE = 'Europe/Istanbul';
|
private readonly DAILY_SYNC_TIME_ZONE = "Europe/Istanbul";
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly scraperService: FeederScraperService,
|
private readonly scraperService: FeederScraperService,
|
||||||
@@ -56,38 +56,38 @@ export class FeederService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getYesterdayDateString(timeZone: string): string {
|
private getYesterdayDateString(timeZone: string): string {
|
||||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
const formatter = new Intl.DateTimeFormat("en-CA", {
|
||||||
timeZone,
|
timeZone,
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
month: '2-digit',
|
month: "2-digit",
|
||||||
day: '2-digit',
|
day: "2-digit",
|
||||||
});
|
});
|
||||||
const parts = formatter.formatToParts(new Date());
|
const parts = formatter.formatToParts(new Date());
|
||||||
const year = Number(parts.find((part) => part.type === 'year')?.value);
|
const year = Number(parts.find((part) => part.type === "year")?.value);
|
||||||
const month = Number(parts.find((part) => part.type === 'month')?.value);
|
const month = Number(parts.find((part) => part.type === "month")?.value);
|
||||||
const day = Number(parts.find((part) => part.type === 'day')?.value);
|
const day = Number(parts.find((part) => part.type === "day")?.value);
|
||||||
|
|
||||||
const tzMidnightUtc = new Date(Date.UTC(year, month - 1, day));
|
const tzMidnightUtc = new Date(Date.UTC(year, month - 1, day));
|
||||||
tzMidnightUtc.setUTCDate(tzMidnightUtc.getUTCDate() - 1);
|
tzMidnightUtc.setUTCDate(tzMidnightUtc.getUTCDate() - 1);
|
||||||
|
|
||||||
return tzMidnightUtc.toISOString().split('T')[0];
|
return tzMidnightUtc.toISOString().split("T")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTimeZoneOffsetMs(date: Date, timeZone: string): number {
|
private getTimeZoneOffsetMs(date: Date, timeZone: string): number {
|
||||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||||
timeZone,
|
timeZone,
|
||||||
timeZoneName: 'shortOffset',
|
timeZoneName: "shortOffset",
|
||||||
});
|
});
|
||||||
const offsetLabel =
|
const offsetLabel =
|
||||||
formatter.formatToParts(date).find((part) => part.type === 'timeZoneName')
|
formatter.formatToParts(date).find((part) => part.type === "timeZoneName")
|
||||||
?.value || 'GMT+0';
|
?.value || "GMT+0";
|
||||||
|
|
||||||
const match = offsetLabel.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/);
|
const match = offsetLabel.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/);
|
||||||
if (!match) return 0;
|
if (!match) return 0;
|
||||||
|
|
||||||
const sign = match[1] === '-' ? -1 : 1;
|
const sign = match[1] === "-" ? -1 : 1;
|
||||||
const hours = Number(match[2] || '0');
|
const hours = Number(match[2] || "0");
|
||||||
const minutes = Number(match[3] || '0');
|
const minutes = Number(match[3] || "0");
|
||||||
|
|
||||||
return sign * (hours * 60 + minutes) * 60 * 1000;
|
return sign * (hours * 60 + minutes) * 60 * 1000;
|
||||||
}
|
}
|
||||||
@@ -96,17 +96,14 @@ export class FeederService {
|
|||||||
dateString: string,
|
dateString: string,
|
||||||
timeZone: string,
|
timeZone: string,
|
||||||
): { startTs: number; endTs: number } {
|
): { 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 startGuess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
|
||||||
const nextDayGuess = new Date(
|
const nextDayGuess = new Date(Date.UTC(year, month - 1, day + 1, 0, 0, 0));
|
||||||
Date.UTC(year, month - 1, day + 1, 0, 0, 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
const startOffsetMs = this.getTimeZoneOffsetMs(startGuess, timeZone);
|
const startOffsetMs = this.getTimeZoneOffsetMs(startGuess, timeZone);
|
||||||
const nextDayOffsetMs = this.getTimeZoneOffsetMs(nextDayGuess, timeZone);
|
const nextDayOffsetMs = this.getTimeZoneOffsetMs(nextDayGuess, timeZone);
|
||||||
|
|
||||||
const startMs =
|
const startMs = Date.UTC(year, month - 1, day, 0, 0, 0) - startOffsetMs;
|
||||||
Date.UTC(year, month - 1, day, 0, 0, 0) - startOffsetMs;
|
|
||||||
const nextDayStartMs =
|
const nextDayStartMs =
|
||||||
Date.UTC(year, month - 1, day + 1, 0, 0, 0) - nextDayOffsetMs;
|
Date.UTC(year, month - 1, day + 1, 0, 0, 0) - nextDayOffsetMs;
|
||||||
|
|
||||||
@@ -117,35 +114,39 @@ export class FeederService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private parseScoreValue(value: unknown): number | null {
|
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);
|
const parsed = Number(value);
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isCompletedMatchSummary(match: MatchSummary): boolean {
|
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()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
const normalizedStatus = String(match.status || '')
|
const normalizedStatus = String(match.status || "")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
const normalizedSubstate = String(match.substate || '')
|
const normalizedSubstate = String(match.substate || "")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|
||||||
if (['postgame', 'post'].includes(normalizedState)) return true;
|
if (["postgame", "post"].includes(normalizedState)) return true;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
['played', 'finished', 'ft', 'afterpenalties', 'penalties'].includes(
|
["played", "finished", "ft", "afterpenalties", "penalties"].includes(
|
||||||
normalizedStatus,
|
normalizedStatus,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['postgame', 'post', 'played', 'finished', 'ft'].includes(normalizedSubstate)) {
|
if (
|
||||||
|
["postgame", "post", "played", "finished", "ft"].includes(
|
||||||
|
normalizedSubstate,
|
||||||
|
)
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +168,7 @@ export class FeederService {
|
|||||||
targetLeagueIds: string[] = [],
|
targetLeagueIds: string[] = [],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(
|
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) {
|
for (const sport of sports) {
|
||||||
@@ -191,7 +192,7 @@ export class FeederService {
|
|||||||
targetLeagueIds: string[] = [], // NEW: Optional league filter
|
targetLeagueIds: string[] = [], // NEW: Optional league filter
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(
|
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);
|
const startDate = new Date(startDateStr);
|
||||||
@@ -201,7 +202,7 @@ export class FeederService {
|
|||||||
// writing to live_matches. Historical scan should only fill matches table.
|
// writing to live_matches. Historical scan should only fill matches table.
|
||||||
endDate.setDate(endDate.getDate() - 2);
|
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;
|
let currentDate: Date | null = null;
|
||||||
|
|
||||||
// Resume from saved state
|
// 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
|
// For reverse scan, we resume from the *next* day backwards, i.e., resumeDate - 1 day
|
||||||
currentDate.setDate(currentDate.getDate() - 1);
|
currentDate.setDate(currentDate.getDate() - 1);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`📍 Resuming from: ${currentDate.toISOString().split('T')[0]}`,
|
`📍 Resuming from: ${currentDate.toISOString().split("T")[0]}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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)
|
// Initialize currentDate to endDate if not resuming (or if resume failed)
|
||||||
@@ -231,7 +232,7 @@ export class FeederService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
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;
|
let processedDays = 0;
|
||||||
@@ -239,7 +240,7 @@ export class FeederService {
|
|||||||
|
|
||||||
// REVERSE LOOP: Iterate while currentDate is greater than or equal to startDate
|
// REVERSE LOOP: Iterate while currentDate is greater than or equal to startDate
|
||||||
while (currentDate >= startDate) {
|
while (currentDate >= startDate) {
|
||||||
const dateString = currentDate.toISOString().split('T')[0];
|
const dateString = currentDate.toISOString().split("T")[0];
|
||||||
|
|
||||||
for (const sport of sports) {
|
for (const sport of sports) {
|
||||||
await this.processDate(dateString, sport, targetLeagueIds);
|
await this.processDate(dateString, sport, targetLeagueIds);
|
||||||
@@ -278,7 +279,7 @@ export class FeederService {
|
|||||||
currentDate.setDate(currentDate.getDate() - 1);
|
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
|
break; // Success, exit loop
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const is502 =
|
const is502 =
|
||||||
e.message?.includes('502') ||
|
e.message?.includes("502") ||
|
||||||
e.response?.status === 502 ||
|
e.response?.status === 502 ||
|
||||||
e.message?.includes('Bad Gateway');
|
e.message?.includes("Bad Gateway");
|
||||||
|
|
||||||
if (is502 && i < 2) {
|
if (is502 && i < 2) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -341,10 +342,7 @@ export class FeederService {
|
|||||||
// regardless of the matchDate query parameter. We must filter by mstUtc
|
// regardless of the matchDate query parameter. We must filter by mstUtc
|
||||||
// to ensure we only process matches that actually belong to the target date.
|
// to ensure we only process matches that actually belong to the target date.
|
||||||
const { startTs: targetDateStartTs, endTs: targetDateEndTs } =
|
const { startTs: targetDateStartTs, endTs: targetDateEndTs } =
|
||||||
this.getDayBoundsForTimeZone(
|
this.getDayBoundsForTimeZone(dateString, this.DAILY_SYNC_TIME_ZONE);
|
||||||
dateString,
|
|
||||||
this.DAILY_SYNC_TIME_ZONE,
|
|
||||||
);
|
|
||||||
|
|
||||||
const dateFilteredMatches = allMatches.filter((m) => {
|
const dateFilteredMatches = allMatches.filter((m) => {
|
||||||
const matchTs = m.mstUtc;
|
const matchTs = m.mstUtc;
|
||||||
@@ -518,14 +516,14 @@ export class FeederService {
|
|||||||
// ============================================
|
// ============================================
|
||||||
async refreshMatch(
|
async refreshMatch(
|
||||||
matchId: string,
|
matchId: string,
|
||||||
scope: 'all' | 'lineups' | 'odds' = 'all',
|
scope: "all" | "lineups" | "odds" = "all",
|
||||||
): Promise<ProcessResult> {
|
): Promise<ProcessResult> {
|
||||||
this.logger.log(`🔄 Refreshing match (${scope}) for ${matchId}`);
|
this.logger.log(`🔄 Refreshing match (${scope}) for ${matchId}`);
|
||||||
|
|
||||||
const matchRecord = await this.persistenceService.getMatch(matchId);
|
const matchRecord = await this.persistenceService.getMatch(matchId);
|
||||||
if (!matchRecord) {
|
if (!matchRecord) {
|
||||||
this.logger.warn(`[${matchId}] Refresh failed: Match not in DB`);
|
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
|
// Construct MatchSummary from DB record
|
||||||
@@ -538,13 +536,13 @@ export class FeederService {
|
|||||||
iddaaCode: matchRecord.iddaaCode,
|
iddaaCode: matchRecord.iddaaCode,
|
||||||
homeTeam: {
|
homeTeam: {
|
||||||
id: matchRecord.homeTeamId,
|
id: matchRecord.homeTeamId,
|
||||||
name: matchRecord.homeTeam?.name || '',
|
name: matchRecord.homeTeam?.name || "",
|
||||||
slug: matchRecord.homeTeam?.slug || '',
|
slug: matchRecord.homeTeam?.slug || "",
|
||||||
},
|
},
|
||||||
awayTeam: {
|
awayTeam: {
|
||||||
id: matchRecord.awayTeamId,
|
id: matchRecord.awayTeamId,
|
||||||
name: matchRecord.awayTeam?.name || '',
|
name: matchRecord.awayTeam?.name || "",
|
||||||
slug: matchRecord.awayTeam?.slug || '',
|
slug: matchRecord.awayTeam?.slug || "",
|
||||||
},
|
},
|
||||||
score: {
|
score: {
|
||||||
home: matchRecord.scoreHome,
|
home: matchRecord.scoreHome,
|
||||||
@@ -555,9 +553,9 @@ export class FeederService {
|
|||||||
const dummyCompetitions: Record<string, Competition> = {
|
const dummyCompetitions: Record<string, Competition> = {
|
||||||
[summary.competitionId]: {
|
[summary.competitionId]: {
|
||||||
id: summary.competitionId,
|
id: summary.competitionId,
|
||||||
name: 'Unknown',
|
name: "Unknown",
|
||||||
competitionSlug: '',
|
competitionSlug: "",
|
||||||
country: { id: '', name: '' },
|
country: { id: "", name: "" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -583,7 +581,7 @@ export class FeederService {
|
|||||||
competitions: Record<string, Competition>,
|
competitions: Record<string, Competition>,
|
||||||
sport: Sport,
|
sport: Sport,
|
||||||
force: boolean = false,
|
force: boolean = false,
|
||||||
scope: 'all' | 'lineups' | 'odds' = 'all', // Add scope flag
|
scope: "all" | "lineups" | "odds" = "all", // Add scope flag
|
||||||
): Promise<ProcessResult> {
|
): Promise<ProcessResult> {
|
||||||
const matchId = matchSummary.id;
|
const matchId = matchSummary.id;
|
||||||
const homeTeamId = matchSummary.homeTeam?.id;
|
const homeTeamId = matchSummary.homeTeam?.id;
|
||||||
@@ -595,7 +593,7 @@ export class FeederService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip postponed matches (ERT = Erteledendi)
|
// Skip postponed matches (ERT = Erteledendi)
|
||||||
if (matchSummary.statusBoxContent === 'ERT') {
|
if (matchSummary.statusBoxContent === "ERT") {
|
||||||
this.logger.debug(`[${matchId}] Skipped: Postponed match (ERT)`);
|
this.logger.debug(`[${matchId}] Skipped: Postponed match (ERT)`);
|
||||||
return { success: false, retryable: false };
|
return { success: false, retryable: false };
|
||||||
}
|
}
|
||||||
@@ -615,9 +613,9 @@ export class FeederService {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const is502 =
|
const is502 =
|
||||||
e.message?.includes('502') ||
|
e.message?.includes("502") ||
|
||||||
e.response?.status === 502 ||
|
e.response?.status === 502 ||
|
||||||
e.message?.includes('Bad Gateway');
|
e.message?.includes("Bad Gateway");
|
||||||
|
|
||||||
if (i === retries - 1) throw e; // Last attempt failed
|
if (i === retries - 1) throw e; // Last attempt failed
|
||||||
|
|
||||||
@@ -661,44 +659,44 @@ export class FeederService {
|
|||||||
|
|
||||||
// 1. Fetch Match Header (score, status)
|
// 1. Fetch Match Header (score, status)
|
||||||
let headerData: ParsedMatchHeader | null = null;
|
let headerData: ParsedMatchHeader | null = null;
|
||||||
if (scope === 'all') {
|
if (scope === "all") {
|
||||||
try {
|
try {
|
||||||
headerData = await fetchResilient('Header', () =>
|
headerData = await fetchResilient("Header", () =>
|
||||||
this.scraperService.fetchMatchHeader(matchId),
|
this.scraperService.fetchMatchHeader(matchId),
|
||||||
);
|
);
|
||||||
} catch (e: any) {
|
} 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}`);
|
this.logger.warn(`[${matchId}] Header fetch failed: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Sport-specific data fetching
|
// 2. Sport-specific data fetching
|
||||||
if (sport === 'basketball') {
|
if (sport === "basketball") {
|
||||||
// Basketball: Box Score (Always if all or lineups)
|
// Basketball: Box Score (Always if all or lineups)
|
||||||
if (scope === 'all' || scope === 'lineups') {
|
if (scope === "all" || scope === "lineups") {
|
||||||
try {
|
try {
|
||||||
const boxData = await fetchResilient('BoxScore', () =>
|
const boxData = await fetchResilient("BoxScore", () =>
|
||||||
this.scraperService.fetchBasketballBoxScore(matchId),
|
this.scraperService.fetchBasketballBoxScore(matchId),
|
||||||
);
|
);
|
||||||
if (boxData) {
|
if (boxData) {
|
||||||
const homeParsed = this.scraperService.parseBasketballBoxScore(
|
const homeParsed = this.scraperService.parseBasketballBoxScore(
|
||||||
boxData.views?.home?.html || '',
|
boxData.views?.home?.html || "",
|
||||||
);
|
);
|
||||||
const awayParsed = this.scraperService.parseBasketballBoxScore(
|
const awayParsed = this.scraperService.parseBasketballBoxScore(
|
||||||
boxData.views?.away?.html || '',
|
boxData.views?.away?.html || "",
|
||||||
);
|
);
|
||||||
|
|
||||||
basketballTeamStats =
|
basketballTeamStats =
|
||||||
scope === 'all'
|
scope === "all"
|
||||||
? {
|
? {
|
||||||
home: homeParsed.teamTotals,
|
home: homeParsed.teamTotals,
|
||||||
away: awayParsed.teamTotals,
|
away: awayParsed.teamTotals,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (scope === 'all') {
|
if (scope === "all") {
|
||||||
try {
|
try {
|
||||||
const details = await fetchResilient('QuarterScores', () =>
|
const details = await fetchResilient("QuarterScores", () =>
|
||||||
this.scraperService.fetchBasketballDetailsHeader(matchId),
|
this.scraperService.fetchBasketballDetailsHeader(matchId),
|
||||||
);
|
);
|
||||||
if (details && basketballTeamStats) {
|
if (details && basketballTeamStats) {
|
||||||
@@ -712,7 +710,7 @@ export class FeederService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.message?.includes('502')) hasCriticalError = true;
|
if (e.message?.includes("502")) hasCriticalError = true;
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`[${matchId}] Quarter scores fetch failed: ${e.message}`,
|
`[${matchId}] Quarter scores fetch failed: ${e.message}`,
|
||||||
);
|
);
|
||||||
@@ -748,7 +746,7 @@ export class FeederService {
|
|||||||
processPlayers(awayParsed, awayTeamId);
|
processPlayers(awayParsed, awayTeamId);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} 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}`);
|
this.logger.warn(`[${matchId}] Box score failed: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -756,9 +754,9 @@ export class FeederService {
|
|||||||
// Football: Events, Lineups, Stats, Officials
|
// Football: Events, Lineups, Stats, Officials
|
||||||
|
|
||||||
// Key Events
|
// Key Events
|
||||||
if (scope === 'all') {
|
if (scope === "all") {
|
||||||
try {
|
try {
|
||||||
const eventsData = await fetchResilient('Events', () =>
|
const eventsData = await fetchResilient("Events", () =>
|
||||||
this.scraperService.fetchKeyEvents(matchId),
|
this.scraperService.fetchKeyEvents(matchId),
|
||||||
);
|
);
|
||||||
if (eventsData?.keyEvents) {
|
if (eventsData?.keyEvents) {
|
||||||
@@ -781,7 +779,7 @@ export class FeederService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} 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}`);
|
this.logger.warn(`[${matchId}] Events failed: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -850,20 +848,20 @@ export class FeederService {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Game Stats & Officials
|
// Game Stats & Officials
|
||||||
if (scope === 'all') {
|
if (scope === "all") {
|
||||||
try {
|
try {
|
||||||
const gameStats = await fetchResilient('Stats', () =>
|
const gameStats = await fetchResilient("Stats", () =>
|
||||||
this.scraperService.fetchGameStats(matchId),
|
this.scraperService.fetchGameStats(matchId),
|
||||||
);
|
);
|
||||||
stats = this.transformerService.transformGameStats(gameStats);
|
stats = this.transformerService.transformGameStats(gameStats);
|
||||||
} catch (e: any) {
|
} 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}`);
|
this.logger.warn(`[${matchId}] Stats failed: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Officials (from match page)
|
// Officials (from match page)
|
||||||
try {
|
try {
|
||||||
const matchPageHtml = await fetchResilient('Officials', () =>
|
const matchPageHtml = await fetchResilient("Officials", () =>
|
||||||
this.scraperService.fetchMatchPage(
|
this.scraperService.fetchMatchPage(
|
||||||
matchId,
|
matchId,
|
||||||
matchSummary.matchSlug,
|
matchSummary.matchSlug,
|
||||||
@@ -875,7 +873,7 @@ export class FeederService {
|
|||||||
this.transformerService.parseOfficials(matchPageHtml);
|
this.transformerService.parseOfficials(matchPageHtml);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} 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}`);
|
this.logger.warn(`[${matchId}] Officials failed: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -883,31 +881,31 @@ export class FeederService {
|
|||||||
|
|
||||||
// 3. Fetch Iddaa Odds (Always if all or odds)
|
// 3. Fetch Iddaa Odds (Always if all or odds)
|
||||||
let oddsArray: DbMarketPayload[] = [];
|
let oddsArray: DbMarketPayload[] = [];
|
||||||
if (scope === 'all' || scope === 'odds') {
|
if (scope === "all" || scope === "odds") {
|
||||||
try {
|
try {
|
||||||
let markets: ParsedMarket[] = [];
|
let markets: ParsedMarket[] = [];
|
||||||
if (sport === 'basketball') {
|
if (sport === "basketball") {
|
||||||
markets =
|
markets =
|
||||||
((await fetchResilient('BucketOdds', () =>
|
((await fetchResilient("BucketOdds", () =>
|
||||||
this.scraperService.fetchBasketballMarkets(matchId),
|
this.scraperService.fetchBasketballMarkets(matchId),
|
||||||
)) as ParsedMarket[]) || [];
|
)) as ParsedMarket[]) || [];
|
||||||
} else {
|
} else {
|
||||||
markets =
|
markets =
|
||||||
((await fetchResilient('IddaaOdds', () =>
|
((await fetchResilient("IddaaOdds", () =>
|
||||||
this.scraperService.fetchIddaaMarkets(matchId),
|
this.scraperService.fetchIddaaMarkets(matchId),
|
||||||
)) as ParsedMarket[]) || [];
|
)) as ParsedMarket[]) || [];
|
||||||
}
|
}
|
||||||
// Logic is same since structure is ParsedMarket[]
|
// Logic is same since structure is ParsedMarket[]
|
||||||
oddsArray = this.transformerService.transformIddaaMarkets(markets);
|
oddsArray = this.transformerService.transformIddaaMarkets(markets);
|
||||||
} catch (e: any) {
|
} 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}`);
|
this.logger.warn(`[${matchId}] Odds failed: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Persist to Database
|
// 4. Persist to Database
|
||||||
let saved = false;
|
let saved = false;
|
||||||
if (scope === 'lineups') {
|
if (scope === "lineups") {
|
||||||
saved = await this.persistenceService.saveLineups(
|
saved = await this.persistenceService.saveLineups(
|
||||||
matchId,
|
matchId,
|
||||||
playersMap,
|
playersMap,
|
||||||
@@ -915,7 +913,7 @@ export class FeederService {
|
|||||||
homeTeamId,
|
homeTeamId,
|
||||||
awayTeamId,
|
awayTeamId,
|
||||||
);
|
);
|
||||||
} else if (scope === 'odds') {
|
} else if (scope === "odds") {
|
||||||
saved = await this.persistenceService.saveOdds(matchId, oddsArray);
|
saved = await this.persistenceService.saveOdds(matchId, oddsArray);
|
||||||
} else {
|
} else {
|
||||||
// Full Update
|
// Full Update
|
||||||
@@ -962,12 +960,12 @@ export class FeederService {
|
|||||||
if (saved && hasCriticalError) {
|
if (saved && hasCriticalError) {
|
||||||
// Collect missing components
|
// Collect missing components
|
||||||
const missingParts: string[] = [];
|
const missingParts: string[] = [];
|
||||||
if (!stats) missingParts.push('Stats');
|
if (!stats) missingParts.push("Stats");
|
||||||
if (oddsArray.length === 0) missingParts.push('Odds');
|
if (oddsArray.length === 0) missingParts.push("Odds");
|
||||||
if (officialsData.length === 0) missingParts.push('Officials');
|
if (officialsData.length === 0) missingParts.push("Officials");
|
||||||
|
|
||||||
this.logger.warn(
|
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 };
|
return { success: false, retryable: true };
|
||||||
}
|
}
|
||||||
@@ -975,12 +973,12 @@ export class FeederService {
|
|||||||
return { success: saved, retryable: !saved };
|
return { success: saved, retryable: !saved };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const isRetryable =
|
const isRetryable =
|
||||||
error.message.includes('502') ||
|
error.message.includes("502") ||
|
||||||
error.message.includes('504') ||
|
error.message.includes("504") ||
|
||||||
error.message.includes('ECONNABORTED') ||
|
error.message.includes("ECONNABORTED") ||
|
||||||
error.message.includes('timeout') ||
|
error.message.includes("timeout") ||
|
||||||
error.message.includes('ETIMEDOUT') ||
|
error.message.includes("ETIMEDOUT") ||
|
||||||
error.message.includes('Unique constraint'); // Concurrency retry
|
error.message.includes("Unique constraint"); // Concurrency retry
|
||||||
|
|
||||||
if (isRetryable) {
|
if (isRetryable) {
|
||||||
this.logger.warn(`[${matchId}] ${error.message} - Will retry`);
|
this.logger.warn(`[${matchId}] ${error.message} - Will retry`);
|
||||||
|
|||||||
@@ -6,27 +6,27 @@
|
|||||||
// ============================================
|
// ============================================
|
||||||
// SPORT TYPES
|
// SPORT TYPES
|
||||||
// ============================================
|
// ============================================
|
||||||
export type Sport = 'football' | 'basketball';
|
export type Sport = "football" | "basketball";
|
||||||
|
|
||||||
export const SPORTS_CONFIG: Record<
|
export const SPORTS_CONFIG: Record<
|
||||||
Sport,
|
Sport,
|
||||||
{ sportParam: string; iddaaUrlPath: string }
|
{ sportParam: string; iddaaUrlPath: string }
|
||||||
> = {
|
> = {
|
||||||
football: { sportParam: 'Soccer', iddaaUrlPath: 'mac' },
|
football: { sportParam: "Soccer", iddaaUrlPath: "mac" },
|
||||||
basketball: { sportParam: 'Basketball', iddaaUrlPath: 'basketbol/mac' },
|
basketball: { sportParam: "Basketball", iddaaUrlPath: "basketbol/mac" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// MATCH STATUS TYPES
|
// MATCH STATUS TYPES
|
||||||
// ============================================
|
// ============================================
|
||||||
export type MatchStatus = 'Cancelled' | 'Played' | 'Playing' | 'Scheduled';
|
export type MatchStatus = "Cancelled" | "Played" | "Playing" | "Scheduled";
|
||||||
export type MatchState =
|
export type MatchState =
|
||||||
| 'preGame'
|
| "preGame"
|
||||||
| 'postGame'
|
| "postGame"
|
||||||
| 'live'
|
| "live"
|
||||||
| 'liveGame'
|
| "liveGame"
|
||||||
| 'pre'
|
| "pre"
|
||||||
| 'post';
|
| "post";
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// LIVESCORES API RESPONSE
|
// LIVESCORES API RESPONSE
|
||||||
@@ -115,9 +115,9 @@ export interface KeyEventsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RawKeyEvent {
|
export interface RawKeyEvent {
|
||||||
type: 'goal' | 'card' | 'substitute' | 'penalty-missed';
|
type: "goal" | "card" | "substitute" | "penalty-missed";
|
||||||
subType: 'goal' | 'penalty-goal' | 'yc' | 'rc' | 'pm' | 'ps' | null;
|
subType: "goal" | "penalty-goal" | "yc" | "rc" | "pm" | "ps" | null;
|
||||||
position: 'home' | 'away';
|
position: "home" | "away";
|
||||||
periodId: number; // 1 = 1st half, 2 = 2nd half
|
periodId: number; // 1 = 1st half, 2 = 2nd half
|
||||||
timeMin: string;
|
timeMin: string;
|
||||||
seconds: number | null;
|
seconds: number | null;
|
||||||
@@ -135,7 +135,7 @@ export interface TransformedEvent {
|
|||||||
playerId: string;
|
playerId: string;
|
||||||
playerName: string;
|
playerName: string;
|
||||||
teamId: string;
|
teamId: string;
|
||||||
eventType: 'goal' | 'card' | 'substitute' | 'other';
|
eventType: "goal" | "card" | "substitute" | "other";
|
||||||
eventSubtype: string | null;
|
eventSubtype: string | null;
|
||||||
timeMinute: string;
|
timeMinute: string;
|
||||||
timeSeconds: number | null;
|
timeSeconds: number | null;
|
||||||
@@ -145,7 +145,7 @@ export interface TransformedEvent {
|
|||||||
scoreAfter: string | null;
|
scoreAfter: string | null;
|
||||||
playerOutId: string | null;
|
playerOutId: string | null;
|
||||||
playerOutName: string | null;
|
playerOutName: string | null;
|
||||||
position: 'home' | 'away';
|
position: "home" | "away";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -170,18 +170,18 @@ export interface RawPlayerStats {
|
|||||||
personId: string;
|
personId: string;
|
||||||
matchName: string;
|
matchName: string;
|
||||||
shirtNumber: number | null;
|
shirtNumber: number | null;
|
||||||
position: 'goalkeeper' | 'defender' | 'midfielder' | 'striker' | 'Coach' | '';
|
position: "goalkeeper" | "defender" | "midfielder" | "striker" | "Coach" | "";
|
||||||
events: PlayerEvent[] | null;
|
events: PlayerEvent[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerEvent {
|
export interface PlayerEvent {
|
||||||
name:
|
name:
|
||||||
| 'goal'
|
| "goal"
|
||||||
| 'yellow-card'
|
| "yellow-card"
|
||||||
| 'red-card'
|
| "red-card"
|
||||||
| 'sub-off'
|
| "sub-off"
|
||||||
| 'sub-on'
|
| "sub-on"
|
||||||
| 'penalty-missed';
|
| "penalty-missed";
|
||||||
timeMin: string;
|
timeMin: string;
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
@@ -270,7 +270,7 @@ export interface IddaaMarket {
|
|||||||
export interface IddaaOutcome {
|
export interface IddaaOutcome {
|
||||||
outcome: string; // The odds value (e.g., "1.78")
|
outcome: string; // The odds value (e.g., "1.78")
|
||||||
handicap: string | null;
|
handicap: string | null;
|
||||||
state: 'active' | 'suspended';
|
state: "active" | "suspended";
|
||||||
label: string; // "1", "X", "2", "Alt", "Üst", etc.
|
label: string; // "1", "X", "2", "Alt", "Üst", etc.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +371,7 @@ export interface DbEventPayload {
|
|||||||
match_id: string;
|
match_id: string;
|
||||||
player_id: string;
|
player_id: string;
|
||||||
team_id: string;
|
team_id: string;
|
||||||
event_type: 'goal' | 'card' | 'substitute';
|
event_type: "goal" | "card" | "substitute";
|
||||||
event_subtype: string | null;
|
event_subtype: string | null;
|
||||||
time_minute: string;
|
time_minute: string;
|
||||||
time_seconds: number | null;
|
time_seconds: number | null;
|
||||||
@@ -379,7 +379,7 @@ export interface DbEventPayload {
|
|||||||
assist_player_id: string | null;
|
assist_player_id: string | null;
|
||||||
score_after: string | null;
|
score_after: string | null;
|
||||||
player_out_id: string | null;
|
player_out_id: string | null;
|
||||||
position: 'home' | 'away';
|
position: "home" | "away";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DbMarketSelectionPayload {
|
export interface DbMarketSelectionPayload {
|
||||||
@@ -402,74 +402,74 @@ export interface DbMarketPayload {
|
|||||||
// ============================================
|
// ============================================
|
||||||
export const MARKET_MAPPING: Record<string, string> = {
|
export const MARKET_MAPPING: Record<string, string> = {
|
||||||
// Ana Bahisler
|
// Ana Bahisler
|
||||||
'1': 'Maç Sonucu',
|
"1": "Maç Sonucu",
|
||||||
'3': 'Çifte Şans',
|
"3": "Çifte Şans",
|
||||||
'6-11': 'Handikaplı MS (0:1)',
|
"6-11": "Handikaplı MS (0:1)",
|
||||||
'6-22': 'Handikaplı MS (0:2)',
|
"6-22": "Handikaplı MS (0:2)",
|
||||||
'611': 'Handikaplı MS (1:0)',
|
"611": "Handikaplı MS (1:0)",
|
||||||
'622': 'Handikaplı MS (2:0)',
|
"622": "Handikaplı MS (2:0)",
|
||||||
'14': 'İlk Yarı / Maç Sonucu',
|
"14": "İlk Yarı / Maç Sonucu",
|
||||||
'15': 'Maç Skoru',
|
"15": "Maç Skoru",
|
||||||
|
|
||||||
// Gol Alt/Üst
|
// Gol Alt/Üst
|
||||||
'180.5': '0.5 Alt/Üst',
|
"180.5": "0.5 Alt/Üst",
|
||||||
'181.5': '1.5 Alt/Üst',
|
"181.5": "1.5 Alt/Üst",
|
||||||
'182.5': '2.5 Alt/Üst',
|
"182.5": "2.5 Alt/Üst",
|
||||||
'183.5': '3.5 Alt/Üst',
|
"183.5": "3.5 Alt/Üst",
|
||||||
'184.5': '4.5 Alt/Üst',
|
"184.5": "4.5 Alt/Üst",
|
||||||
'185.5': '5.5 Alt/Üst',
|
"185.5": "5.5 Alt/Üst",
|
||||||
|
|
||||||
// Diğer Gol Bahisleri
|
// Diğer Gol Bahisleri
|
||||||
'11': 'Karşılıklı Gol',
|
"11": "Karşılıklı Gol",
|
||||||
'12': 'Tek / Çift',
|
"12": "Tek / Çift",
|
||||||
'24': 'İlk Golü Kim Atar',
|
"24": "İlk Golü Kim Atar",
|
||||||
'26': 'Toplam Gol Aralığı',
|
"26": "Toplam Gol Aralığı",
|
||||||
'32': 'En Çok Gol Olacak Yarı',
|
"32": "En Çok Gol Olacak Yarı",
|
||||||
|
|
||||||
// Yarı Bahisleri
|
// Yarı Bahisleri
|
||||||
'4': '1. Yarı Sonucu',
|
"4": "1. Yarı Sonucu",
|
||||||
'5': '1. Yarı Çifte Şans',
|
"5": "1. Yarı Çifte Şans",
|
||||||
'54': '2. Yarı Sonucu',
|
"54": "2. Yarı Sonucu",
|
||||||
'190.5': '1. Yarı 0.5 Alt/Üst',
|
"190.5": "1. Yarı 0.5 Alt/Üst",
|
||||||
'191.5': '1. Yarı 1.5 Alt/Üst',
|
"191.5": "1. Yarı 1.5 Alt/Üst",
|
||||||
'192.5': '1. Yarı 2.5 Alt/Üst',
|
"192.5": "1. Yarı 2.5 Alt/Üst",
|
||||||
'39': '1. Yarı Karşılıklı Gol',
|
"39": "1. Yarı Karşılıklı Gol",
|
||||||
|
|
||||||
// Takım Bahisleri
|
// Takım Bahisleri
|
||||||
'280.5': 'Ev Sahibi 0.5 Alt/Üst',
|
"280.5": "Ev Sahibi 0.5 Alt/Üst",
|
||||||
'281.5': 'Ev Sahibi 1.5 Alt/Üst',
|
"281.5": "Ev Sahibi 1.5 Alt/Üst",
|
||||||
'282.5': 'Ev Sahibi 2.5 Alt/Üst',
|
"282.5": "Ev Sahibi 2.5 Alt/Üst",
|
||||||
'283.5': 'Ev Sahibi 3.5 Alt/Üst',
|
"283.5": "Ev Sahibi 3.5 Alt/Üst",
|
||||||
'290.5': 'Deplasman 0.5 Alt/Üst',
|
"290.5": "Deplasman 0.5 Alt/Üst",
|
||||||
'291.5': 'Deplasman 1.5 Alt/Üst',
|
"291.5": "Deplasman 1.5 Alt/Üst",
|
||||||
'292.5': 'Deplasman 2.5 Alt/Üst',
|
"292.5": "Deplasman 2.5 Alt/Üst",
|
||||||
'400.5': '1. Yarı Ev Sahibi 0.5 Alt/Üst',
|
"400.5": "1. Yarı Ev Sahibi 0.5 Alt/Üst",
|
||||||
'430.5': '1. Yarı Deplasman 0.5 Alt/Üst',
|
"430.5": "1. Yarı Deplasman 0.5 Alt/Üst",
|
||||||
'37': 'Ev Sahibi Gol Yemeden Kazanır',
|
"37": "Ev Sahibi Gol Yemeden Kazanır",
|
||||||
'38': 'Deplasman Gol Yemeden Kazanır',
|
"38": "Deplasman Gol Yemeden Kazanır",
|
||||||
|
|
||||||
// Korner & Kart
|
// Korner & Kart
|
||||||
'47': 'En Çok Korner',
|
"47": "En Çok Korner",
|
||||||
'48': '1. Yarı En Çok Korner',
|
"48": "1. Yarı En Çok Korner",
|
||||||
'49': 'İlk Korner',
|
"49": "İlk Korner",
|
||||||
'43': 'Toplam Korner Aralığı',
|
"43": "Toplam Korner Aralığı",
|
||||||
'44': '1. Yarı Korner Aralığı',
|
"44": "1. Yarı Korner Aralığı",
|
||||||
'463.5': '1. Yarı 3.5 Korner Alt/Üst',
|
"463.5": "1. Yarı 3.5 Korner Alt/Üst",
|
||||||
'464.5': '1. Yarı 4.5 Korner Alt/Üst',
|
"464.5": "1. Yarı 4.5 Korner Alt/Üst",
|
||||||
'465.5': '1. Yarı 5.5 Korner Alt/Üst',
|
"465.5": "1. Yarı 5.5 Korner Alt/Üst",
|
||||||
'53': 'Kırmızı Kart Olur mu?',
|
"53": "Kırmızı Kart Olur mu?",
|
||||||
'384.5': '4.5 Kart Puanı Alt/Üst',
|
"384.5": "4.5 Kart Puanı Alt/Üst",
|
||||||
'385.5': '5.5 Kart Puanı Alt/Üst',
|
"385.5": "5.5 Kart Puanı Alt/Üst",
|
||||||
'386.5': '6.5 Kart Puanı Alt/Üst',
|
"386.5": "6.5 Kart Puanı Alt/Üst",
|
||||||
|
|
||||||
// Kombine
|
// Kombine
|
||||||
'301.5': 'MS ve 1.5 Alt/Üst',
|
"301.5": "MS ve 1.5 Alt/Üst",
|
||||||
'302.5': 'MS ve 2.5 Alt/Üst',
|
"302.5": "MS ve 2.5 Alt/Üst",
|
||||||
'303.5': 'MS ve 3.5 Alt/Üst',
|
"303.5": "MS ve 3.5 Alt/Üst",
|
||||||
'304.5': 'MS ve 4.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)
|
// İ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 {
|
export interface AxiosRequestConfig {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': string;
|
"User-Agent": string;
|
||||||
Referer: string;
|
Referer: string;
|
||||||
'X-Requested-With': string;
|
"X-Requested-With": string;
|
||||||
'Accept-Language'?: string;
|
"Accept-Language"?: string;
|
||||||
};
|
};
|
||||||
timeout: number;
|
timeout: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_HEADERS = {
|
export const DEFAULT_HEADERS = {
|
||||||
'User-Agent':
|
"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',
|
"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/',
|
Referer: "https://www.mackolik.com/",
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
'Accept-Language': 'tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7',
|
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_TIMEOUT = 30000;
|
export const DEFAULT_TIMEOUT = 30000;
|
||||||
@@ -516,7 +516,7 @@ export interface SidelinedPlayer {
|
|||||||
playerUrl: string;
|
playerUrl: string;
|
||||||
position: string;
|
position: string;
|
||||||
positionShort: string;
|
positionShort: string;
|
||||||
type: 'injury' | 'suspension' | 'other';
|
type: "injury" | "suspension" | "other";
|
||||||
description: string;
|
description: string;
|
||||||
matchesMissed: number | null;
|
matchesMissed: number | null;
|
||||||
average: number | null;
|
average: number | null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { registerAs } from '@nestjs/config';
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
export const geminiConfig = registerAs('gemini', () => ({
|
export const geminiConfig = registerAs("gemini", () => ({
|
||||||
enabled: process.env.ENABLE_GEMINI === 'true',
|
enabled: process.env.ENABLE_GEMINI === "true",
|
||||||
apiKey: process.env.GOOGLE_API_KEY,
|
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 { Module, Global } from "@nestjs/common";
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { GeminiService } from './gemini.service';
|
import { GeminiService } from "./gemini.service";
|
||||||
import { geminiConfig } from './gemini.config';
|
import { geminiConfig } from "./gemini.config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gemini AI Module
|
* Gemini AI Module
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
import { Injectable, OnModuleInit, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { GoogleGenAI } from '@google/genai';
|
import { GoogleGenAI } from "@google/genai";
|
||||||
|
|
||||||
export interface GeminiGenerateOptions {
|
export interface GeminiGenerateOptions {
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -10,7 +10,7 @@ export interface GeminiGenerateOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GeminiChatMessage {
|
export interface GeminiChatMessage {
|
||||||
role: 'user' | 'model';
|
role: "user" | "model";
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,34 +48,34 @@ export class GeminiService implements OnModuleInit {
|
|||||||
private defaultModel: string;
|
private defaultModel: string;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
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>(
|
this.defaultModel = this.configService.get<string>(
|
||||||
'gemini.defaultModel',
|
"gemini.defaultModel",
|
||||||
'gemini-2.5-flash',
|
"gemini-2.5-flash",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
if (!this.isEnabled) {
|
if (!this.isEnabled) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
'Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.',
|
"Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = this.configService.get<string>('gemini.apiKey');
|
const apiKey = this.configService.get<string>("gemini.apiKey");
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
this.logger.warn(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.client = new GoogleGenAI({ apiKey });
|
this.client = new GoogleGenAI({ apiKey });
|
||||||
this.logger.log('✅ Gemini AI initialized successfully');
|
this.logger.log("✅ Gemini AI initialized successfully");
|
||||||
} catch (error) {
|
} 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 = {},
|
options: GeminiGenerateOptions = {},
|
||||||
): Promise<{ text: string; usage?: any }> {
|
): Promise<{ text: string; usage?: any }> {
|
||||||
if (!this.isAvailable()) {
|
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;
|
const model = options.model || this.defaultModel;
|
||||||
@@ -109,17 +109,17 @@ export class GeminiService implements OnModuleInit {
|
|||||||
// Add system prompt if provided
|
// Add system prompt if provided
|
||||||
if (options.systemPrompt) {
|
if (options.systemPrompt) {
|
||||||
contents.push({
|
contents.push({
|
||||||
role: 'user',
|
role: "user",
|
||||||
parts: [{ text: options.systemPrompt }],
|
parts: [{ text: options.systemPrompt }],
|
||||||
});
|
});
|
||||||
contents.push({
|
contents.push({
|
||||||
role: 'model',
|
role: "model",
|
||||||
parts: [{ text: 'Understood. I will follow these instructions.' }],
|
parts: [{ text: "Understood. I will follow these instructions." }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
contents.push({
|
contents.push({
|
||||||
role: 'user',
|
role: "user",
|
||||||
parts: [{ text: prompt }],
|
parts: [{ text: prompt }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,11 +133,11 @@ export class GeminiService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: (response.text || '').trim(),
|
text: (response.text || "").trim(),
|
||||||
usage: response.usageMetadata,
|
usage: response.usageMetadata,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Gemini generation failed', error);
|
this.logger.error("Gemini generation failed", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ export class GeminiService implements OnModuleInit {
|
|||||||
options: GeminiGenerateOptions = {},
|
options: GeminiGenerateOptions = {},
|
||||||
): Promise<{ text: string; usage?: any }> {
|
): Promise<{ text: string; usage?: any }> {
|
||||||
if (!this.isAvailable()) {
|
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;
|
const model = options.model || this.defaultModel;
|
||||||
@@ -169,12 +169,12 @@ export class GeminiService implements OnModuleInit {
|
|||||||
if (options.systemPrompt) {
|
if (options.systemPrompt) {
|
||||||
contents.unshift(
|
contents.unshift(
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: "user",
|
||||||
parts: [{ text: options.systemPrompt }],
|
parts: [{ text: options.systemPrompt }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'model',
|
role: "model",
|
||||||
parts: [{ text: 'Understood. I will follow these instructions.' }],
|
parts: [{ text: "Understood. I will follow these instructions." }],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -189,11 +189,11 @@ export class GeminiService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: (response.text || '').trim(),
|
text: (response.text || "").trim(),
|
||||||
usage: response.usageMetadata,
|
usage: response.usageMetadata,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Gemini chat failed', error);
|
this.logger.error("Gemini chat failed", error);
|
||||||
throw 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;
|
const data = JSON.parse(jsonStr) as T;
|
||||||
return { data, usage: response.usage };
|
return { data, usage: response.usage };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to parse JSON response', error);
|
this.logger.error("Failed to parse JSON response", error);
|
||||||
throw new Error('Failed to parse AI response as JSON');
|
throw new Error("Failed to parse AI response as JSON");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export * from './gemini.module';
|
export * from "./gemini.module";
|
||||||
export * from './gemini.service';
|
export * from "./gemini.service";
|
||||||
export * from './gemini.config';
|
export * from "./gemini.config";
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from "@nestjs/common";
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } from "@nestjs/swagger";
|
||||||
import {
|
import {
|
||||||
HealthCheck,
|
HealthCheck,
|
||||||
HealthCheckService,
|
HealthCheckService,
|
||||||
PrismaHealthIndicator,
|
PrismaHealthIndicator,
|
||||||
} from '@nestjs/terminus';
|
} from "@nestjs/terminus";
|
||||||
import { Public } from '../../common/decorators';
|
import { Public } from "../../common/decorators";
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
|
|
||||||
@ApiTags('Health')
|
@ApiTags("Health")
|
||||||
@Controller('health')
|
@Controller("health")
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(
|
constructor(
|
||||||
private health: HealthCheckService,
|
private health: HealthCheckService,
|
||||||
@@ -20,25 +20,25 @@ export class HealthController {
|
|||||||
@Get()
|
@Get()
|
||||||
@Public()
|
@Public()
|
||||||
@HealthCheck()
|
@HealthCheck()
|
||||||
@ApiOperation({ summary: 'Basic health check' })
|
@ApiOperation({ summary: "Basic health check" })
|
||||||
check() {
|
check() {
|
||||||
return this.health.check([]);
|
return this.health.check([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('ready')
|
@Get("ready")
|
||||||
@Public()
|
@Public()
|
||||||
@HealthCheck()
|
@HealthCheck()
|
||||||
@ApiOperation({ summary: 'Readiness check (includes database)' })
|
@ApiOperation({ summary: "Readiness check (includes database)" })
|
||||||
readiness() {
|
readiness() {
|
||||||
return this.health.check([
|
return this.health.check([
|
||||||
() => this.prismaHealth.pingCheck('database', this.prisma),
|
() => this.prismaHealth.pingCheck("database", this.prisma),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('live')
|
@Get("live")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Liveness check' })
|
@ApiOperation({ summary: "Liveness check" })
|
||||||
liveness() {
|
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 { Module } from "@nestjs/common";
|
||||||
import { TerminusModule } from '@nestjs/terminus';
|
import { TerminusModule } from "@nestjs/terminus";
|
||||||
import { PrismaHealthIndicator } from '@nestjs/terminus';
|
import { PrismaHealthIndicator } from "@nestjs/terminus";
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from "./health.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TerminusModule],
|
imports: [TerminusModule],
|
||||||
|
|||||||
@@ -4,20 +4,20 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
} from '@nestjs/swagger';
|
} from "@nestjs/swagger";
|
||||||
import { LeaguesService } from './leagues.service';
|
import { LeaguesService } from "./leagues.service";
|
||||||
import { Sport } from '@prisma/client';
|
import { Sport } from "@prisma/client";
|
||||||
import { Public } from '../../common/decorators';
|
import { Public } from "../../common/decorators";
|
||||||
|
|
||||||
@ApiTags('Leagues')
|
@ApiTags("Leagues")
|
||||||
@Controller('leagues')
|
@Controller("leagues")
|
||||||
export class LeaguesController {
|
export class LeaguesController {
|
||||||
constructor(private readonly leaguesService: LeaguesService) {}
|
constructor(private readonly leaguesService: LeaguesService) {}
|
||||||
|
|
||||||
@@ -25,10 +25,10 @@ export class LeaguesController {
|
|||||||
* GET /leagues/countries
|
* GET /leagues/countries
|
||||||
* Get all countries
|
* Get all countries
|
||||||
*/
|
*/
|
||||||
@Get('countries')
|
@Get("countries")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Get all countries' })
|
@ApiOperation({ summary: "Get all countries" })
|
||||||
@ApiResponse({ status: 200, description: 'List of countries' })
|
@ApiResponse({ status: 200, description: "List of countries" })
|
||||||
async getCountries() {
|
async getCountries() {
|
||||||
return this.leaguesService.findAllCountries();
|
return this.leaguesService.findAllCountries();
|
||||||
}
|
}
|
||||||
@@ -37,13 +37,13 @@ export class LeaguesController {
|
|||||||
* GET /leagues/countries/:id
|
* GET /leagues/countries/:id
|
||||||
* Get country by ID with leagues
|
* Get country by ID with leagues
|
||||||
*/
|
*/
|
||||||
@Get('countries/:id')
|
@Get("countries/:id")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Get country by ID with leagues' })
|
@ApiOperation({ summary: "Get country by ID with leagues" })
|
||||||
@ApiParam({ name: 'id', description: 'Country ID' })
|
@ApiParam({ name: "id", description: "Country ID" })
|
||||||
async getCountryById(@Param('id') id: string) {
|
async getCountryById(@Param("id") id: string) {
|
||||||
const country = await this.leaguesService.findCountryById(id);
|
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;
|
return country;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +53,13 @@ export class LeaguesController {
|
|||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Get all leagues' })
|
@ApiOperation({ summary: "Get all leagues" })
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: 'sport',
|
name: "sport",
|
||||||
required: false,
|
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);
|
return this.leaguesService.findAllLeagues(sport as Sport);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,21 +68,21 @@ export class LeaguesController {
|
|||||||
* Get head-to-head matches between two teams
|
* Get head-to-head matches between two teams
|
||||||
* NOTE: Must come before /teams/:id to avoid route conflict
|
* NOTE: Must come before /teams/:id to avoid route conflict
|
||||||
*/
|
*/
|
||||||
@Get('teams/h2h')
|
@Get("teams/h2h")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Get head-to-head matches between two teams' })
|
@ApiOperation({ summary: "Get head-to-head matches between two teams" })
|
||||||
@ApiQuery({ name: 'team1', required: true })
|
@ApiQuery({ name: "team1", required: true })
|
||||||
@ApiQuery({ name: 'team2', required: true })
|
@ApiQuery({ name: "team2", required: true })
|
||||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
@ApiQuery({ name: "limit", required: false, type: Number })
|
||||||
async getHeadToHead(
|
async getHeadToHead(
|
||||||
@Query('team1') team1: string,
|
@Query("team1") team1: string,
|
||||||
@Query('team2') team2: string,
|
@Query("team2") team2: string,
|
||||||
@Query('limit') limit?: string,
|
@Query("limit") limit?: string,
|
||||||
) {
|
) {
|
||||||
return this.leaguesService.getHeadToHead(
|
return this.leaguesService.getHeadToHead(
|
||||||
team1,
|
team1,
|
||||||
team2,
|
team2,
|
||||||
parseInt(limit || '10', 10),
|
parseInt(limit || "10", 10),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,16 +90,16 @@ export class LeaguesController {
|
|||||||
* GET /leagues/teams/search
|
* GET /leagues/teams/search
|
||||||
* Search teams by name
|
* Search teams by name
|
||||||
*/
|
*/
|
||||||
@Get('teams/search')
|
@Get("teams/search")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Search teams by name' })
|
@ApiOperation({ summary: "Search teams by name" })
|
||||||
@ApiQuery({ name: 'q', required: true, description: 'Search query' })
|
@ApiQuery({ name: "q", required: true, description: "Search query" })
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: 'sport',
|
name: "sport",
|
||||||
required: false,
|
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);
|
return this.leaguesService.searchTeams(query, sport as Sport);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,13 +107,13 @@ export class LeaguesController {
|
|||||||
* GET /leagues/teams/:id
|
* GET /leagues/teams/:id
|
||||||
* Get team by ID
|
* Get team by ID
|
||||||
*/
|
*/
|
||||||
@Get('teams/:id')
|
@Get("teams/:id")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Get team by ID' })
|
@ApiOperation({ summary: "Get team by ID" })
|
||||||
@ApiParam({ name: 'id', description: 'Team ID' })
|
@ApiParam({ name: "id", description: "Team ID" })
|
||||||
async getTeamById(@Param('id') id: string) {
|
async getTeamById(@Param("id") id: string) {
|
||||||
const team = await this.leaguesService.findTeamById(id);
|
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;
|
return team;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,18 +121,18 @@ export class LeaguesController {
|
|||||||
* GET /leagues/teams/:id/matches
|
* GET /leagues/teams/:id/matches
|
||||||
* Get team's recent matches
|
* Get team's recent matches
|
||||||
*/
|
*/
|
||||||
@Get('teams/:id/matches')
|
@Get("teams/:id/matches")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Get team's recent matches" })
|
@ApiOperation({ summary: "Get team's recent matches" })
|
||||||
@ApiParam({ name: 'id', description: 'Team ID' })
|
@ApiParam({ name: "id", description: "Team ID" })
|
||||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
@ApiQuery({ name: "limit", required: false, type: Number })
|
||||||
async getTeamMatches(
|
async getTeamMatches(
|
||||||
@Param('id') id: string,
|
@Param("id") id: string,
|
||||||
@Query('limit') limit?: string,
|
@Query("limit") limit?: string,
|
||||||
) {
|
) {
|
||||||
return this.leaguesService.getTeamRecentMatches(
|
return this.leaguesService.getTeamRecentMatches(
|
||||||
id,
|
id,
|
||||||
parseInt(limit || '10', 10),
|
parseInt(limit || "10", 10),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,13 +140,13 @@ export class LeaguesController {
|
|||||||
* GET /leagues/:id
|
* GET /leagues/:id
|
||||||
* Get league by ID
|
* Get league by ID
|
||||||
*/
|
*/
|
||||||
@Get(':id')
|
@Get(":id")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Get league by ID' })
|
@ApiOperation({ summary: "Get league by ID" })
|
||||||
@ApiParam({ name: 'id', description: 'League ID' })
|
@ApiParam({ name: "id", description: "League ID" })
|
||||||
async getLeagueById(@Param('id') id: string) {
|
async getLeagueById(@Param("id") id: string) {
|
||||||
const league = await this.leaguesService.findLeagueById(id);
|
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;
|
return league;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { LeaguesController } from './leagues.controller';
|
import { LeaguesController } from "./leagues.controller";
|
||||||
import { LeaguesService } from './leagues.service';
|
import { LeaguesService } from "./leagues.service";
|
||||||
import { DatabaseModule } from '../../database/database.module';
|
import { DatabaseModule } from "../../database/database.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule],
|
imports: [DatabaseModule],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import { Sport } from '@prisma/client';
|
import { Sport } from "@prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LeaguesService {
|
export class LeaguesService {
|
||||||
@@ -13,7 +13,7 @@ export class LeaguesService {
|
|||||||
*/
|
*/
|
||||||
async findAllCountries() {
|
async findAllCountries() {
|
||||||
return this.prisma.country.findMany({
|
return this.prisma.country.findMany({
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export class LeaguesService {
|
|||||||
return this.prisma.league.findMany({
|
return this.prisma.league.findMany({
|
||||||
where: sport ? { sport } : undefined,
|
where: sport ? { sport } : undefined,
|
||||||
include: { country: true },
|
include: { country: true },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ export class LeaguesService {
|
|||||||
...(sport ? { sport } : {}),
|
...(sport ? { sport } : {}),
|
||||||
},
|
},
|
||||||
include: { country: true },
|
include: { country: true },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,9 +69,9 @@ export class LeaguesService {
|
|||||||
return this.prisma.team.findMany({
|
return this.prisma.team.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(sport ? { sport } : {}),
|
...(sport ? { sport } : {}),
|
||||||
...(search ? { name: { contains: search, mode: 'insensitive' } } : {}),
|
...(search ? { name: { contains: search, mode: "insensitive" } } : {}),
|
||||||
},
|
},
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: "asc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ export class LeaguesService {
|
|||||||
async searchTeams(name: string, sport?: Sport) {
|
async searchTeams(name: string, sport?: Sport) {
|
||||||
return this.prisma.team.findMany({
|
return this.prisma.team.findMany({
|
||||||
where: {
|
where: {
|
||||||
name: { contains: name, mode: 'insensitive' },
|
name: { contains: name, mode: "insensitive" },
|
||||||
...(sport ? { sport } : {}),
|
...(sport ? { sport } : {}),
|
||||||
},
|
},
|
||||||
take: 20,
|
take: 20,
|
||||||
@@ -111,7 +111,7 @@ export class LeaguesService {
|
|||||||
awayTeam: true,
|
awayTeam: true,
|
||||||
league: { include: { country: true } },
|
league: { include: { country: true } },
|
||||||
},
|
},
|
||||||
orderBy: { mstUtc: 'desc' },
|
orderBy: { mstUtc: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -126,14 +126,14 @@ export class LeaguesService {
|
|||||||
{ homeTeamId: teamId1, awayTeamId: teamId2 },
|
{ homeTeamId: teamId1, awayTeamId: teamId2 },
|
||||||
{ homeTeamId: teamId2, awayTeamId: teamId1 },
|
{ homeTeamId: teamId2, awayTeamId: teamId1 },
|
||||||
],
|
],
|
||||||
state: 'postGame', // Finished matches are stored as "postGame"
|
state: "postGame", // Finished matches are stored as "postGame"
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
homeTeam: true,
|
homeTeam: true,
|
||||||
awayTeam: true,
|
awayTeam: true,
|
||||||
league: true,
|
league: true,
|
||||||
},
|
},
|
||||||
orderBy: { mstUtc: 'desc' },
|
orderBy: { mstUtc: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,21 +8,21 @@ import {
|
|||||||
Max,
|
Max,
|
||||||
IsArray,
|
IsArray,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from "class-validator";
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from "class-transformer";
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
|
||||||
export enum Sport {
|
export enum Sport {
|
||||||
FOOTBALL = 'football',
|
FOOTBALL = "football",
|
||||||
BASKETBALL = 'basketball',
|
BASKETBALL = "basketball",
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OddFilterDto {
|
export class OddFilterDto {
|
||||||
@ApiProperty({ example: 'Maç Sonucu' })
|
@ApiProperty({ example: "Maç Sonucu" })
|
||||||
@IsString()
|
@IsString()
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '1' })
|
@ApiProperty({ example: "1" })
|
||||||
@IsString()
|
@IsString()
|
||||||
selectionName: string;
|
selectionName: string;
|
||||||
|
|
||||||
@@ -39,10 +39,10 @@ export class TeamFilterDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['home', 'away', 'any'] })
|
@ApiPropertyOptional({ enum: ["home", "away", "any"] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
role?: 'home' | 'away' | 'any';
|
role?: "home" | "away" | "any";
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DateRangeDto {
|
export class DateRangeDto {
|
||||||
@@ -73,13 +73,13 @@ export class MatchQueryDto {
|
|||||||
leagueId?: string;
|
leagueId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Filter by status: LIVE, Finished, etc.',
|
description: "Filter by status: LIVE, Finished, etc.",
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Single date filter (YYYY-MM-DD)' })
|
@ApiPropertyOptional({ description: "Single date filter (YYYY-MM-DD)" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
date?: string;
|
date?: string;
|
||||||
@@ -153,7 +153,7 @@ export class MatchResponseDto {
|
|||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
countryName?: string;
|
countryName?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: 'array' })
|
@ApiPropertyOptional({ type: "array" })
|
||||||
odds?: any[];
|
odds?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,26 +10,26 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import { Public } from '../../common/decorators';
|
import { Public } from "../../common/decorators";
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
} from '@nestjs/swagger';
|
} from "@nestjs/swagger";
|
||||||
import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager';
|
import { CacheInterceptor, CacheTTL } from "@nestjs/cache-manager";
|
||||||
import { MatchesService } from './matches.service';
|
import { MatchesService } from "./matches.service";
|
||||||
import {
|
import {
|
||||||
MatchQueryDto,
|
MatchQueryDto,
|
||||||
Sport,
|
Sport,
|
||||||
LeagueWithMatchesDto,
|
LeagueWithMatchesDto,
|
||||||
ActiveLeagueDto,
|
ActiveLeagueDto,
|
||||||
} from './dto';
|
} from "./dto";
|
||||||
|
|
||||||
@ApiTags('Matches')
|
@ApiTags("Matches")
|
||||||
@Controller('matches')
|
@Controller("matches")
|
||||||
export class MatchesController {
|
export class MatchesController {
|
||||||
constructor(private readonly matchesService: MatchesService) {}
|
constructor(private readonly matchesService: MatchesService) {}
|
||||||
|
|
||||||
@@ -38,9 +38,9 @@ export class MatchesController {
|
|||||||
* Advanced match query with filters
|
* Advanced match query with filters
|
||||||
*/
|
*/
|
||||||
@Public()
|
@Public()
|
||||||
@Post('query')
|
@Post("query")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Advanced match query with filters' })
|
@ApiOperation({ summary: "Advanced match query with filters" })
|
||||||
@ApiResponse({ status: 200, type: [LeagueWithMatchesDto] })
|
@ApiResponse({ status: 200, type: [LeagueWithMatchesDto] })
|
||||||
async queryMatches(
|
async queryMatches(
|
||||||
@Body() queryDto: MatchQueryDto,
|
@Body() queryDto: MatchQueryDto,
|
||||||
@@ -67,18 +67,18 @@ export class MatchesController {
|
|||||||
*/
|
*/
|
||||||
@Public()
|
@Public()
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List matches with pagination' })
|
@ApiOperation({ summary: "List matches with pagination" })
|
||||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
@ApiQuery({ name: "page", required: false, type: Number })
|
||||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
@ApiQuery({ name: "limit", required: false, type: Number })
|
||||||
@ApiQuery({ name: 'sport', required: false, enum: Sport })
|
@ApiQuery({ name: "sport", required: false, enum: Sport })
|
||||||
@ApiResponse({ status: 200, description: 'Paginated list of matches' })
|
@ApiResponse({ status: 200, description: "Paginated list of matches" })
|
||||||
async listMatches(
|
async listMatches(
|
||||||
@Query('page') page?: string,
|
@Query("page") page?: string,
|
||||||
@Query('limit') limit?: string,
|
@Query("limit") limit?: string,
|
||||||
@Query('sport') sport?: Sport,
|
@Query("sport") sport?: Sport,
|
||||||
) {
|
) {
|
||||||
const pageNum = parseInt(page || '1', 10);
|
const pageNum = parseInt(page || "1", 10);
|
||||||
const limitNum = parseInt(limit || '20', 10);
|
const limitNum = parseInt(limit || "20", 10);
|
||||||
const sportType = sport || Sport.FOOTBALL;
|
const sportType = sport || Sport.FOOTBALL;
|
||||||
|
|
||||||
return this.matchesService.listMatches(sportType, pageNum, limitNum);
|
return this.matchesService.listMatches(sportType, pageNum, limitNum);
|
||||||
@@ -89,14 +89,14 @@ export class MatchesController {
|
|||||||
* Get active leagues with match counts
|
* Get active leagues with match counts
|
||||||
*/
|
*/
|
||||||
@Public()
|
@Public()
|
||||||
@Get('leagues/active')
|
@Get("leagues/active")
|
||||||
@UseInterceptors(CacheInterceptor)
|
@UseInterceptors(CacheInterceptor)
|
||||||
@CacheTTL(60000) // 1 minute cache
|
@CacheTTL(60000) // 1 minute cache
|
||||||
@ApiOperation({ summary: 'Get active leagues with upcoming/live matches' })
|
@ApiOperation({ summary: "Get active leagues with upcoming/live matches" })
|
||||||
@ApiQuery({ name: 'sport', required: false, enum: Sport })
|
@ApiQuery({ name: "sport", required: false, enum: Sport })
|
||||||
@ApiResponse({ status: 200, type: [ActiveLeagueDto] })
|
@ApiResponse({ status: 200, type: [ActiveLeagueDto] })
|
||||||
async getActiveLeagues(
|
async getActiveLeagues(
|
||||||
@Query('sport') sport?: Sport,
|
@Query("sport") sport?: Sport,
|
||||||
): Promise<ActiveLeagueDto[]> {
|
): Promise<ActiveLeagueDto[]> {
|
||||||
return this.matchesService.getActiveLeagues(sport || Sport.FOOTBALL);
|
return this.matchesService.getActiveLeagues(sport || Sport.FOOTBALL);
|
||||||
}
|
}
|
||||||
@@ -106,23 +106,23 @@ export class MatchesController {
|
|||||||
* Get full match details
|
* Get full match details
|
||||||
*/
|
*/
|
||||||
@Public()
|
@Public()
|
||||||
@Get(':id')
|
@Get(":id")
|
||||||
@ApiOperation({ summary: 'Get full match details by ID' })
|
@ApiOperation({ summary: "Get full match details by ID" })
|
||||||
@ApiParam({ name: 'id', description: 'Match ID' })
|
@ApiParam({ name: "id", description: "Match ID" })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
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' })
|
@ApiResponse({ status: 404, description: "Match not found" })
|
||||||
async getMatchDetails(@Param('id') id: string) {
|
async getMatchDetails(@Param("id") id: string) {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new BadRequestException('Match ID is required');
|
throw new BadRequestException("Match ID is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = await this.matchesService.getMatchDetailsById(id);
|
const match = await this.matchesService.getMatchDetailsById(id);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new NotFoundException('Match not found');
|
throw new NotFoundException("Match not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return match;
|
return match;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { MatchesController } from './matches.controller';
|
import { MatchesController } from "./matches.controller";
|
||||||
import { MatchesService } from './matches.service';
|
import { MatchesService } from "./matches.service";
|
||||||
import { DatabaseModule } from '../../database/database.module';
|
import { DatabaseModule } from "../../database/database.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule],
|
imports: [DatabaseModule],
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import * as fs from 'fs';
|
import * as fs from "fs";
|
||||||
import * as path from 'path';
|
import * as path from "path";
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import {
|
import {
|
||||||
Sport,
|
Sport,
|
||||||
MatchQueryDto,
|
MatchQueryDto,
|
||||||
LeagueWithMatchesDto,
|
LeagueWithMatchesDto,
|
||||||
ActiveLeagueDto,
|
ActiveLeagueDto,
|
||||||
} from './dto';
|
} from "./dto";
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MatchesService {
|
export class MatchesService {
|
||||||
@@ -21,9 +21,9 @@ export class MatchesService {
|
|||||||
|
|
||||||
private loadTopLeagues() {
|
private loadTopLeagues() {
|
||||||
try {
|
try {
|
||||||
const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json');
|
const topLeaguesPath = path.join(process.cwd(), "top_leagues.json");
|
||||||
if (fs.existsSync(topLeaguesPath)) {
|
if (fs.existsSync(topLeaguesPath)) {
|
||||||
this.topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8'));
|
this.topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Loaded ${this.topLeagueIds.length} top leagues for filtering.`,
|
`Loaded ${this.topLeagueIds.length} top leagues for filtering.`,
|
||||||
);
|
);
|
||||||
@@ -39,22 +39,22 @@ export class MatchesService {
|
|||||||
{
|
{
|
||||||
status: {
|
status: {
|
||||||
in: [
|
in: [
|
||||||
'LIVE',
|
"LIVE",
|
||||||
'1H',
|
"1H",
|
||||||
'2H',
|
"2H",
|
||||||
'HT',
|
"HT",
|
||||||
'1Q',
|
"1Q",
|
||||||
'2Q',
|
"2Q",
|
||||||
'3Q',
|
"3Q",
|
||||||
'4Q',
|
"4Q",
|
||||||
'Playing',
|
"Playing",
|
||||||
'Half Time',
|
"Half Time",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
state: {
|
state: {
|
||||||
in: ['live', 'firsthalf', 'secondhalf'],
|
in: ["live", "firsthalf", "secondhalf"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -66,12 +66,12 @@ export class MatchesService {
|
|||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
status: {
|
status: {
|
||||||
in: ['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'],
|
in: ["Finished", "Played", "FT", "AET", "PEN", "Ended"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
state: {
|
state: {
|
||||||
in: ['Finished', 'post', 'FT', 'postGame'],
|
in: ["Finished", "post", "FT", "postGame"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -134,16 +134,16 @@ export class MatchesService {
|
|||||||
|
|
||||||
if (leagueId) {
|
if (leagueId) {
|
||||||
where.leagueId = 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
|
// Filter live matches by top leagues by default if no leagueId is provided
|
||||||
where.leagueId = { in: this.topLeagueIds };
|
where.leagueId = { in: this.topLeagueIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'LIVE') {
|
if (status === "LIVE") {
|
||||||
andConditions.push(this.getLiveFilter());
|
andConditions.push(this.getLiveFilter());
|
||||||
} else if (status === 'UPCOMING' || status === 'NOT_STARTED') {
|
} else if (status === "UPCOMING" || status === "NOT_STARTED") {
|
||||||
andConditions.push(this.getUpcomingFilter(Date.now()));
|
andConditions.push(this.getUpcomingFilter(Date.now()));
|
||||||
} else if (status === 'FINISHED') {
|
} else if (status === "FINISHED") {
|
||||||
andConditions.push(this.getFinishedFilter());
|
andConditions.push(this.getFinishedFilter());
|
||||||
} else if (status) {
|
} else if (status) {
|
||||||
where.status = status;
|
where.status = status;
|
||||||
@@ -170,9 +170,9 @@ export class MatchesService {
|
|||||||
|
|
||||||
// Team filter
|
// Team filter
|
||||||
if (team) {
|
if (team) {
|
||||||
if (team.role === 'home') {
|
if (team.role === "home") {
|
||||||
where.homeTeamId = team.id;
|
where.homeTeamId = team.id;
|
||||||
} else if (team.role === 'away') {
|
} else if (team.role === "away") {
|
||||||
where.awayTeamId = team.id;
|
where.awayTeamId = team.id;
|
||||||
} else {
|
} else {
|
||||||
andConditions.push({
|
andConditions.push({
|
||||||
@@ -197,7 +197,7 @@ export class MatchesService {
|
|||||||
const matches = await this.prisma.liveMatch.findMany({
|
const matches = await this.prisma.liveMatch.findMany({
|
||||||
where,
|
where,
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
orderBy: { mstUtc: 'asc' }, // Sort by nearest match first
|
orderBy: { mstUtc: "asc" }, // Sort by nearest match first
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ export class MatchesService {
|
|||||||
AND: [this.getUpcomingFilter(Date.now())],
|
AND: [this.getUpcomingFilter(Date.now())],
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
orderBy: { mstUtc: 'asc' },
|
orderBy: { mstUtc: "asc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
console.log(
|
console.log(
|
||||||
@@ -283,16 +283,16 @@ export class MatchesService {
|
|||||||
const leaguesMap = new Map<string, LeagueWithMatchesDto>();
|
const leaguesMap = new Map<string, LeagueWithMatchesDto>();
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const leagueId = match.leagueId || 'unknown';
|
const leagueId = match.leagueId || "unknown";
|
||||||
|
|
||||||
if (!leaguesMap.has(leagueId)) {
|
if (!leaguesMap.has(leagueId)) {
|
||||||
leaguesMap.set(leagueId, {
|
leaguesMap.set(leagueId, {
|
||||||
id: leagueId,
|
id: leagueId,
|
||||||
name: match.league?.name || 'Unknown League',
|
name: match.league?.name || "Unknown League",
|
||||||
code: match.league?.code || undefined,
|
code: match.league?.code || undefined,
|
||||||
country: {
|
country: {
|
||||||
id: match.league?.country?.id || '',
|
id: match.league?.country?.id || "",
|
||||||
name: match.league?.country?.name || '',
|
name: match.league?.country?.name || "",
|
||||||
flagUrl: match.league?.country?.flagUrl || undefined,
|
flagUrl: match.league?.country?.flagUrl || undefined,
|
||||||
},
|
},
|
||||||
sport: sport,
|
sport: sport,
|
||||||
@@ -306,13 +306,13 @@ export class MatchesService {
|
|||||||
const structuredOdds: any[] = [];
|
const structuredOdds: any[] = [];
|
||||||
if (
|
if (
|
||||||
match.odds &&
|
match.odds &&
|
||||||
typeof match.odds === 'object' &&
|
typeof match.odds === "object" &&
|
||||||
!Array.isArray(match.odds)
|
!Array.isArray(match.odds)
|
||||||
) {
|
) {
|
||||||
const oddsObj = match.odds as Record<string, Record<string, number>>;
|
const oddsObj = match.odds as Record<string, Record<string, number>>;
|
||||||
for (const [marketName, selections] of Object.entries(oddsObj)) {
|
for (const [marketName, selections] of Object.entries(oddsObj)) {
|
||||||
const structuredSelections: Record<string, { odd: string }> = {};
|
const structuredSelections: Record<string, { odd: string }> = {};
|
||||||
if (selections && typeof selections === 'object') {
|
if (selections && typeof selections === "object") {
|
||||||
for (const [selName, selOdd] of Object.entries(selections)) {
|
for (const [selName, selOdd] of Object.entries(selections)) {
|
||||||
structuredSelections[selName] = { odd: String(selOdd) };
|
structuredSelections[selName] = { odd: String(selOdd) };
|
||||||
}
|
}
|
||||||
@@ -325,15 +325,15 @@ export class MatchesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map status for frontend
|
// Map status for frontend
|
||||||
let displayStatus = match.status || 'NS';
|
let displayStatus = match.status || "NS";
|
||||||
if (match.state === 'live') {
|
if (match.state === "live") {
|
||||||
displayStatus = 'LIVE';
|
displayStatus = "LIVE";
|
||||||
} else if (
|
} else if (
|
||||||
match.state === 'post' ||
|
match.state === "post" ||
|
||||||
match.state === 'FT' ||
|
match.state === "FT" ||
|
||||||
match.status === 'Finished'
|
match.status === "Finished"
|
||||||
) {
|
) {
|
||||||
displayStatus = 'Finished';
|
displayStatus = "Finished";
|
||||||
}
|
}
|
||||||
|
|
||||||
league.matches.push({
|
league.matches.push({
|
||||||
@@ -349,11 +349,11 @@ export class MatchesService {
|
|||||||
scoreAway: match.scoreAway ?? undefined,
|
scoreAway: match.scoreAway ?? undefined,
|
||||||
htScoreHome: undefined, // LiveMatch table doesn't have HT scores separately usually
|
htScoreHome: undefined, // LiveMatch table doesn't have HT scores separately usually
|
||||||
htScoreAway: undefined,
|
htScoreAway: undefined,
|
||||||
homeTeamName: match.homeTeam?.name || 'Unknown',
|
homeTeamName: match.homeTeam?.name || "Unknown",
|
||||||
homeTeamLogo: match.homeTeamId
|
homeTeamLogo: match.homeTeamId
|
||||||
? `https://file.mackolikfeeds.com/teams/${match.homeTeamId}`
|
? `https://file.mackolikfeeds.com/teams/${match.homeTeamId}`
|
||||||
: undefined,
|
: undefined,
|
||||||
awayTeamName: match.awayTeam?.name || 'Unknown',
|
awayTeamName: match.awayTeam?.name || "Unknown",
|
||||||
awayTeamLogo: match.awayTeamId
|
awayTeamLogo: match.awayTeamId
|
||||||
? `https://file.mackolikfeeds.com/teams/${match.awayTeamId}`
|
? `https://file.mackolikfeeds.com/teams/${match.awayTeamId}`
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -390,15 +390,15 @@ export class MatchesService {
|
|||||||
|
|
||||||
// Priority sorting (Mackolik style)
|
// Priority sorting (Mackolik style)
|
||||||
const PRIORITY = [
|
const PRIORITY = [
|
||||||
'Trendyol Süper Lig',
|
"Trendyol Süper Lig",
|
||||||
'Süper Lig',
|
"Süper Lig",
|
||||||
'Trendyol 1. Lig',
|
"Trendyol 1. Lig",
|
||||||
'1. Lig',
|
"1. Lig",
|
||||||
'Premier Lig',
|
"Premier Lig",
|
||||||
'LaLiga',
|
"LaLiga",
|
||||||
'Serie A',
|
"Serie A",
|
||||||
'Bundesliga',
|
"Bundesliga",
|
||||||
'Ligue 1',
|
"Ligue 1",
|
||||||
];
|
];
|
||||||
|
|
||||||
return leagues
|
return leagues
|
||||||
@@ -410,7 +410,7 @@ export class MatchesService {
|
|||||||
const bPriority = bIdx === -1 ? 999 : bIdx;
|
const bPriority = bIdx === -1 ? 999 : bIdx;
|
||||||
|
|
||||||
if (aPriority !== bPriority) return aPriority - bPriority;
|
if (aPriority !== bPriority) return aPriority - bPriority;
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
return (a.name || "").localeCompare(b.name || "");
|
||||||
})
|
})
|
||||||
.map((l) => ({
|
.map((l) => ({
|
||||||
id: l.id,
|
id: l.id,
|
||||||
@@ -439,7 +439,7 @@ export class MatchesService {
|
|||||||
include: { country: true },
|
include: { country: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { mstUtc: 'desc' },
|
orderBy: { mstUtc: "desc" },
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
}),
|
}),
|
||||||
@@ -482,7 +482,7 @@ export class MatchesService {
|
|||||||
createdAt: stat.createdAt,
|
createdAt: stat.createdAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
if ((sport || '').toLowerCase() === 'basketball') {
|
if ((sport || "").toLowerCase() === "basketball") {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
points: stat.points,
|
points: stat.points,
|
||||||
@@ -532,7 +532,7 @@ export class MatchesService {
|
|||||||
basketballTeamStats: true,
|
basketballTeamStats: true,
|
||||||
playerParticipations: {
|
playerParticipations: {
|
||||||
include: { player: true },
|
include: { player: true },
|
||||||
orderBy: [{ isStarting: 'desc' }, { position: 'asc' }],
|
orderBy: [{ isStarting: "desc" }, { position: "asc" }],
|
||||||
},
|
},
|
||||||
playerEvents: {
|
playerEvents: {
|
||||||
include: {
|
include: {
|
||||||
@@ -540,7 +540,7 @@ export class MatchesService {
|
|||||||
assistPlayer: true,
|
assistPlayer: true,
|
||||||
substitutedOut: true,
|
substitutedOut: true,
|
||||||
},
|
},
|
||||||
orderBy: [{ periodId: 'asc' }, { timeMinute: 'asc' }],
|
orderBy: [{ periodId: "asc" }, { timeMinute: "asc" }],
|
||||||
},
|
},
|
||||||
oddCategories: {
|
oddCategories: {
|
||||||
include: { selections: true },
|
include: { selections: true },
|
||||||
@@ -562,15 +562,15 @@ export class MatchesService {
|
|||||||
|
|
||||||
if (liveMatch) {
|
if (liveMatch) {
|
||||||
// Map liveMatch status
|
// Map liveMatch status
|
||||||
let displayStatus = liveMatch.status || 'NS';
|
let displayStatus = liveMatch.status || "NS";
|
||||||
if (liveMatch.state === 'live') {
|
if (liveMatch.state === "live") {
|
||||||
displayStatus = 'LIVE';
|
displayStatus = "LIVE";
|
||||||
} else if (
|
} else if (
|
||||||
liveMatch.state === 'post' ||
|
liveMatch.state === "post" ||
|
||||||
liveMatch.state === 'FT' ||
|
liveMatch.state === "FT" ||
|
||||||
liveMatch.status === 'Finished'
|
liveMatch.status === "Finished"
|
||||||
) {
|
) {
|
||||||
displayStatus = 'Finished';
|
displayStatus = "Finished";
|
||||||
}
|
}
|
||||||
|
|
||||||
match = {
|
match = {
|
||||||
@@ -607,14 +607,14 @@ export class MatchesService {
|
|||||||
if (
|
if (
|
||||||
match.isLiveSource &&
|
match.isLiveSource &&
|
||||||
match.odds &&
|
match.odds &&
|
||||||
typeof match.odds === 'object' &&
|
typeof match.odds === "object" &&
|
||||||
!Array.isArray(match.odds)
|
!Array.isArray(match.odds)
|
||||||
) {
|
) {
|
||||||
// Parse JSON odds from LiveMatch
|
// Parse JSON odds from LiveMatch
|
||||||
const oddsObj = match.odds as Record<string, Record<string, number>>;
|
const oddsObj = match.odds as Record<string, Record<string, number>>;
|
||||||
for (const [marketName, selections] of Object.entries(oddsObj)) {
|
for (const [marketName, selections] of Object.entries(oddsObj)) {
|
||||||
odds[marketName] = {};
|
odds[marketName] = {};
|
||||||
if (selections && typeof selections === 'object') {
|
if (selections && typeof selections === "object") {
|
||||||
for (const [selName, selOdd] of Object.entries(selections)) {
|
for (const [selName, selOdd] of Object.entries(selections)) {
|
||||||
odds[marketName][selName] = { odd: String(selOdd) };
|
odds[marketName][selName] = { odd: String(selOdd) };
|
||||||
}
|
}
|
||||||
@@ -628,7 +628,7 @@ export class MatchesService {
|
|||||||
for (const sel of cat.selections) {
|
for (const sel of cat.selections) {
|
||||||
if (sel.name) {
|
if (sel.name) {
|
||||||
odds[cat.name][sel.name] = {
|
odds[cat.name][sel.name] = {
|
||||||
odd: sel.oddValue || '',
|
odd: sel.oddValue || "",
|
||||||
sov: sel.sov ?? undefined,
|
sov: sel.sov ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -637,7 +637,7 @@ export class MatchesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sportStats =
|
const sportStats =
|
||||||
match.sport === 'basketball'
|
match.sport === "basketball"
|
||||||
? match.basketballTeamStats || []
|
? match.basketballTeamStats || []
|
||||||
: match.footballTeamStats || [];
|
: match.footballTeamStats || [];
|
||||||
const normalizedTeamStats = sportStats.map((s: any) =>
|
const normalizedTeamStats = sportStats.map((s: any) =>
|
||||||
@@ -692,7 +692,7 @@ export class MatchesService {
|
|||||||
// Fuzzy search
|
// Fuzzy search
|
||||||
team = await this.prisma.team.findFirst({
|
team = await this.prisma.team.findFirst({
|
||||||
where: {
|
where: {
|
||||||
name: { contains: trimmedName, mode: 'insensitive' },
|
name: { contains: trimmedName, mode: "insensitive" },
|
||||||
sport: sport as any,
|
sport: sport as any,
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
|
||||||
export type SignalTier =
|
export type SignalTier = "CORE" | "VALUE" | "LEAN" | "LONGSHOT" | "PASS";
|
||||||
| 'CORE'
|
|
||||||
| 'VALUE'
|
|
||||||
| 'LEAN'
|
|
||||||
| 'LONGSHOT'
|
|
||||||
| 'PASS';
|
|
||||||
|
|
||||||
export class MatchInfoDto {
|
export class MatchInfoDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -34,14 +29,14 @@ export class MatchInfoDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
required: false,
|
||||||
enum: ['football', 'basketball'],
|
enum: ["football", "basketball"],
|
||||||
})
|
})
|
||||||
sport?: 'football' | 'basketball';
|
sport?: "football" | "basketball";
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DataQualityDto {
|
export class DataQualityDto {
|
||||||
@ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] })
|
@ApiProperty({ enum: ["HIGH", "MEDIUM", "LOW"] })
|
||||||
label: 'HIGH' | 'MEDIUM' | 'LOW';
|
label: "HIGH" | "MEDIUM" | "LOW";
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
score: number;
|
score: number;
|
||||||
@@ -52,7 +47,7 @@ export class DataQualityDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
away_lineup_count: number;
|
away_lineup_count: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false, default: 'none' })
|
@ApiProperty({ required: false, default: "none" })
|
||||||
lineup_source?: string;
|
lineup_source?: string;
|
||||||
|
|
||||||
@ApiProperty({ type: [String] })
|
@ApiProperty({ type: [String] })
|
||||||
@@ -69,16 +64,16 @@ export class ConfidenceIntervalDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
width: number;
|
width: number;
|
||||||
|
|
||||||
@ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] })
|
@ApiProperty({ enum: ["HIGH", "MEDIUM", "LOW"] })
|
||||||
band: 'HIGH' | 'MEDIUM' | 'LOW';
|
band: "HIGH" | "MEDIUM" | "LOW";
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
threshold_met: boolean;
|
threshold_met: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RiskDto {
|
export class RiskDto {
|
||||||
@ApiProperty({ enum: ['LOW', 'MEDIUM', 'HIGH', 'EXTREME'] })
|
@ApiProperty({ enum: ["LOW", "MEDIUM", "HIGH", "EXTREME"] })
|
||||||
level: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
|
level: "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
score: number;
|
score: number;
|
||||||
@@ -156,8 +151,8 @@ export class MatchPickDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
playable: boolean;
|
playable: boolean;
|
||||||
|
|
||||||
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
|
@ApiProperty({ enum: ["A", "B", "C", "PASS"] })
|
||||||
bet_grade: 'A' | 'B' | 'C' | 'PASS';
|
bet_grade: "A" | "B" | "C" | "PASS";
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
stake_units: number;
|
stake_units: number;
|
||||||
@@ -170,7 +165,7 @@ export class MatchPickDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
required: false,
|
||||||
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
|
enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"],
|
||||||
})
|
})
|
||||||
signal_tier?: SignalTier;
|
signal_tier?: SignalTier;
|
||||||
}
|
}
|
||||||
@@ -185,15 +180,15 @@ export class MatchBetAdviceDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
reason: string;
|
reason: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false, enum: ['HIGH', 'MEDIUM', 'LOW'] })
|
@ApiProperty({ required: false, enum: ["HIGH", "MEDIUM", "LOW"] })
|
||||||
confidence_band?: 'HIGH' | 'MEDIUM' | 'LOW';
|
confidence_band?: "HIGH" | "MEDIUM" | "LOW";
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
min_confidence_for_play?: number;
|
min_confidence_for_play?: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
required: false,
|
||||||
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
|
enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"],
|
||||||
})
|
})
|
||||||
signal_tier?: SignalTier;
|
signal_tier?: SignalTier;
|
||||||
}
|
}
|
||||||
@@ -211,8 +206,8 @@ export class MatchBetSummaryItemDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
calibrated_confidence: number;
|
calibrated_confidence: number;
|
||||||
|
|
||||||
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
|
@ApiProperty({ enum: ["A", "B", "C", "PASS"] })
|
||||||
bet_grade: 'A' | 'B' | 'C' | 'PASS';
|
bet_grade: "A" | "B" | "C" | "PASS";
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
playable: boolean;
|
playable: boolean;
|
||||||
@@ -240,30 +235,30 @@ export class MatchBetSummaryItemDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
required: false,
|
||||||
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
|
enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"],
|
||||||
})
|
})
|
||||||
signal_tier?: SignalTier;
|
signal_tier?: SignalTier;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HtFtPredictionDto {
|
export class HtFtPredictionDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
'1/1': number;
|
"1/1": number;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
'1/X': number;
|
"1/X": number;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
'1/2': number;
|
"1/2": number;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
'X/1': number;
|
"X/1": number;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
'X/X': number;
|
"X/X": number;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
'X/2': number;
|
"X/2": number;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
'2/1': number;
|
"2/1": number;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
'2/X': number;
|
"2/X": number;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
'2/2': number;
|
"2/2": number;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
pick: string;
|
pick: string;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -310,8 +305,8 @@ export class AggressivePickDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
playable: boolean;
|
playable: boolean;
|
||||||
|
|
||||||
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
|
@ApiProperty({ enum: ["A", "B", "C", "PASS"] })
|
||||||
bet_grade: 'A' | 'B' | 'C' | 'PASS';
|
bet_grade: "A" | "B" | "C" | "PASS";
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
stake_units: number;
|
stake_units: number;
|
||||||
@@ -468,4 +463,4 @@ export class AIHealthDto {
|
|||||||
predictionServiceReady: boolean;
|
predictionServiceReady: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export * from './smart-coupon.dto';
|
export * from "./smart-coupon.dto";
|
||||||
|
|||||||
@@ -8,28 +8,28 @@ import {
|
|||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
Min,
|
Min,
|
||||||
Max,
|
Max,
|
||||||
} from 'class-validator';
|
} from "class-validator";
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
|
||||||
export class GeneratePredictionDto {
|
export class GeneratePredictionDto {
|
||||||
@ApiProperty({ description: 'Match ID to generate prediction for' })
|
@ApiProperty({ description: "Match ID to generate prediction for" })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
matchId: string;
|
matchId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CouponStrategy {
|
export enum CouponStrategy {
|
||||||
SAFE = 'SAFE',
|
SAFE = "SAFE",
|
||||||
BALANCED = 'BALANCED',
|
BALANCED = "BALANCED",
|
||||||
AGGRESSIVE = 'AGGRESSIVE',
|
AGGRESSIVE = "AGGRESSIVE",
|
||||||
VALUE = 'VALUE',
|
VALUE = "VALUE",
|
||||||
MIRACLE = 'MIRACLE',
|
MIRACLE = "MIRACLE",
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SmartCouponRequestDto {
|
export class SmartCouponRequestDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'List of match IDs for coupon',
|
description: "List of match IDs for coupon",
|
||||||
example: ['match-1', 'match-2'],
|
example: ["match-1", "match-2"],
|
||||||
})
|
})
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
@@ -44,7 +44,7 @@ export class SmartCouponRequestDto {
|
|||||||
@IsEnum(CouponStrategy)
|
@IsEnum(CouponStrategy)
|
||||||
strategy?: CouponStrategy;
|
strategy?: CouponStrategy;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 })
|
@ApiPropertyOptional({ description: "Maximum matches in coupon", example: 5 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@@ -52,7 +52,7 @@ export class SmartCouponRequestDto {
|
|||||||
maxMatches?: number;
|
maxMatches?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Minimum confidence threshold (0-100)',
|
description: "Minimum confidence threshold (0-100)",
|
||||||
example: 60,
|
example: 60,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type CouponStrategy =
|
export type CouponStrategy =
|
||||||
| 'SAFE'
|
| "SAFE"
|
||||||
| 'BALANCED'
|
| "BALANCED"
|
||||||
| 'AGGRESSIVE'
|
| "AGGRESSIVE"
|
||||||
| 'VALUE'
|
| "VALUE"
|
||||||
| 'MIRACLE';
|
| "MIRACLE";
|
||||||
|
|
||||||
export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
|
export type RiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
|
||||||
export type DataQualityLabel = 'HIGH' | 'MEDIUM' | 'LOW';
|
export type DataQualityLabel = "HIGH" | "MEDIUM" | "LOW";
|
||||||
|
|
||||||
export interface SmartCouponRequestDto {
|
export interface SmartCouponRequestDto {
|
||||||
match_ids: string[];
|
match_ids: string[];
|
||||||
|
|||||||
@@ -7,24 +7,24 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from "@nestjs/swagger";
|
||||||
import { PredictionsService } from './predictions.service';
|
import { PredictionsService } from "./predictions.service";
|
||||||
import {
|
import {
|
||||||
MatchPredictionDto,
|
MatchPredictionDto,
|
||||||
PredictionHistoryResponseDto,
|
PredictionHistoryResponseDto,
|
||||||
UpcomingPredictionsDto,
|
UpcomingPredictionsDto,
|
||||||
ValueBetDto,
|
ValueBetDto,
|
||||||
AIHealthDto,
|
AIHealthDto,
|
||||||
} from './dto';
|
} from "./dto";
|
||||||
import {
|
import {
|
||||||
GeneratePredictionDto,
|
GeneratePredictionDto,
|
||||||
SmartCouponRequestDto,
|
SmartCouponRequestDto,
|
||||||
} from './dto/predictions-request.dto';
|
} from "./dto/predictions-request.dto";
|
||||||
import { Public } from 'src/common/decorators';
|
import { Public } from "src/common/decorators";
|
||||||
|
|
||||||
@ApiTags('Predictions')
|
@ApiTags("Predictions")
|
||||||
@Controller('predictions')
|
@Controller("predictions")
|
||||||
export class PredictionsController {
|
export class PredictionsController {
|
||||||
constructor(private readonly predictionsService: PredictionsService) {}
|
constructor(private readonly predictionsService: PredictionsService) {}
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ export class PredictionsController {
|
|||||||
* GET /predictions/health
|
* GET /predictions/health
|
||||||
* Check AI Engine health status
|
* Check AI Engine health status
|
||||||
*/
|
*/
|
||||||
@Get('health')
|
@Get("health")
|
||||||
@ApiOperation({ summary: 'Check AI Engine health status' })
|
@ApiOperation({ summary: "Check AI Engine health status" })
|
||||||
@ApiResponse({ status: 200, type: AIHealthDto })
|
@ApiResponse({ status: 200, type: AIHealthDto })
|
||||||
async checkHealth(): Promise<AIHealthDto> {
|
async checkHealth(): Promise<AIHealthDto> {
|
||||||
return this.predictionsService.checkHealth();
|
return this.predictionsService.checkHealth();
|
||||||
@@ -43,8 +43,8 @@ export class PredictionsController {
|
|||||||
* GET /predictions/upcoming
|
* GET /predictions/upcoming
|
||||||
* Get predictions for upcoming matches
|
* Get predictions for upcoming matches
|
||||||
*/
|
*/
|
||||||
@Get('upcoming')
|
@Get("upcoming")
|
||||||
@ApiOperation({ summary: 'Get predictions for upcoming matches' })
|
@ApiOperation({ summary: "Get predictions for upcoming matches" })
|
||||||
@ApiResponse({ status: 200, type: UpcomingPredictionsDto })
|
@ApiResponse({ status: 200, type: UpcomingPredictionsDto })
|
||||||
async getUpcoming(): Promise<UpcomingPredictionsDto> {
|
async getUpcoming(): Promise<UpcomingPredictionsDto> {
|
||||||
return this.predictionsService.getUpcomingPredictions();
|
return this.predictionsService.getUpcomingPredictions();
|
||||||
@@ -54,10 +54,10 @@ export class PredictionsController {
|
|||||||
* GET /predictions/test/:id
|
* GET /predictions/test/:id
|
||||||
* Refetch match data and get prediction
|
* Refetch match data and get prediction
|
||||||
*/
|
*/
|
||||||
@Get('test/:id')
|
@Get("test/:id")
|
||||||
@ApiOperation({ summary: 'Refetch match data and get prediction' })
|
@ApiOperation({ summary: "Refetch match data and get prediction" })
|
||||||
@ApiParam({ name: 'id', description: 'Match ID' })
|
@ApiParam({ name: "id", description: "Match ID" })
|
||||||
async getTestPrediction(@Param('id') id: string) {
|
async getTestPrediction(@Param("id") id: string) {
|
||||||
return this.predictionsService.testPrediction(id);
|
return this.predictionsService.testPrediction(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +65,8 @@ export class PredictionsController {
|
|||||||
* GET /predictions/value-bets
|
* GET /predictions/value-bets
|
||||||
* Get EV+ betting opportunities
|
* Get EV+ betting opportunities
|
||||||
*/
|
*/
|
||||||
@Get('value-bets')
|
@Get("value-bets")
|
||||||
@ApiOperation({ summary: 'Get value betting opportunities (EV+)' })
|
@ApiOperation({ summary: "Get value betting opportunities (EV+)" })
|
||||||
@ApiResponse({ status: 200, type: [ValueBetDto] })
|
@ApiResponse({ status: 200, type: [ValueBetDto] })
|
||||||
async getValueBets(): Promise<ValueBetDto[]> {
|
async getValueBets(): Promise<ValueBetDto[]> {
|
||||||
return this.predictionsService.getValueBets();
|
return this.predictionsService.getValueBets();
|
||||||
@@ -76,8 +76,8 @@ export class PredictionsController {
|
|||||||
* GET /predictions/history
|
* GET /predictions/history
|
||||||
* Get prediction history and accuracy stats
|
* Get prediction history and accuracy stats
|
||||||
*/
|
*/
|
||||||
@Get('history')
|
@Get("history")
|
||||||
@ApiOperation({ summary: 'Get prediction history and accuracy statistics' })
|
@ApiOperation({ summary: "Get prediction history and accuracy statistics" })
|
||||||
@ApiResponse({ status: 200, type: PredictionHistoryResponseDto })
|
@ApiResponse({ status: 200, type: PredictionHistoryResponseDto })
|
||||||
async getHistory(): Promise<PredictionHistoryResponseDto> {
|
async getHistory(): Promise<PredictionHistoryResponseDto> {
|
||||||
return this.predictionsService.getPredictionHistory();
|
return this.predictionsService.getPredictionHistory();
|
||||||
@@ -87,14 +87,14 @@ export class PredictionsController {
|
|||||||
* GET /predictions/:matchId
|
* GET /predictions/:matchId
|
||||||
* Get prediction for a specific match
|
* Get prediction for a specific match
|
||||||
*/
|
*/
|
||||||
@Get(':matchId')
|
@Get(":matchId")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Get prediction for a specific match' })
|
@ApiOperation({ summary: "Get prediction for a specific match" })
|
||||||
@ApiParam({ name: 'matchId', description: 'Match ID' })
|
@ApiParam({ name: "matchId", description: "Match ID" })
|
||||||
@ApiResponse({ status: 200, type: MatchPredictionDto })
|
@ApiResponse({ status: 200, type: MatchPredictionDto })
|
||||||
@ApiResponse({ status: 404, description: 'Match not found' })
|
@ApiResponse({ status: 404, description: "Match not found" })
|
||||||
async getPrediction(
|
async getPrediction(
|
||||||
@Param('matchId') matchId: string,
|
@Param("matchId") matchId: string,
|
||||||
): Promise<MatchPredictionDto> {
|
): Promise<MatchPredictionDto> {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cached = await this.predictionsService.getCachedPrediction(matchId);
|
const cached = await this.predictionsService.getCachedPrediction(matchId);
|
||||||
@@ -119,9 +119,9 @@ export class PredictionsController {
|
|||||||
* POST /predictions/generate
|
* POST /predictions/generate
|
||||||
* Generate prediction with provided match data
|
* Generate prediction with provided match data
|
||||||
*/
|
*/
|
||||||
@Post('generate')
|
@Post("generate")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Generate prediction with provided match data' })
|
@ApiOperation({ summary: "Generate prediction with provided match data" })
|
||||||
@ApiResponse({ status: 200, type: MatchPredictionDto })
|
@ApiResponse({ status: 200, type: MatchPredictionDto })
|
||||||
async generatePrediction(
|
async generatePrediction(
|
||||||
@Body() dto: GeneratePredictionDto,
|
@Body() dto: GeneratePredictionDto,
|
||||||
@@ -131,7 +131,7 @@ export class PredictionsController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!prediction) {
|
if (!prediction) {
|
||||||
throw new NotFoundException('Failed to generate prediction');
|
throw new NotFoundException("Failed to generate prediction");
|
||||||
}
|
}
|
||||||
|
|
||||||
return prediction;
|
return prediction;
|
||||||
@@ -141,19 +141,19 @@ export class PredictionsController {
|
|||||||
* POST /predictions/smart-coupon
|
* POST /predictions/smart-coupon
|
||||||
* Generate Smart Coupon using AI Engine V20
|
* Generate Smart Coupon using AI Engine V20
|
||||||
*/
|
*/
|
||||||
@Post('smart-coupon')
|
@Post("smart-coupon")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Generate Smart Coupon with V20 AI recommendations',
|
summary: "Generate Smart Coupon with V20 AI recommendations",
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Smart coupon generated successfully',
|
description: "Smart coupon generated successfully",
|
||||||
})
|
})
|
||||||
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> {
|
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> {
|
||||||
const coupon = await this.predictionsService.getSmartCoupon(
|
const coupon = await this.predictionsService.getSmartCoupon(
|
||||||
dto.matchIds,
|
dto.matchIds,
|
||||||
dto.strategy || 'BALANCED',
|
dto.strategy || "BALANCED",
|
||||||
{
|
{
|
||||||
maxMatches: dto.maxMatches,
|
maxMatches: dto.maxMatches,
|
||||||
minConfidence: dto.minConfidence,
|
minConfidence: dto.minConfidence,
|
||||||
@@ -161,7 +161,7 @@ export class PredictionsController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!coupon) {
|
if (!coupon) {
|
||||||
throw new NotFoundException('Failed to generate Smart Coupon');
|
throw new NotFoundException("Failed to generate Smart Coupon");
|
||||||
}
|
}
|
||||||
|
|
||||||
return coupon;
|
return coupon;
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from "@nestjs/bullmq";
|
||||||
import { PredictionsController } from './predictions.controller';
|
import { PredictionsController } from "./predictions.controller";
|
||||||
import { PredictionsService } from './predictions.service';
|
import { PredictionsService } from "./predictions.service";
|
||||||
import { AiFeatureStoreService } from './services/ai-feature-store.service';
|
import { AiFeatureStoreService } from "./services/ai-feature-store.service";
|
||||||
import { DatabaseModule } from '../../database/database.module';
|
import { DatabaseModule } from "../../database/database.module";
|
||||||
import { MatchesModule } from '../matches/matches.module';
|
import { MatchesModule } from "../matches/matches.module";
|
||||||
import { PredictionsQueue } from './queues/predictions.queue';
|
import { PredictionsQueue } from "./queues/predictions.queue";
|
||||||
import { PredictionsProcessor } from './queues/predictions.processor';
|
import { PredictionsProcessor } from "./queues/predictions.processor";
|
||||||
import { PREDICTIONS_QUEUE } from './queues/predictions.types';
|
import { PREDICTIONS_QUEUE } from "./queues/predictions.types";
|
||||||
import { FeederModule } from '../feeder/feeder.module';
|
import { FeederModule } from "../feeder/feeder.module";
|
||||||
|
|
||||||
const redisEnabled = process.env.REDIS_ENABLED === 'true';
|
const redisEnabled = process.env.REDIS_ENABLED === "true";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -6,26 +6,26 @@ import {
|
|||||||
OnModuleDestroy,
|
OnModuleDestroy,
|
||||||
OnModuleInit,
|
OnModuleInit,
|
||||||
Optional,
|
Optional,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { QueueEvents } from 'bullmq';
|
import { QueueEvents } from "bullmq";
|
||||||
import { PredictionsQueue } from './queues/predictions.queue';
|
import { PredictionsQueue } from "./queues/predictions.queue";
|
||||||
import { PREDICTIONS_QUEUE } from './queues/predictions.types';
|
import { PREDICTIONS_QUEUE } from "./queues/predictions.types";
|
||||||
import {
|
import {
|
||||||
MatchPredictionDto,
|
MatchPredictionDto,
|
||||||
PredictionHistoryResponseDto,
|
PredictionHistoryResponseDto,
|
||||||
UpcomingPredictionsDto,
|
UpcomingPredictionsDto,
|
||||||
ValueBetDto,
|
ValueBetDto,
|
||||||
AIHealthDto,
|
AIHealthDto,
|
||||||
} from './dto';
|
} from "./dto";
|
||||||
import axios, { AxiosError } from 'axios';
|
import axios, { AxiosError } from "axios";
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from "@prisma/client";
|
||||||
import { FeederService } from '../feeder/feeder.service';
|
import { FeederService } from "../feeder/feeder.service";
|
||||||
import * as fs from 'node:fs';
|
import * as fs from "node:fs";
|
||||||
import * as path from 'node:path';
|
import * as path from "node:path";
|
||||||
|
|
||||||
type ConfidenceBand = 'HIGH' | 'MEDIUM' | 'LOW';
|
type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW";
|
||||||
|
|
||||||
interface ConfidenceInterval {
|
interface ConfidenceInterval {
|
||||||
lower: number;
|
lower: number;
|
||||||
@@ -47,73 +47,72 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly aiEngineUrl: string;
|
private readonly aiEngineUrl: string;
|
||||||
private readonly topLeagueIds = new Set<string>();
|
private readonly topLeagueIds = new Set<string>();
|
||||||
private readonly reasonTranslations: Record<string, string> = {
|
private readonly reasonTranslations: Record<string, string> = {
|
||||||
confidence_below_threshold: 'Güven eşiğin altında',
|
confidence_below_threshold: "Güven eşiğin altında",
|
||||||
confidence_interval_too_wide: 'Güven aralığı çok geniş',
|
confidence_interval_too_wide: "Güven aralığı çok geniş",
|
||||||
confidence_interval_too_wide_for_main_pick:
|
confidence_interval_too_wide_for_main_pick:
|
||||||
'Ana seçim için güven aralığı çok geniş',
|
"Ana seçim için güven aralığı çok geniş",
|
||||||
confidence_band_low: 'Güven bandı düşük',
|
confidence_band_low: "Güven bandı düşük",
|
||||||
playable_edge_found: 'Oynanabilir avantaj bulundu',
|
playable_edge_found: "Oynanabilir avantaj bulundu",
|
||||||
market_signal_dominant: 'Piyasa sinyali baskın',
|
market_signal_dominant: "Piyasa sinyali baskın",
|
||||||
team_form_signal_dominant: 'Takım formuna dayalı sinyaller çok 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_strong: "İlk on bir sinyali güçlü",
|
||||||
lineup_signal_weak: 'İlk on bir sinyali zayıf',
|
lineup_signal_weak: "İlk on bir sinyali zayıf",
|
||||||
lineup_probable_xi_used: 'Muhtemel ilk on bir kullanıldı',
|
lineup_probable_xi_used: "Muhtemel ilk on bir kullanıldı",
|
||||||
lineup_probable_not_confirmed: 'Muhtemel ilk on bir henüz doğrulanmadı',
|
lineup_probable_not_confirmed: "Muhtemel ilk on bir henüz doğrulanmadı",
|
||||||
lineup_unavailable: 'İlk on bir bilgisi mevcut değil',
|
lineup_unavailable: "İlk on bir bilgisi mevcut değil",
|
||||||
lineup_incomplete: 'İlk on bir bilgisi eksik',
|
lineup_incomplete: "İlk on bir bilgisi eksik",
|
||||||
missing_referee: 'Hakem verisi eksik',
|
missing_referee: "Hakem verisi eksik",
|
||||||
draw_probability_elevated: 'Beraberlik olasılığı yükselmiş görünüyor',
|
draw_probability_elevated: "Beraberlik olasılığı yükselmiş görünüyor",
|
||||||
balanced_match_risk: 'Maç dengeli, sürpriz riski yükseliyor',
|
balanced_match_risk: "Maç dengeli, sürpriz riski yükseliyor",
|
||||||
draw_pressure: 'Beraberlik baskısı yüksek',
|
draw_pressure: "Beraberlik baskısı yüksek",
|
||||||
upset_risk_detected: 'Sürpriz riski tespit edildi',
|
upset_risk_detected: "Sürpriz riski tespit edildi",
|
||||||
limited_data_confidence: 'Veri kısıtlı olduğu için güven sınırlı',
|
limited_data_confidence: "Veri kısıtlı olduğu için güven sınırlı",
|
||||||
data_quality_issue: 'Veri kalitesi sorunu var',
|
data_quality_issue: "Veri kalitesi sorunu var",
|
||||||
high_risk_low_data_quality: 'Risk yüksek, veri kalitesi düşük',
|
high_risk_low_data_quality: "Risk yüksek, veri kalitesi düşük",
|
||||||
insufficient_play_score: 'Oynanabilirlik puanı yetersiz',
|
insufficient_play_score: "Oynanabilirlik puanı yetersiz",
|
||||||
no_bet_conditions_met: 'Bahis koşulları oluşmadı',
|
no_bet_conditions_met: "Bahis koşulları oluşmadı",
|
||||||
market_passed_all_gates: 'Market tüm güvenlik kontrollerini geçti',
|
market_passed_all_gates: "Market tüm güvenlik kontrollerini geçti",
|
||||||
no_ev_edge_minimum_stake:
|
no_ev_edge_minimum_stake:
|
||||||
'Beklenen avantaj oluşmadı, minimum bahis önerildi',
|
"Beklenen avantaj oluşmadı, minimum bahis önerildi",
|
||||||
player_form_signal_strong: 'Oyuncu formu sinyali güçlü',
|
player_form_signal_strong: "Oyuncu formu sinyali güçlü",
|
||||||
player_form_signal_limited: 'Oyuncu formu sinyali sınırlı',
|
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_state_impossible_market:
|
||||||
live_score_exceeds_under_line:
|
"Canlı maç durumu bu marketi geçersiz kılıyor",
|
||||||
'Mevcut skor bu alt seçeneğiyle çelişiyor',
|
live_score_exceeds_under_line: "Mevcut skor bu alt seçeneğiyle çelişiyor",
|
||||||
score_model_conflicts_with_under_pick:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
first_half_goal_pressure_conflicts_with_htft_draw:
|
||||||
'İlk yarı gol baskısı ilk yarı beraberlik kurgusuyla çelişiyor',
|
"İlk yarı gol baskısı ilk yarı beraberlik kurgusuyla çelişiyor",
|
||||||
live_total_goals_close_to_line:
|
live_total_goals_close_to_line: "Canlı toplam gol çizgisine çok yakın",
|
||||||
'Canlı toplam gol çizgisine çok yakın',
|
|
||||||
score_model_conflicts_with_btts_no:
|
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:
|
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:
|
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:
|
score_model_conflicts_with_away_pick:
|
||||||
'Skor modeli deplasman seçeneğiyle çelişiyor',
|
"Skor modeli deplasman seçeneğiyle çelişiyor",
|
||||||
high_total_goal_volatility: 'Toplam gol volatilitesi yüksek',
|
high_total_goal_volatility: "Toplam gol volatilitesi yüksek",
|
||||||
mutual_goal_pressure: 'İki takımın da gol baskısı 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',
|
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_open_state: "Canlı maç açık oyuna dönmüş durumda",
|
||||||
live_match_active_state: 'Canlı maç aktif ve dalgalı ilerliyor',
|
live_match_active_state: "Canlı maç aktif ve dalgalı ilerliyor",
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -123,8 +122,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
@Optional() private readonly predictionsQueue?: PredictionsQueue,
|
@Optional() private readonly predictionsQueue?: PredictionsQueue,
|
||||||
) {
|
) {
|
||||||
this.aiEngineUrl = this.configService.get(
|
this.aiEngineUrl = this.configService.get(
|
||||||
'AI_ENGINE_URL',
|
"AI_ENGINE_URL",
|
||||||
'http://localhost:8000',
|
"http://localhost:8000",
|
||||||
);
|
);
|
||||||
this.topLeagueIds = this.loadTopLeagueIds();
|
this.topLeagueIds = this.loadTopLeagueIds();
|
||||||
}
|
}
|
||||||
@@ -133,14 +132,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (this.predictionsQueue) {
|
if (this.predictionsQueue) {
|
||||||
this.queueEvents = new QueueEvents(PREDICTIONS_QUEUE, {
|
this.queueEvents = new QueueEvents(PREDICTIONS_QUEUE, {
|
||||||
connection: {
|
connection: {
|
||||||
host: this.configService.get('redis.host', 'localhost'),
|
host: this.configService.get("redis.host", "localhost"),
|
||||||
port: this.configService.get('redis.port', 6379),
|
port: this.configService.get("redis.port", 6379),
|
||||||
password: this.configService.get('redis.password'),
|
password: this.configService.get("redis.password"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.logger.log('Queue mode enabled for predictions');
|
this.logger.log("Queue mode enabled for predictions");
|
||||||
} else {
|
} 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> {
|
checkHealth(): Promise<AIHealthDto> {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
status: 'healthy',
|
status: "healthy",
|
||||||
modelLoaded: true,
|
modelLoaded: true,
|
||||||
predictionServiceReady: true,
|
predictionServiceReady: true,
|
||||||
});
|
});
|
||||||
@@ -212,12 +211,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
if (status === 422) {
|
if (status === 422) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
`AI Engine: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`,
|
`AI Engine: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`,
|
||||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new HttpException(
|
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,
|
status || HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -232,7 +231,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
async testPrediction(matchId: string): Promise<MatchPredictionDto | null> {
|
async testPrediction(matchId: string): Promise<MatchPredictionDto | null> {
|
||||||
this.logger.log(`[TEST PREDICTION] Syncing match data for ${matchId}...`);
|
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
|
// 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) {
|
if (!refreshResult.success) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -251,7 +250,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const upcoming = await this.prisma.prediction.findMany({
|
const upcoming = await this.prisma.prediction.findMany({
|
||||||
where: {
|
where: {
|
||||||
match: {
|
match: {
|
||||||
status: 'NS',
|
status: "NS",
|
||||||
mstUtc: { gte: Math.floor(Date.now() / 1000) },
|
mstUtc: { gte: Math.floor(Date.now() / 1000) },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -260,13 +259,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
include: { homeTeam: true, awayTeam: true, league: true },
|
include: { homeTeam: true, awayTeam: true, league: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { match: { mstUtc: 'asc' } },
|
orderBy: { match: { mstUtc: "asc" } },
|
||||||
take: 50,
|
take: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
count: upcoming.length,
|
count: upcoming.length,
|
||||||
modelVersion: 'v25-v30-ensemble',
|
modelVersion: "v25-v30-ensemble",
|
||||||
matches: upcoming.map((p) => {
|
matches: upcoming.map((p) => {
|
||||||
const out = p.predictionJson as Record<string, unknown>;
|
const out = p.predictionJson as Record<string, unknown>;
|
||||||
const matchInfo = (out?.match_info || {}) as Record<string, unknown>;
|
const matchInfo = (out?.match_info || {}) as Record<string, unknown>;
|
||||||
@@ -276,9 +275,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
...matchInfo,
|
...matchInfo,
|
||||||
match_name: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`,
|
match_name: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`,
|
||||||
match_date_ms: Number(p.match.mstUtc) * 1000,
|
match_date_ms: Number(p.match.mstUtc) * 1000,
|
||||||
league: p.match.league?.name || '',
|
league: p.match.league?.name || "",
|
||||||
league_id: p.match.leagueId,
|
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;
|
} as unknown as MatchPredictionDto;
|
||||||
}),
|
}),
|
||||||
@@ -287,12 +286,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
private loadTopLeagueIds(): Set<string> {
|
private loadTopLeagueIds(): Set<string> {
|
||||||
try {
|
try {
|
||||||
const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json');
|
const topLeaguesPath = path.join(process.cwd(), "top_leagues.json");
|
||||||
if (!fs.existsSync(topLeaguesPath)) {
|
if (!fs.existsSync(topLeaguesPath)) {
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8'));
|
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
|
||||||
if (!Array.isArray(raw)) {
|
if (!Array.isArray(raw)) {
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
}
|
}
|
||||||
@@ -318,7 +317,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (match) {
|
if (match) {
|
||||||
return {
|
return {
|
||||||
leagueId: match.leagueId ?? null,
|
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 {
|
return {
|
||||||
leagueId: liveMatch?.leagueId ?? null,
|
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:
|
league_id:
|
||||||
this.asRecord(response.match_info).league_id ?? matchContext.leagueId,
|
this.asRecord(response.match_info).league_id ?? matchContext.leagueId,
|
||||||
is_top_league:
|
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(
|
const mainPick = this.enrichPick(
|
||||||
@@ -369,9 +369,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const supportingPicks = Array.isArray(response.supporting_picks)
|
const supportingPicks = Array.isArray(response.supporting_picks)
|
||||||
? response.supporting_picks.map((pick) =>
|
? response.supporting_picks
|
||||||
this.enrichPick(pick, response, matchContext, marketBoard),
|
.map((pick) =>
|
||||||
).filter((pick): pick is NonNullable<typeof pick> => pick !== null)
|
this.enrichPick(pick, response, matchContext, marketBoard),
|
||||||
|
)
|
||||||
|
.filter((pick): pick is NonNullable<typeof pick> => pick !== null)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const betSummary = Array.isArray(response.bet_summary)
|
const betSummary = Array.isArray(response.bet_summary)
|
||||||
@@ -380,8 +382,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const mainBand =
|
const mainBand = this.asRecord(mainPick?.confidence_interval).band ?? "LOW";
|
||||||
this.asRecord(mainPick?.confidence_interval).band ?? 'LOW';
|
|
||||||
const minConfidenceForPlay = this.getMinConfidenceForPlay(
|
const minConfidenceForPlay = this.getMinConfidenceForPlay(
|
||||||
this.asRecord(mainPick).market,
|
this.asRecord(mainPick).market,
|
||||||
matchContext.isTopLeague,
|
matchContext.isTopLeague,
|
||||||
@@ -402,7 +403,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
if (mainPick && !isMainPlayable) {
|
if (mainPick && !isMainPlayable) {
|
||||||
reasoningFactors.unshift(
|
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
|
isMainPlayable
|
||||||
? String(
|
? String(
|
||||||
this.asRecord(response.bet_advice).reason ||
|
this.asRecord(response.bet_advice).reason ||
|
||||||
'playable_edge_found',
|
"playable_edge_found",
|
||||||
)
|
)
|
||||||
: 'confidence_below_threshold',
|
: "confidence_below_threshold",
|
||||||
),
|
),
|
||||||
suggested_stake_units: isMainPlayable
|
suggested_stake_units: isMainPlayable
|
||||||
? Number(this.asRecord(response.bet_advice).suggested_stake_units ?? 0)
|
? Number(this.asRecord(response.bet_advice).suggested_stake_units ?? 0)
|
||||||
@@ -428,15 +429,18 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const enrichedMarketBoard = Object.fromEntries(
|
const enrichedMarketBoard = Object.fromEntries(
|
||||||
Object.entries(marketBoard).map(([market, entry]) => {
|
Object.entries(marketBoard).map(([market, entry]) => {
|
||||||
const record = this.asRecord(entry);
|
const record = this.asRecord(entry);
|
||||||
const pickName = String(record.pick ?? '');
|
const pickName = String(record.pick ?? "");
|
||||||
if (!pickName || !record.probs || typeof record.probs !== 'object') {
|
if (!pickName || !record.probs || typeof record.probs !== "object") {
|
||||||
return [market, record];
|
return [market, record];
|
||||||
}
|
}
|
||||||
|
|
||||||
const syntheticPick = {
|
const syntheticPick = {
|
||||||
market,
|
market,
|
||||||
pick: pickName,
|
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),
|
confidence: Number(record.confidence ?? 0),
|
||||||
calibrated_confidence: Number(record.confidence ?? 0),
|
calibrated_confidence: Number(record.confidence ?? 0),
|
||||||
raw_confidence: Number(record.confidence ?? 0),
|
raw_confidence: Number(record.confidence ?? 0),
|
||||||
@@ -447,7 +451,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
implied_prob: 0,
|
implied_prob: 0,
|
||||||
play_score: 0,
|
play_score: 0,
|
||||||
playable: false,
|
playable: false,
|
||||||
bet_grade: 'PASS',
|
bet_grade: "PASS",
|
||||||
stake_units: 0,
|
stake_units: 0,
|
||||||
decision_reasons: [],
|
decision_reasons: [],
|
||||||
};
|
};
|
||||||
@@ -464,7 +468,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
{
|
{
|
||||||
...record,
|
...record,
|
||||||
confidence_interval: this.asRecord(enriched?.confidence_interval),
|
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 {
|
return {
|
||||||
...response,
|
...response,
|
||||||
match_info: matchInfo as MatchPredictionDto['match_info'],
|
match_info: matchInfo as MatchPredictionDto["match_info"],
|
||||||
data_quality: {
|
data_quality: {
|
||||||
...dataQuality,
|
...dataQuality,
|
||||||
lineup_source: String(dataQuality.lineup_source ?? 'none'),
|
lineup_source: String(dataQuality.lineup_source ?? "none"),
|
||||||
} as MatchPredictionDto['data_quality'],
|
} as MatchPredictionDto["data_quality"],
|
||||||
risk: {
|
risk: {
|
||||||
...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)
|
surprise_reasons: Array.isArray(risk.surprise_reasons)
|
||||||
? risk.surprise_reasons.map((reason) =>
|
? risk.surprise_reasons.map((reason) =>
|
||||||
this.translateReason(String(reason)),
|
this.translateReason(String(reason)),
|
||||||
@@ -490,13 +495,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.translateReason(String(warning)),
|
this.translateReason(String(warning)),
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
} as MatchPredictionDto['risk'],
|
} as MatchPredictionDto["risk"],
|
||||||
main_pick: mainPick,
|
main_pick: mainPick,
|
||||||
value_pick: valuePick,
|
value_pick: valuePick,
|
||||||
aggressive_pick: aggressivePick,
|
aggressive_pick: aggressivePick,
|
||||||
supporting_picks: supportingPicks,
|
supporting_picks: supportingPicks,
|
||||||
bet_summary: betSummary,
|
bet_summary: betSummary,
|
||||||
bet_advice: betAdvice as MatchPredictionDto['bet_advice'],
|
bet_advice: betAdvice as MatchPredictionDto["bet_advice"],
|
||||||
market_board: enrichedMarketBoard,
|
market_board: enrichedMarketBoard,
|
||||||
reasoning_factors: reasoningFactors,
|
reasoning_factors: reasoningFactors,
|
||||||
};
|
};
|
||||||
@@ -507,14 +512,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
prediction: Record<string, unknown>,
|
prediction: Record<string, unknown>,
|
||||||
matchContext: MatchContext,
|
matchContext: MatchContext,
|
||||||
marketBoard: Record<string, unknown>,
|
marketBoard: Record<string, unknown>,
|
||||||
): MatchPredictionDto['main_pick'] {
|
): MatchPredictionDto["main_pick"] {
|
||||||
if (!pick || typeof pick !== 'object') {
|
if (!pick || typeof pick !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = this.asRecord(pick);
|
const record = this.asRecord(pick);
|
||||||
const market = String(record.market ?? '');
|
const market = String(record.market ?? "");
|
||||||
const pickName = String(record.pick ?? '');
|
const pickName = String(record.pick ?? "");
|
||||||
const probs = this.resolveMarketProbabilities(marketBoard, market);
|
const probs = this.resolveMarketProbabilities(marketBoard, market);
|
||||||
const probability =
|
const probability =
|
||||||
this.asNumber(record.probability) ||
|
this.asNumber(record.probability) ||
|
||||||
@@ -538,7 +543,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
),
|
),
|
||||||
riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
|
riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
|
||||||
lineupSource: String(
|
lineupSource: String(
|
||||||
this.asRecord(prediction.data_quality).lineup_source ?? 'none',
|
this.asRecord(prediction.data_quality).lineup_source ?? "none",
|
||||||
),
|
),
|
||||||
isTopLeague: matchContext.isTopLeague,
|
isTopLeague: matchContext.isTopLeague,
|
||||||
});
|
});
|
||||||
@@ -547,10 +552,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
? [...record.decision_reasons]
|
? [...record.decision_reasons]
|
||||||
: [];
|
: [];
|
||||||
if (!interval.threshold_met) {
|
if (!interval.threshold_met) {
|
||||||
nextReasons.push('confidence_interval_too_wide');
|
nextReasons.push("confidence_interval_too_wide");
|
||||||
}
|
}
|
||||||
if (interval.band === 'LOW') {
|
if (interval.band === "LOW") {
|
||||||
nextReasons.push('confidence_band_low');
|
nextReasons.push("confidence_band_low");
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayOdds = this.normalizeDisplayOdds(
|
const displayOdds = this.normalizeDisplayOdds(
|
||||||
@@ -559,7 +564,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(record as MatchPredictionDto['main_pick']),
|
...(record as MatchPredictionDto["main_pick"]),
|
||||||
market,
|
market,
|
||||||
pick: pickName,
|
pick: pickName,
|
||||||
probability,
|
probability,
|
||||||
@@ -573,7 +578,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
odds: displayOdds,
|
odds: displayOdds,
|
||||||
edge: this.asNumber(record.edge),
|
edge: this.asNumber(record.edge),
|
||||||
play_score: this.asNumber(record.play_score),
|
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,
|
implied_prob: impliedProb,
|
||||||
ev_edge: evEdge,
|
ev_edge: evEdge,
|
||||||
playable: Boolean(record.playable) && interval.threshold_met,
|
playable: Boolean(record.playable) && interval.threshold_met,
|
||||||
@@ -594,10 +599,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
prediction: Record<string, unknown>,
|
prediction: Record<string, unknown>,
|
||||||
matchContext: MatchContext,
|
matchContext: MatchContext,
|
||||||
marketBoard: Record<string, unknown>,
|
marketBoard: Record<string, unknown>,
|
||||||
): MatchPredictionDto['bet_summary'][number] {
|
): MatchPredictionDto["bet_summary"][number] {
|
||||||
const record = this.asRecord(item);
|
const record = this.asRecord(item);
|
||||||
const market = String(record.market ?? '');
|
const market = String(record.market ?? "");
|
||||||
const pickName = String(record.pick ?? '');
|
const pickName = String(record.pick ?? "");
|
||||||
const probs = this.resolveMarketProbabilities(marketBoard, market);
|
const probs = this.resolveMarketProbabilities(marketBoard, market);
|
||||||
const probability = this.lookupProbability(probs, pickName);
|
const probability = this.lookupProbability(probs, pickName);
|
||||||
const calibratedConfidence =
|
const calibratedConfidence =
|
||||||
@@ -621,13 +626,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
),
|
),
|
||||||
riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
|
riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
|
||||||
lineupSource: String(
|
lineupSource: String(
|
||||||
this.asRecord(prediction.data_quality).lineup_source ?? 'none',
|
this.asRecord(prediction.data_quality).lineup_source ?? "none",
|
||||||
),
|
),
|
||||||
isTopLeague: matchContext.isTopLeague,
|
isTopLeague: matchContext.isTopLeague,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(record as MatchPredictionDto['bet_summary'][number]),
|
...(record as MatchPredictionDto["bet_summary"][number]),
|
||||||
odds: this.normalizeDisplayOdds(odds, impliedProb),
|
odds: this.normalizeDisplayOdds(odds, impliedProb),
|
||||||
implied_prob: impliedProb,
|
implied_prob: impliedProb,
|
||||||
ev_edge: evEdge,
|
ev_edge: evEdge,
|
||||||
@@ -658,12 +663,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
private translateReason(reason: string): string {
|
private translateReason(reason: string): string {
|
||||||
if (!reason) {
|
if (!reason) {
|
||||||
return '';
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = reason.startsWith('risk:')
|
const normalized = reason.startsWith("risk:") ? reason.slice(5) : reason;
|
||||||
? reason.slice(5)
|
|
||||||
: reason;
|
|
||||||
|
|
||||||
if (this.reasonTranslations[normalized]) {
|
if (this.reasonTranslations[normalized]) {
|
||||||
return 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]})`;
|
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) {
|
if (negativeEdgeMatch) {
|
||||||
return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
|
return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
|
||||||
}
|
}
|
||||||
@@ -692,47 +697,44 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private classifySignalTier(
|
private classifySignalTier(
|
||||||
record: Record<string, unknown>,
|
record: Record<string, unknown>,
|
||||||
interval: {
|
interval: {
|
||||||
band?: 'HIGH' | 'MEDIUM' | 'LOW';
|
band?: "HIGH" | "MEDIUM" | "LOW";
|
||||||
threshold_met?: boolean;
|
threshold_met?: boolean;
|
||||||
},
|
},
|
||||||
): 'CORE' | 'VALUE' | 'LEAN' | 'LONGSHOT' | 'PASS' {
|
): "CORE" | "VALUE" | "LEAN" | "LONGSHOT" | "PASS" {
|
||||||
const playable = Boolean(record.playable) && Boolean(interval.threshold_met);
|
const playable =
|
||||||
|
Boolean(record.playable) && Boolean(interval.threshold_met);
|
||||||
const calibratedConfidence = this.asNumber(record.calibrated_confidence);
|
const calibratedConfidence = this.asNumber(record.calibrated_confidence);
|
||||||
const odds = this.asNumber(record.odds);
|
const odds = this.asNumber(record.odds);
|
||||||
const evEdge = this.asNumber(record.ev_edge) || this.asNumber(record.edge);
|
const evEdge = this.asNumber(record.ev_edge) || this.asNumber(record.edge);
|
||||||
const playScore = this.asNumber(record.play_score);
|
const playScore = this.asNumber(record.play_score);
|
||||||
const band = String(interval.band ?? 'LOW').toUpperCase();
|
const band = String(interval.band ?? "LOW").toUpperCase();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
playable &&
|
playable &&
|
||||||
band === 'HIGH' &&
|
band === "HIGH" &&
|
||||||
calibratedConfidence >= 72 &&
|
calibratedConfidence >= 72 &&
|
||||||
evEdge >= 0.02 &&
|
evEdge >= 0.02 &&
|
||||||
playScore >= 68
|
playScore >= 68
|
||||||
) {
|
) {
|
||||||
return 'CORE';
|
return "CORE";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (calibratedConfidence >= 52 && odds >= 1.75 && evEdge >= 0.04) {
|
||||||
calibratedConfidence >= 52 &&
|
return playable ? "VALUE" : "LONGSHOT";
|
||||||
odds >= 1.75 &&
|
|
||||||
evEdge >= 0.04
|
|
||||||
) {
|
|
||||||
return playable ? 'VALUE' : 'LONGSHOT';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
calibratedConfidence >= 46 &&
|
calibratedConfidence >= 46 &&
|
||||||
(band === 'HIGH' || band === 'MEDIUM' || evEdge > 0)
|
(band === "HIGH" || band === "MEDIUM" || evEdge > 0)
|
||||||
) {
|
) {
|
||||||
return 'LEAN';
|
return "LEAN";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (odds >= 2.2 && calibratedConfidence >= 38) {
|
if (odds >= 2.2 && calibratedConfidence >= 38) {
|
||||||
return 'LONGSHOT';
|
return "LONGSHOT";
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'PASS';
|
return "PASS";
|
||||||
}
|
}
|
||||||
|
|
||||||
private estimateConfidenceInterval(input: {
|
private estimateConfidenceInterval(input: {
|
||||||
@@ -755,7 +757,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const secondProb = sortedProbs[1] ?? 0;
|
const secondProb = sortedProbs[1] ?? 0;
|
||||||
const topProb = sortedProbs[0] ?? probability;
|
const topProb = sortedProbs[0] ?? probability;
|
||||||
const margin = Math.max(0, topProb - secondProb);
|
const margin = Math.max(0, topProb - secondProb);
|
||||||
const normalizedConfidence = this.normalizePercent(input.calibratedConfidence);
|
const normalizedConfidence = this.normalizePercent(
|
||||||
|
input.calibratedConfidence,
|
||||||
|
);
|
||||||
|
|
||||||
const baseWidthByMarket: Record<string, number> = {
|
const baseWidthByMarket: Record<string, number> = {
|
||||||
MS: 0.18,
|
MS: 0.18,
|
||||||
@@ -767,19 +771,19 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
};
|
};
|
||||||
const baseWidth = baseWidthByMarket[input.market] ?? 0.19;
|
const baseWidth = baseWidthByMarket[input.market] ?? 0.19;
|
||||||
const lineupPenalty =
|
const lineupPenalty =
|
||||||
input.lineupSource === 'confirmed_live'
|
input.lineupSource === "confirmed_live"
|
||||||
? -0.015
|
? -0.015
|
||||||
: input.lineupSource === 'probable_xi'
|
: input.lineupSource === "probable_xi"
|
||||||
? 0
|
? 0
|
||||||
: 0.02;
|
: 0.02;
|
||||||
const width = this.clamp(
|
const width = this.clamp(
|
||||||
baseWidth
|
baseWidth -
|
||||||
- margin * 0.22
|
margin * 0.22 -
|
||||||
- normalizedConfidence * 0.05
|
normalizedConfidence * 0.05 +
|
||||||
+ (1 - input.dataQualityScore) * 0.09
|
(1 - input.dataQualityScore) * 0.09 +
|
||||||
+ input.riskScore * 0.08
|
input.riskScore * 0.08 -
|
||||||
- (input.isTopLeague ? 0.012 : 0)
|
(input.isTopLeague ? 0.012 : 0) +
|
||||||
+ lineupPenalty,
|
lineupPenalty,
|
||||||
0.08,
|
0.08,
|
||||||
0.34,
|
0.34,
|
||||||
);
|
);
|
||||||
@@ -795,17 +799,17 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
width <= this.getMaxAllowedWidth(input.market) &&
|
width <= this.getMaxAllowedWidth(input.market) &&
|
||||||
input.dataQualityScore >= 0.58 &&
|
input.dataQualityScore >= 0.58 &&
|
||||||
input.evEdge >= this.getMinEdge(input.market) &&
|
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) {
|
if (input.calibratedConfidence >= 69 && width <= 0.12 && margin >= 0.07) {
|
||||||
band = 'HIGH';
|
band = "HIGH";
|
||||||
} else if (
|
} else if (
|
||||||
input.calibratedConfidence >= 58 &&
|
input.calibratedConfidence >= 58 &&
|
||||||
width <= 0.18 &&
|
width <= 0.18 &&
|
||||||
margin >= 0.035
|
margin >= 0.035
|
||||||
) {
|
) {
|
||||||
band = 'MEDIUM';
|
band = "MEDIUM";
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -864,7 +868,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const entry = this.asRecord(marketBoard[market]);
|
const entry = this.asRecord(marketBoard[market]);
|
||||||
const probs = entry.probs;
|
const probs = entry.probs;
|
||||||
return probs && typeof probs === 'object'
|
return probs && typeof probs === "object"
|
||||||
? (probs as Record<string, unknown>)
|
? (probs as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
}
|
}
|
||||||
@@ -895,7 +899,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
private normalizeScore(value: unknown): number {
|
private normalizeScore(value: unknown): number {
|
||||||
const numeric = this.asNumber(value);
|
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 {
|
private normalizePercent(value: number): number {
|
||||||
@@ -903,15 +909,15 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private asRecord(value: unknown): Record<string, any> {
|
private asRecord(value: unknown): Record<string, any> {
|
||||||
return value && typeof value === 'object'
|
return value && typeof value === "object"
|
||||||
? (value as Record<string, any>)
|
? (value as Record<string, any>)
|
||||||
: {};
|
: {};
|
||||||
}
|
}
|
||||||
|
|
||||||
private asNumber(value: unknown): number {
|
private asNumber(value: unknown): number {
|
||||||
return typeof value === 'number'
|
return typeof value === "number"
|
||||||
? value
|
? value
|
||||||
: typeof value === 'string'
|
: typeof value === "string"
|
||||||
? Number(value) || 0
|
? Number(value) || 0
|
||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
@@ -922,7 +928,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
async getValueBets(): Promise<ValueBetDto[]> {
|
async getValueBets(): Promise<ValueBetDto[]> {
|
||||||
const predictions = await this.prisma.prediction.findMany({
|
const predictions = await this.prisma.prediction.findMany({
|
||||||
where: { match: { status: 'NS' } },
|
where: { match: { status: "NS" } },
|
||||||
include: { match: { include: { homeTeam: true, awayTeam: true } } },
|
include: { match: { include: { homeTeam: true, awayTeam: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -937,14 +943,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
valueBets.push({
|
valueBets.push({
|
||||||
matchId: p.matchId,
|
matchId: p.matchId,
|
||||||
matchName: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`,
|
matchName: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`,
|
||||||
betType: (vb.market || vb.betType || '') as string,
|
betType: (vb.market || vb.betType || "") as string,
|
||||||
prediction: (vb.pick || vb.prediction || '') as string,
|
prediction: (vb.pick || vb.prediction || "") as string,
|
||||||
confidence: typeof vb.confidence === 'number' ? vb.confidence : 0,
|
confidence: typeof vb.confidence === "number" ? vb.confidence : 0,
|
||||||
odd: typeof vb.odd === 'number' ? vb.odd : 0,
|
odd: typeof vb.odd === "number" ? vb.odd : 0,
|
||||||
expectedValue:
|
expectedValue:
|
||||||
typeof vb.edge === 'number'
|
typeof vb.edge === "number"
|
||||||
? vb.edge
|
? vb.edge
|
||||||
: typeof vb.expectedValue === 'number'
|
: typeof vb.expectedValue === "number"
|
||||||
? vb.expectedValue
|
? vb.expectedValue
|
||||||
: 0,
|
: 0,
|
||||||
});
|
});
|
||||||
@@ -959,7 +965,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
async getSmartCoupon(
|
async getSmartCoupon(
|
||||||
matchIds: string[],
|
matchIds: string[],
|
||||||
strategy: string = 'BALANCED',
|
strategy: string = "BALANCED",
|
||||||
options: { maxMatches?: number; minConfidence?: number } = {},
|
options: { maxMatches?: number; minConfidence?: number } = {},
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
await this.ensureSmartCouponDataReady(matchIds);
|
await this.ensureSmartCouponDataReady(matchIds);
|
||||||
@@ -997,23 +1003,23 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
private throwAiError(message: string): never {
|
private throwAiError(message: string): never {
|
||||||
if (
|
if (
|
||||||
message.includes('timed out') ||
|
message.includes("timed out") ||
|
||||||
message.includes('AI_ENGINE_TIMEOUT') ||
|
message.includes("AI_ENGINE_TIMEOUT") ||
|
||||||
message.includes('AI_ENGINE_504')
|
message.includes("AI_ENGINE_504")
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
'Prediction request timed out',
|
"Prediction request timed out",
|
||||||
HttpStatus.GATEWAY_TIMEOUT,
|
HttpStatus.GATEWAY_TIMEOUT,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (message.includes('AI_ENGINE_502')) {
|
if (message.includes("AI_ENGINE_502")) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
'AI Engine upstream returned 502',
|
"AI Engine upstream returned 502",
|
||||||
HttpStatus.BAD_GATEWAY,
|
HttpStatus.BAD_GATEWAY,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
'Failed to get prediction from AI Engine',
|
"Failed to get prediction from AI Engine",
|
||||||
HttpStatus.SERVICE_UNAVAILABLE,
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1066,12 +1072,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cached = prediction.predictionJson as Record<string, unknown>;
|
const cached = prediction.predictionJson as Record<string, unknown>;
|
||||||
const modelVersion = cached['model_version'];
|
const modelVersion = cached["model_version"];
|
||||||
if (typeof modelVersion !== 'string') {
|
if (typeof modelVersion !== "string") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!modelVersion.startsWith('v25')) {
|
if (!modelVersion.startsWith("v25")) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,7 +1088,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const uniqueMatchIds = [...new Set(matchIds.filter((id) => !!id))];
|
const uniqueMatchIds = [...new Set(matchIds.filter((id) => !!id))];
|
||||||
if (uniqueMatchIds.length === 0) {
|
if (uniqueMatchIds.length === 0) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
'No matchIds provided for smart coupon generation',
|
"No matchIds provided for smart coupon generation",
|
||||||
HttpStatus.BAD_REQUEST,
|
HttpStatus.BAD_REQUEST,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1122,7 +1128,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
const hasLiveOdds =
|
const hasLiveOdds =
|
||||||
!!liveMatch?.odds &&
|
!!liveMatch?.odds &&
|
||||||
typeof liveMatch.odds === 'object' &&
|
typeof liveMatch.odds === "object" &&
|
||||||
!Array.isArray(liveMatch.odds) &&
|
!Array.isArray(liveMatch.odds) &&
|
||||||
Object.keys(liveMatch.odds as Record<string, unknown>).length > 0;
|
Object.keys(liveMatch.odds as Record<string, unknown>).length > 0;
|
||||||
const matchExists = !!liveMatch?.id || !!persistedMatch?.id;
|
const matchExists = !!liveMatch?.id || !!persistedMatch?.id;
|
||||||
@@ -1146,9 +1152,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
const isFinished =
|
const isFinished =
|
||||||
hasScores ||
|
hasScores ||
|
||||||
state === 'MS' ||
|
state === "MS" ||
|
||||||
state === 'postGame' ||
|
state === "postGame" ||
|
||||||
['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'].includes(
|
["Finished", "Played", "FT", "AET", "PEN", "Ended"].includes(
|
||||||
status as string,
|
status as string,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
/* eslint-disable @typescript-eslint/unbound-method */
|
/* eslint-disable @typescript-eslint/unbound-method */
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
import { PredictionJobType } from './predictions.types';
|
import { PredictionJobType } from "./predictions.types";
|
||||||
import { PredictionsProcessor } from './predictions.processor';
|
import { PredictionsProcessor } from "./predictions.processor";
|
||||||
|
|
||||||
jest.mock('axios');
|
jest.mock("axios");
|
||||||
|
|
||||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
|
|
||||||
describe('PredictionsProcessor', () => {
|
describe("PredictionsProcessor", () => {
|
||||||
let processor: PredictionsProcessor;
|
let processor: PredictionsProcessor;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
process.env.AI_ENGINE_URL = 'http://unit-ai:8000';
|
process.env.AI_ENGINE_URL = "http://unit-ai:8000";
|
||||||
processor = new PredictionsProcessor();
|
processor = new PredictionsProcessor();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -20,34 +20,34 @@ describe('PredictionsProcessor', () => {
|
|||||||
delete process.env.AI_ENGINE_URL;
|
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);
|
mockedAxios.post.mockResolvedValueOnce({ data: { ok: true } } as any);
|
||||||
|
|
||||||
const job = {
|
const job = {
|
||||||
id: 'j1',
|
id: "j1",
|
||||||
name: PredictionJobType.PREDICT_MATCH,
|
name: PredictionJobType.PREDICT_MATCH,
|
||||||
data: { matchId: 'match-123' },
|
data: { matchId: "match-123" },
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await processor.process(job);
|
const result = await processor.process(job);
|
||||||
|
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||||
'http://unit-ai:8000/v20plus/analyze/match-123',
|
"http://unit-ai:8000/v20plus/analyze/match-123",
|
||||||
{},
|
{},
|
||||||
{ timeout: 30000 },
|
{ 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);
|
mockedAxios.post.mockResolvedValueOnce({ data: { bets: [] } } as any);
|
||||||
|
|
||||||
const job = {
|
const job = {
|
||||||
id: 'j2',
|
id: "j2",
|
||||||
name: PredictionJobType.SMART_COUPON,
|
name: PredictionJobType.SMART_COUPON,
|
||||||
data: {
|
data: {
|
||||||
matchIds: ['m1', 'm2'],
|
matchIds: ["m1", "m2"],
|
||||||
strategy: 'BALANCED',
|
strategy: "BALANCED",
|
||||||
options: { maxMatches: 4, minConfidence: 65 },
|
options: { maxMatches: 4, minConfidence: 65 },
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
@@ -56,10 +56,10 @@ describe('PredictionsProcessor', () => {
|
|||||||
|
|
||||||
expect(result).toEqual({ bets: [] });
|
expect(result).toEqual({ bets: [] });
|
||||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||||
'http://unit-ai:8000/v20plus/coupon',
|
"http://unit-ai:8000/v20plus/coupon",
|
||||||
{
|
{
|
||||||
match_ids: ['m1', 'm2'],
|
match_ids: ["m1", "m2"],
|
||||||
strategy: 'BALANCED',
|
strategy: "BALANCED",
|
||||||
max_matches: 4,
|
max_matches: 4,
|
||||||
min_confidence: 65,
|
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 = {
|
const job = {
|
||||||
id: 'j3',
|
id: "j3",
|
||||||
name: 'unknown-job',
|
name: "unknown-job",
|
||||||
data: {},
|
data: {},
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
await expect(processor.process(job)).rejects.toThrow(
|
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 */
|
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
|
||||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
import { Processor, WorkerHost } from "@nestjs/bullmq";
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from "@nestjs/common";
|
||||||
import { Job } from 'bullmq';
|
import { Job } from "bullmq";
|
||||||
import {
|
import {
|
||||||
PREDICTIONS_QUEUE,
|
PREDICTIONS_QUEUE,
|
||||||
PredictionJobType,
|
PredictionJobType,
|
||||||
PredictMatchJobData,
|
PredictMatchJobData,
|
||||||
SmartCouponJobData,
|
SmartCouponJobData,
|
||||||
} from './predictions.types';
|
} from "./predictions.types";
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Predictions Processor
|
* Predictions Processor
|
||||||
@@ -22,7 +22,7 @@ export class PredictionsProcessor extends WorkerHost {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
// Default to container service URL
|
// 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> {
|
async process(job: Job<any, any, string>): Promise<any> {
|
||||||
@@ -56,7 +56,7 @@ export class PredictionsProcessor extends WorkerHost {
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} 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;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw this.mapAxiosError(error, matchIds.join(','), 'smart-coupon');
|
throw this.mapAxiosError(error, matchIds.join(","), "smart-coupon");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapAxiosError(
|
private mapAxiosError(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
identifier: string,
|
identifier: string,
|
||||||
flow: 'predict' | 'smart-coupon',
|
flow: "predict" | "smart-coupon",
|
||||||
): Error {
|
): Error {
|
||||||
if (!axios.isAxiosError(error)) {
|
if (!axios.isAxiosError(error)) {
|
||||||
return error instanceof Error
|
return error instanceof Error
|
||||||
@@ -98,7 +98,7 @@ export class PredictionsProcessor extends WorkerHost {
|
|||||||
|
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
const detail = error.response?.data?.detail || error.message;
|
const detail = error.response?.data?.detail || error.message;
|
||||||
const code = error.code || '';
|
const code = error.code || "";
|
||||||
|
|
||||||
if (status === 502) {
|
if (status === 502) {
|
||||||
this.logger.error(`AI Engine 502 (${flow}:${identifier}): ${detail}`);
|
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}`);
|
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}`);
|
this.logger.error(`AI Engine timeout (${flow}:${identifier}): ${detail}`);
|
||||||
return new Error(`AI_ENGINE_TIMEOUT|${flow}|${detail}`);
|
return new Error(`AI_ENGINE_TIMEOUT|${flow}|${detail}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(
|
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}`);
|
return new Error(`AI_ENGINE_ERROR|${flow}|${detail}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from "@nestjs/bullmq";
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from "bullmq";
|
||||||
import {
|
import {
|
||||||
PREDICTIONS_QUEUE,
|
PREDICTIONS_QUEUE,
|
||||||
PredictionJobType,
|
PredictionJobType,
|
||||||
PredictMatchJobData,
|
PredictMatchJobData,
|
||||||
SmartCouponJobData,
|
SmartCouponJobData,
|
||||||
} from './predictions.types';
|
} from "./predictions.types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PredictionsQueue {
|
export class PredictionsQueue {
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
* Senior Level Strict Typing
|
* Senior Level Strict Typing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const PREDICTIONS_QUEUE = 'predictions-queue';
|
export const PREDICTIONS_QUEUE = "predictions-queue";
|
||||||
|
|
||||||
export enum PredictionJobType {
|
export enum PredictionJobType {
|
||||||
PREDICT_MATCH = 'predict-match',
|
PREDICT_MATCH = "predict-match",
|
||||||
SMART_COUPON = 'smart-coupon',
|
SMART_COUPON = "smart-coupon",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PredictMatchJobData {
|
export interface PredictMatchJobData {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaService } from '../../../database/prisma.service';
|
import { PrismaService } from "../../../database/prisma.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiFeatureStoreService {
|
export class AiFeatureStoreService {
|
||||||
@@ -16,10 +16,10 @@ export class AiFeatureStoreService {
|
|||||||
where: { id: matchId },
|
where: { id: matchId },
|
||||||
include: {
|
include: {
|
||||||
homeTeam: {
|
homeTeam: {
|
||||||
include: { homeMatches: { take: 5, orderBy: { mstUtc: 'desc' } } },
|
include: { homeMatches: { take: 5, orderBy: { mstUtc: "desc" } } },
|
||||||
},
|
},
|
||||||
awayTeam: {
|
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 { Injectable, Logger } from "@nestjs/common";
|
||||||
import { GeminiService } from '../gemini/gemini.service';
|
import { GeminiService } from "../gemini/gemini.service";
|
||||||
import { PredictionCardDto } from './dto/prediction-card.dto';
|
import { PredictionCardDto } from "./dto/prediction-card.dto";
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `Sen profesyonel bir spor analisti ve sosyal medya içerik üreticisisin.
|
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.
|
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> {
|
async generateCaption(card: PredictionCardDto): Promise<string> {
|
||||||
if (!this.geminiService.isAvailable()) {
|
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);
|
return this.generateFallbackCaption(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export class CaptionGeneratorService {
|
|||||||
);
|
);
|
||||||
return caption;
|
return caption;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Gemini caption generation failed', error);
|
this.logger.error("Gemini caption generation failed", error);
|
||||||
return this.generateFallbackCaption(card);
|
return this.generateFallbackCaption(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ export class CaptionGeneratorService {
|
|||||||
(p, i) =>
|
(p, i) =>
|
||||||
`${i + 1}. ${p.market} (${p.marketEn}) — ${p.pick} — Güven: %${p.confidence} — Oran: ${p.odds}`,
|
`${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:
|
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 {
|
private ensureHashtags(text: string, card: PredictionCardDto): string {
|
||||||
// If no hashtags in text, add them
|
// If no hashtags in text, add them
|
||||||
if (!text.includes('#')) {
|
if (!text.includes("#")) {
|
||||||
const leagueTag = card.leagueName
|
const leagueTag = card.leagueName
|
||||||
.replace(/\s+/g, '')
|
.replace(/\s+/g, "")
|
||||||
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, '');
|
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
|
||||||
const homeTag = card.homeTeam.replace(/\s+/g, '');
|
const homeTag = card.homeTeam.replace(/\s+/g, "");
|
||||||
const awayTag = card.awayTeam.replace(/\s+/g, '');
|
const awayTag = card.awayTeam.replace(/\s+/g, "");
|
||||||
text += `\n\n#${leagueTag} #${homeTag} #${awayTag}`;
|
text += `\n\n#${leagueTag} #${homeTag} #${awayTag}`;
|
||||||
}
|
}
|
||||||
return text.trim();
|
return text.trim();
|
||||||
@@ -96,13 +96,13 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`;
|
|||||||
private generateFallbackCaption(card: PredictionCardDto): string {
|
private generateFallbackCaption(card: PredictionCardDto): string {
|
||||||
const topPick = card.topPicks[0];
|
const topPick = card.topPicks[0];
|
||||||
const leagueTag = card.leagueName
|
const leagueTag = card.leagueName
|
||||||
.replace(/\s+/g, '')
|
.replace(/\s+/g, "")
|
||||||
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, '');
|
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
|
||||||
|
|
||||||
return `⚡ ${card.homeTeam} vs ${card.awayTeam}
|
return `⚡ ${card.homeTeam} vs ${card.awayTeam}
|
||||||
🎯 Tahminimiz: ${card.ftScore} (İY: ${card.htScore})
|
🎯 Tahminimiz: ${card.ftScore} (İY: ${card.htScore})
|
||||||
📊 Güven: %${card.scoreConfidence}
|
📊 Güven: %${card.scoreConfidence}
|
||||||
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ''}
|
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ""}
|
||||||
|
|
||||||
#${leagueTag} #SuggestBet #Bahis`.trim();
|
#${leagueTag} #SuggestBet #Bahis`.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export interface PredictionCardDto {
|
|||||||
topPicks: TopPick[];
|
topPicks: TopPick[];
|
||||||
|
|
||||||
// ─── Risk ───
|
// ─── Risk ───
|
||||||
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
|
riskLevel: "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
|
||||||
|
|
||||||
// ─── Raw prediction JSON (for Gemini caption) ───
|
// ─── Raw prediction JSON (for Gemini caption) ───
|
||||||
rawPrediction?: Record<string, any>;
|
rawPrediction?: Record<string, any>;
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||||
import * as fs from 'fs';
|
import * as fs from "fs";
|
||||||
import * as path from 'path';
|
import * as path from "path";
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
import { createCanvas, loadImage } from 'canvas';
|
import { createCanvas, loadImage } from "canvas";
|
||||||
import { PredictionCardDto } from './dto/prediction-card.dto';
|
import { PredictionCardDto } from "./dto/prediction-card.dto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImageRendererService implements OnModuleInit {
|
export class ImageRendererService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(ImageRendererService.name);
|
private readonly logger = new Logger(ImageRendererService.name);
|
||||||
private readonly outputDir = path.join(
|
private readonly outputDir = path.join(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
'public',
|
"public",
|
||||||
'predictions',
|
"predictions",
|
||||||
);
|
);
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
@@ -53,8 +53,8 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Case 1: Local relative path → read from public/ directory
|
// Case 1: Local relative path → read from public/ directory
|
||||||
if (url.startsWith('/')) {
|
if (url.startsWith("/")) {
|
||||||
const localPath = path.join(process.cwd(), 'public', url);
|
const localPath = path.join(process.cwd(), "public", url);
|
||||||
if (fs.existsSync(localPath)) {
|
if (fs.existsSync(localPath)) {
|
||||||
this.logger.debug(`Loading logo from local file: ${localPath}`);
|
this.logger.debug(`Loading logo from local file: ${localPath}`);
|
||||||
return await loadImage(localPath);
|
return await loadImage(localPath);
|
||||||
@@ -66,9 +66,9 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Case 2: Full HTTP/HTTPS URL → fetch directly
|
// Case 2: Full HTTP/HTTPS URL → fetch directly
|
||||||
if (url.startsWith('http')) {
|
if (url.startsWith("http")) {
|
||||||
const response = await axios.get(url, {
|
const response = await axios.get(url, {
|
||||||
responseType: 'arraybuffer',
|
responseType: "arraybuffer",
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
return await loadImage(response.data);
|
return await loadImage(response.data);
|
||||||
@@ -133,14 +133,14 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
const width = 1080;
|
const width = 1080;
|
||||||
const height = 1920;
|
const height = 1920;
|
||||||
const canvas = createCanvas(width, height);
|
const canvas = createCanvas(width, height);
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
// Background Gradient
|
// Background Gradient
|
||||||
const bgGrad = ctx.createLinearGradient(0, 0, width, height);
|
const bgGrad = ctx.createLinearGradient(0, 0, width, height);
|
||||||
bgGrad.addColorStop(0, '#0a0e27');
|
bgGrad.addColorStop(0, "#0a0e27");
|
||||||
bgGrad.addColorStop(0.35, '#1a1040');
|
bgGrad.addColorStop(0.35, "#1a1040");
|
||||||
bgGrad.addColorStop(0.7, '#0d1b2a');
|
bgGrad.addColorStop(0.7, "#0d1b2a");
|
||||||
bgGrad.addColorStop(1, '#0a0e27');
|
bgGrad.addColorStop(1, "#0a0e27");
|
||||||
ctx.fillStyle = bgGrad;
|
ctx.fillStyle = bgGrad;
|
||||||
ctx.fillRect(0, 0, width, height);
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
@@ -148,12 +148,12 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(width / 2, height / 2);
|
ctx.translate(width / 2, height / 2);
|
||||||
ctx.rotate((-35 * Math.PI) / 180);
|
ctx.rotate((-35 * Math.PI) / 180);
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)';
|
ctx.fillStyle = "rgba(255, 255, 255, 0.05)";
|
||||||
ctx.font = '900 100px sans-serif';
|
ctx.font = "900 100px sans-serif";
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = "middle";
|
||||||
const wmLine =
|
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++) {
|
for (let i = -15; i <= 15; i++) {
|
||||||
ctx.fillText(wmLine, 0, i * 180);
|
ctx.fillText(wmLine, 0, i * 180);
|
||||||
}
|
}
|
||||||
@@ -163,14 +163,14 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
const paddingX = 80;
|
const paddingX = 80;
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
ctx.fillStyle = "rgba(255, 255, 255, 0.7)";
|
||||||
ctx.font = '600 28px sans-serif';
|
ctx.font = "600 28px sans-serif";
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = "left";
|
||||||
ctx.fillText(data.leagueName.toUpperCase(), paddingX, 120);
|
ctx.fillText(data.leagueName.toUpperCase(), paddingX, 120);
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
|
ctx.fillStyle = "rgba(255, 255, 255, 0.45)";
|
||||||
ctx.font = '400 22px sans-serif';
|
ctx.font = "400 22px sans-serif";
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = "right";
|
||||||
ctx.fillText(data.matchDate, width - paddingX, 120);
|
ctx.fillText(data.matchDate, width - paddingX, 120);
|
||||||
|
|
||||||
// Teams Section
|
// Teams Section
|
||||||
@@ -184,31 +184,31 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
if (awayImg)
|
if (awayImg)
|
||||||
ctx.drawImage(awayImg, (width / 4) * 3 - 100, currentY, 200, 200);
|
ctx.drawImage(awayImg, (width / 4) * 3 - 100, currentY, 200, 200);
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
|
ctx.fillStyle = "rgba(255, 255, 255, 0.15)";
|
||||||
ctx.font = '900 56px sans-serif';
|
ctx.font = "900 56px sans-serif";
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = "center";
|
||||||
ctx.fillText('VS', width / 2, currentY + 110);
|
ctx.fillText("VS", width / 2, currentY + 110);
|
||||||
|
|
||||||
currentY += 250;
|
currentY += 250;
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = "#ffffff";
|
||||||
ctx.font = '700 36px sans-serif';
|
ctx.font = "700 36px sans-serif";
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = "center";
|
||||||
ctx.fillText(data.homeTeam, width / 4, currentY);
|
ctx.fillText(data.homeTeam, width / 4, currentY);
|
||||||
ctx.fillText(data.awayTeam, (width / 4) * 3, currentY);
|
ctx.fillText(data.awayTeam, (width / 4) * 3, currentY);
|
||||||
|
|
||||||
// Divider: Skore Prediction
|
// Divider: Skore Prediction
|
||||||
currentY += 140;
|
currentY += 140;
|
||||||
const drawSectionTitle = (y: number, text: string) => {
|
const drawSectionTitle = (y: number, text: string) => {
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = "center";
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
ctx.fillStyle = "rgba(255, 255, 255, 0.5)";
|
||||||
ctx.font = '600 22px sans-serif';
|
ctx.font = "600 22px sans-serif";
|
||||||
ctx.fillText(text, width / 2, y + 8);
|
ctx.fillText(text, width / 2, y + 8);
|
||||||
|
|
||||||
const txtWidth = ctx.measureText(text).width;
|
const txtWidth = ctx.measureText(text).width;
|
||||||
const grad = ctx.createLinearGradient(paddingX, y, width - paddingX, y);
|
const grad = ctx.createLinearGradient(paddingX, y, width - paddingX, y);
|
||||||
grad.addColorStop(0, '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(0.5, "rgba(120, 80, 255, 0.6)");
|
||||||
grad.addColorStop(1, 'rgba(120, 80, 255, 0)');
|
grad.addColorStop(1, "rgba(120, 80, 255, 0)");
|
||||||
|
|
||||||
ctx.fillStyle = grad;
|
ctx.fillStyle = grad;
|
||||||
ctx.fillRect(
|
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
|
// Scores
|
||||||
currentY += 80;
|
currentY += 80;
|
||||||
@@ -235,20 +235,20 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
const ftX = width / 2 + 24;
|
const ftX = width / 2 + 24;
|
||||||
|
|
||||||
// HT Box
|
// HT Box
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)';
|
ctx.fillStyle = "rgba(255, 255, 255, 0.04)";
|
||||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.08)";
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
this.fillRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
|
this.fillRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
|
||||||
this.strokeRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
|
this.strokeRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
|
ctx.fillStyle = "rgba(255, 255, 255, 0.45)";
|
||||||
ctx.font = '600 20px sans-serif';
|
ctx.font = "600 20px sans-serif";
|
||||||
ctx.fillText('İLK YARI', htX + scoreBoxWidth / 2, currentY + 40);
|
ctx.fillText("İLK YARI", htX + scoreBoxWidth / 2, currentY + 40);
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
|
ctx.fillStyle = "rgba(255, 255, 255, 0.25)";
|
||||||
ctx.font = '400 16px sans-serif';
|
ctx.font = "400 16px sans-serif";
|
||||||
ctx.fillText('Half Time', htX + scoreBoxWidth / 2, currentY + 65);
|
ctx.fillText("Half Time", htX + scoreBoxWidth / 2, currentY + 65);
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = "#ffffff";
|
||||||
ctx.font = '900 80px sans-serif';
|
ctx.font = "900 80px sans-serif";
|
||||||
ctx.fillText(data.htScore, htX + scoreBoxWidth / 2, currentY + 160);
|
ctx.fillText(data.htScore, htX + scoreBoxWidth / 2, currentY + 160);
|
||||||
|
|
||||||
// FT Box
|
// FT Box
|
||||||
@@ -258,19 +258,19 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
ftX + scoreBoxWidth,
|
ftX + scoreBoxWidth,
|
||||||
currentY + scoreBoxHeight,
|
currentY + scoreBoxHeight,
|
||||||
);
|
);
|
||||||
ftGrad.addColorStop(0, 'rgba(120, 80, 255, 0.15)');
|
ftGrad.addColorStop(0, "rgba(120, 80, 255, 0.15)");
|
||||||
ftGrad.addColorStop(1, 'rgba(0, 200, 255, 0.1)');
|
ftGrad.addColorStop(1, "rgba(0, 200, 255, 0.1)");
|
||||||
ctx.fillStyle = ftGrad;
|
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.fillRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
|
||||||
this.strokeRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
|
this.strokeRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
|
ctx.fillStyle = "rgba(255, 255, 255, 0.45)";
|
||||||
ctx.font = '600 20px sans-serif';
|
ctx.font = "600 20px sans-serif";
|
||||||
ctx.fillText('MAÇ SONU', ftX + scoreBoxWidth / 2, currentY + 40);
|
ctx.fillText("MAÇ SONU", ftX + scoreBoxWidth / 2, currentY + 40);
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
|
ctx.fillStyle = "rgba(255, 255, 255, 0.25)";
|
||||||
ctx.font = '400 16px sans-serif';
|
ctx.font = "400 16px sans-serif";
|
||||||
ctx.fillText('Full Time', ftX + scoreBoxWidth / 2, currentY + 65);
|
ctx.fillText("Full Time", ftX + scoreBoxWidth / 2, currentY + 65);
|
||||||
|
|
||||||
// Score text gradient
|
// Score text gradient
|
||||||
const txtGrad = ctx.createLinearGradient(
|
const txtGrad = ctx.createLinearGradient(
|
||||||
@@ -279,15 +279,15 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
ftX,
|
ftX,
|
||||||
currentY + 160,
|
currentY + 160,
|
||||||
);
|
);
|
||||||
txtGrad.addColorStop(0, '#9b6fff');
|
txtGrad.addColorStop(0, "#9b6fff");
|
||||||
txtGrad.addColorStop(1, '#00c8ff');
|
txtGrad.addColorStop(1, "#00c8ff");
|
||||||
ctx.fillStyle = txtGrad;
|
ctx.fillStyle = txtGrad;
|
||||||
ctx.font = '900 80px sans-serif';
|
ctx.font = "900 80px sans-serif";
|
||||||
ctx.fillText(data.ftScore, ftX + scoreBoxWidth / 2, currentY + 160);
|
ctx.fillText(data.ftScore, ftX + scoreBoxWidth / 2, currentY + 160);
|
||||||
|
|
||||||
// Confidence badge
|
// Confidence badge
|
||||||
ctx.fillStyle = '#0a0e27';
|
ctx.fillStyle = "#0a0e27";
|
||||||
ctx.strokeStyle = 'rgba(120, 80, 255, 0.6)';
|
ctx.strokeStyle = "rgba(120, 80, 255, 0.6)";
|
||||||
this.fillRoundRect(
|
this.fillRoundRect(
|
||||||
ctx,
|
ctx,
|
||||||
ftX + scoreBoxWidth / 2 - 80,
|
ftX + scoreBoxWidth / 2 - 80,
|
||||||
@@ -304,8 +304,8 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
40,
|
40,
|
||||||
20,
|
20,
|
||||||
);
|
);
|
||||||
ctx.fillStyle = '#b89dff';
|
ctx.fillStyle = "#b89dff";
|
||||||
ctx.font = '800 20px sans-serif';
|
ctx.font = "800 20px sans-serif";
|
||||||
ctx.fillText(
|
ctx.fillText(
|
||||||
`🎯 %${data.scoreConfidence}`,
|
`🎯 %${data.scoreConfidence}`,
|
||||||
ftX + scoreBoxWidth / 2,
|
ftX + scoreBoxWidth / 2,
|
||||||
@@ -314,13 +314,13 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
|
|
||||||
// Divider: Picks
|
// Divider: Picks
|
||||||
currentY += scoreBoxHeight + 100;
|
currentY += scoreBoxHeight + 100;
|
||||||
drawSectionTitle(currentY, 'EN İYİ TAHMİNLER / BEST PICKS');
|
drawSectionTitle(currentY, "EN İYİ TAHMİNLER / BEST PICKS");
|
||||||
|
|
||||||
// Picks rendering
|
// Picks rendering
|
||||||
currentY += 80;
|
currentY += 80;
|
||||||
data.topPicks.forEach((pick, index) => {
|
data.topPicks.forEach((pick, index) => {
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.03)';
|
ctx.fillStyle = "rgba(255, 255, 255, 0.03)";
|
||||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.06)";
|
||||||
this.fillRoundRect(
|
this.fillRoundRect(
|
||||||
ctx,
|
ctx,
|
||||||
paddingX,
|
paddingX,
|
||||||
@@ -338,18 +338,18 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
16,
|
16,
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
ctx.fillStyle = "rgba(255, 255, 255, 0.3)";
|
||||||
ctx.font = '700 28px sans-serif';
|
ctx.font = "700 28px sans-serif";
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = "left";
|
||||||
ctx.fillText(String(index + 1), paddingX + 30, currentY + 58);
|
ctx.fillText(String(index + 1), paddingX + 30, currentY + 58);
|
||||||
|
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = "#ffffff";
|
||||||
ctx.font = '600 26px sans-serif';
|
ctx.font = "600 26px sans-serif";
|
||||||
ctx.fillText(pick.market, paddingX + 80, currentY + 45);
|
ctx.fillText(pick.market, paddingX + 80, currentY + 45);
|
||||||
|
|
||||||
const marketWidth = ctx.measureText(pick.market).width;
|
const marketWidth = ctx.measureText(pick.market).width;
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
|
ctx.fillStyle = "rgba(255, 255, 255, 0.35)";
|
||||||
ctx.font = '400 18px sans-serif';
|
ctx.font = "400 18px sans-serif";
|
||||||
ctx.fillText(
|
ctx.fillText(
|
||||||
`(${pick.marketEn})`,
|
`(${pick.marketEn})`,
|
||||||
paddingX + 80 + marketWidth + 10,
|
paddingX + 80 + marketWidth + 10,
|
||||||
@@ -357,7 +357,7 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Pick Bar bg
|
// 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;
|
const barMaxWidth = width - 2 * paddingX - 220;
|
||||||
this.fillRoundRect(ctx, paddingX + 80, currentY + 65, barMaxWidth, 12, 6);
|
this.fillRoundRect(ctx, paddingX + 80, currentY + 65, barMaxWidth, 12, 6);
|
||||||
|
|
||||||
@@ -369,15 +369,15 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
paddingX + 80 + barMaxWidth,
|
paddingX + 80 + barMaxWidth,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
barGrad.addColorStop(0, '#7850ff');
|
barGrad.addColorStop(0, "#7850ff");
|
||||||
barGrad.addColorStop(1, '#00c8ff');
|
barGrad.addColorStop(1, "#00c8ff");
|
||||||
ctx.fillStyle = barGrad;
|
ctx.fillStyle = barGrad;
|
||||||
this.fillRoundRect(ctx, paddingX + 80, currentY + 65, fillWidth, 12, 6);
|
this.fillRoundRect(ctx, paddingX + 80, currentY + 65, fillWidth, 12, 6);
|
||||||
|
|
||||||
// Confidence text
|
// Confidence text
|
||||||
ctx.fillStyle = '#b89dff';
|
ctx.fillStyle = "#b89dff";
|
||||||
ctx.font = '900 32px sans-serif';
|
ctx.font = "900 32px sans-serif";
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = "right";
|
||||||
ctx.fillText(`%${pick.confidence}`, width - paddingX - 30, currentY + 58);
|
ctx.fillText(`%${pick.confidence}`, width - paddingX - 30, currentY + 58);
|
||||||
|
|
||||||
currentY += 124;
|
currentY += 124;
|
||||||
@@ -385,41 +385,41 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
currentY = height - 80;
|
currentY = height - 80;
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
|
ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
|
||||||
ctx.font = '700 26px sans-serif';
|
ctx.font = "700 26px sans-serif";
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = "left";
|
||||||
ctx.fillText('⚡ AI Powered by SuggestBet', paddingX, currentY);
|
ctx.fillText("⚡ AI Powered by SuggestBet", paddingX, currentY);
|
||||||
|
|
||||||
let riskBg, riskColor, riskBorder;
|
let riskBg, riskColor, riskBorder;
|
||||||
switch (data.riskLevel) {
|
switch (data.riskLevel) {
|
||||||
case 'LOW':
|
case "LOW":
|
||||||
riskBg = 'rgba(0, 200, 100, 0.15)';
|
riskBg = "rgba(0, 200, 100, 0.15)";
|
||||||
riskColor = '#4ade80';
|
riskColor = "#4ade80";
|
||||||
riskBorder = 'rgba(0, 200, 100, 0.3)';
|
riskBorder = "rgba(0, 200, 100, 0.3)";
|
||||||
break;
|
break;
|
||||||
case 'MEDIUM':
|
case "MEDIUM":
|
||||||
riskBg = 'rgba(255, 200, 0, 0.12)';
|
riskBg = "rgba(255, 200, 0, 0.12)";
|
||||||
riskColor = '#fbbf24';
|
riskColor = "#fbbf24";
|
||||||
riskBorder = 'rgba(255, 200, 0, 0.25)';
|
riskBorder = "rgba(255, 200, 0, 0.25)";
|
||||||
break;
|
break;
|
||||||
case 'HIGH':
|
case "HIGH":
|
||||||
riskBg = 'rgba(255, 100, 50, 0.12)';
|
riskBg = "rgba(255, 100, 50, 0.12)";
|
||||||
riskColor = '#f97316';
|
riskColor = "#f97316";
|
||||||
riskBorder = 'rgba(255, 100, 50, 0.25)';
|
riskBorder = "rgba(255, 100, 50, 0.25)";
|
||||||
break;
|
break;
|
||||||
case 'EXTREME':
|
case "EXTREME":
|
||||||
riskBg = 'rgba(255, 50, 50, 0.15)';
|
riskBg = "rgba(255, 50, 50, 0.15)";
|
||||||
riskColor = '#ef4444';
|
riskColor = "#ef4444";
|
||||||
riskBorder = 'rgba(255, 50, 50, 0.3)';
|
riskBorder = "rgba(255, 50, 50, 0.3)";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
riskBg = 'rgba(255, 255, 255, 0.1)';
|
riskBg = "rgba(255, 255, 255, 0.1)";
|
||||||
riskColor = '#ffffff';
|
riskColor = "#ffffff";
|
||||||
riskBorder = 'rgba(255, 255, 255, 0.3)';
|
riskBorder = "rgba(255, 255, 255, 0.3)";
|
||||||
}
|
}
|
||||||
|
|
||||||
const riskText = `RISK: ${data.riskLevel}`;
|
const riskText = `RISK: ${data.riskLevel}`;
|
||||||
ctx.font = '800 20px sans-serif';
|
ctx.font = "800 20px sans-serif";
|
||||||
const riskWidth = ctx.measureText(riskText).width;
|
const riskWidth = ctx.measureText(riskText).width;
|
||||||
ctx.fillStyle = riskBg;
|
ctx.fillStyle = riskBg;
|
||||||
ctx.strokeStyle = riskBorder;
|
ctx.strokeStyle = riskBorder;
|
||||||
@@ -441,11 +441,11 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
|
|
||||||
ctx.fillStyle = riskColor;
|
ctx.fillStyle = riskColor;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = "center";
|
||||||
ctx.fillText(riskText, width - paddingX - riskWidth / 2 - 24, currentY + 3);
|
ctx.fillText(riskText, width - paddingX - riskWidth / 2 - 24, currentY + 3);
|
||||||
|
|
||||||
// Save Output directly using the buffer
|
// Save Output directly using the buffer
|
||||||
const buffer = canvas.toBuffer('image/png');
|
const buffer = canvas.toBuffer("image/png");
|
||||||
fs.writeFileSync(outPath, buffer);
|
fs.writeFileSync(outPath, buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,9 +454,9 @@ export class ImageRendererService implements OnModuleInit {
|
|||||||
*/
|
*/
|
||||||
getImageUrl(filePath: string): string {
|
getImageUrl(filePath: string): string {
|
||||||
const relativePath = path.relative(
|
const relativePath = path.relative(
|
||||||
path.join(process.cwd(), 'public'),
|
path.join(process.cwd(), "public"),
|
||||||
filePath,
|
filePath,
|
||||||
);
|
);
|
||||||
return `/${relativePath.replace(/\\/g, '/')}`;
|
return `/${relativePath.replace(/\\/g, "/")}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MetaService {
|
export class MetaService {
|
||||||
@@ -10,21 +10,21 @@ export class MetaService {
|
|||||||
private readonly pageId: string;
|
private readonly pageId: string;
|
||||||
private readonly igUserId: string;
|
private readonly igUserId: string;
|
||||||
private readonly isEnabled: boolean;
|
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) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
this.pageAccessToken =
|
this.pageAccessToken =
|
||||||
this.configService.get<string>('META_PAGE_ACCESS_TOKEN') || '';
|
this.configService.get<string>("META_PAGE_ACCESS_TOKEN") || "";
|
||||||
this.pageId = this.configService.get<string>('META_PAGE_ID') || '';
|
this.pageId = this.configService.get<string>("META_PAGE_ID") || "";
|
||||||
this.igUserId = this.configService.get<string>('META_IG_USER_ID') || '';
|
this.igUserId = this.configService.get<string>("META_IG_USER_ID") || "";
|
||||||
|
|
||||||
this.isEnabled = !!(this.pageAccessToken && this.pageId);
|
this.isEnabled = !!(this.pageAccessToken && this.pageId);
|
||||||
|
|
||||||
if (this.isEnabled) {
|
if (this.isEnabled) {
|
||||||
this.logger.log('✅ Meta API client initialized');
|
this.logger.log("✅ Meta API client initialized");
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(
|
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,
|
imageUrl: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (!this.facebookAvailable) {
|
if (!this.facebookAvailable) {
|
||||||
this.logger.warn('Facebook not available, skipping post');
|
this.logger.warn("Facebook not available, skipping post");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ export class MetaService {
|
|||||||
imageUrl: string,
|
imageUrl: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (!this.instagramAvailable) {
|
if (!this.instagramAvailable) {
|
||||||
this.logger.warn('Instagram not available, skipping post');
|
this.logger.warn("Instagram not available, skipping post");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ export class MetaService {
|
|||||||
|
|
||||||
const containerId = containerResponse.data?.id;
|
const containerId = containerResponse.data?.id;
|
||||||
if (!containerId) {
|
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)
|
// Wait for container processing (IG needs a few seconds)
|
||||||
@@ -156,25 +156,25 @@ export class MetaService {
|
|||||||
`${this.graphApiBase}/${containerId}`,
|
`${this.graphApiBase}/${containerId}`,
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
fields: 'status_code',
|
fields: "status_code",
|
||||||
access_token: this.pageAccessToken,
|
access_token: this.pageAccessToken,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const status = response.data?.status_code;
|
const status = response.data?.status_code;
|
||||||
if (status === 'FINISHED') return;
|
if (status === "FINISHED") return;
|
||||||
if (status === 'ERROR') {
|
if (status === "ERROR") {
|
||||||
throw new Error('Container processing failed');
|
throw new Error("Container processing failed");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === 'Container processing failed') throw error;
|
if (error.message === "Container processing failed") throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait 2 seconds before checking again
|
// Wait 2 seconds before checking again
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
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 { Controller, Post, Param, Get, UseGuards } from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { SocialPosterService } from './social-poster.service';
|
import { SocialPosterService } from "./social-poster.service";
|
||||||
import { Roles } from '../../common/decorators';
|
import { Roles } from "../../common/decorators";
|
||||||
import { RolesGuard } from '../auth/guards/auth.guards';
|
import { RolesGuard } from "../auth/guards/auth.guards";
|
||||||
|
|
||||||
@ApiTags('Social Poster')
|
@ApiTags("Social Poster")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@Roles('admin')
|
@Roles("admin")
|
||||||
@Controller('social-poster')
|
@Controller("social-poster")
|
||||||
export class SocialPosterController {
|
export class SocialPosterController {
|
||||||
constructor(private readonly socialPosterService: SocialPosterService) {}
|
constructor(private readonly socialPosterService: SocialPosterService) {}
|
||||||
|
|
||||||
@Get('preview/:matchId')
|
@Get("preview/:matchId")
|
||||||
async previewCard(@Param('matchId') matchId: string) {
|
async previewCard(@Param("matchId") matchId: string) {
|
||||||
return this.socialPosterService.renderPreview(matchId);
|
return this.socialPosterService.renderPreview(matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('post/:matchId')
|
@Post("post/:matchId")
|
||||||
async postMatch(@Param('matchId') matchId: string) {
|
async postMatch(@Param("matchId") matchId: string) {
|
||||||
return this.socialPosterService.manualPost(matchId);
|
return this.socialPosterService.manualPost(matchId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
|
|
||||||
import { SocialPosterService } from './social-poster.service';
|
import { SocialPosterService } from "./social-poster.service";
|
||||||
import { ImageRendererService } from './image-renderer.service';
|
import { ImageRendererService } from "./image-renderer.service";
|
||||||
import { CaptionGeneratorService } from './caption-generator.service';
|
import { CaptionGeneratorService } from "./caption-generator.service";
|
||||||
import { TwitterService } from './twitter.service';
|
import { TwitterService } from "./twitter.service";
|
||||||
import { MetaService } from './meta.service';
|
import { MetaService } from "./meta.service";
|
||||||
|
|
||||||
import { SocialPosterController } from './social-poster.controller';
|
import { SocialPosterController } from "./social-poster.controller";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Social Poster Module
|
* Social Poster Module
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { Cron } from '@nestjs/schedule';
|
import { Cron } from "@nestjs/schedule";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
import * as fs from 'fs';
|
import * as fs from "fs";
|
||||||
import * as path from 'path';
|
import * as path from "path";
|
||||||
|
|
||||||
import { ImageRendererService } from './image-renderer.service';
|
import { ImageRendererService } from "./image-renderer.service";
|
||||||
import { CaptionGeneratorService } from './caption-generator.service';
|
import { CaptionGeneratorService } from "./caption-generator.service";
|
||||||
import { TwitterService } from './twitter.service';
|
import { TwitterService } from "./twitter.service";
|
||||||
import { MetaService } from './meta.service';
|
import { MetaService } from "./meta.service";
|
||||||
import {
|
import {
|
||||||
PredictionCardDto,
|
PredictionCardDto,
|
||||||
TopPick,
|
TopPick,
|
||||||
SocialPostResult,
|
SocialPostResult,
|
||||||
} from './dto/prediction-card.dto';
|
} from "./dto/prediction-card.dto";
|
||||||
|
|
||||||
// Top leagues loaded once
|
// 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()
|
@Injectable()
|
||||||
export class SocialPosterService {
|
export class SocialPosterService {
|
||||||
@@ -38,24 +38,24 @@ export class SocialPosterService {
|
|||||||
private readonly metaService: MetaService,
|
private readonly metaService: MetaService,
|
||||||
) {
|
) {
|
||||||
this.aiEngineUrl =
|
this.aiEngineUrl =
|
||||||
this.configService.get<string>('AI_ENGINE_URL') ||
|
this.configService.get<string>("AI_ENGINE_URL") ||
|
||||||
'http://localhost:8000';
|
"http://localhost:8000";
|
||||||
this.appBaseUrl =
|
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.isEnabled =
|
||||||
this.configService.get<string>('SOCIAL_POSTER_ENABLED') === 'true';
|
this.configService.get<string>("SOCIAL_POSTER_ENABLED") === "true";
|
||||||
|
|
||||||
this.loadTopLeagues();
|
this.loadTopLeagues();
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadTopLeagues() {
|
private loadTopLeagues() {
|
||||||
try {
|
try {
|
||||||
const data = fs.readFileSync(TOP_LEAGUES_PATH, 'utf-8');
|
const data = fs.readFileSync(TOP_LEAGUES_PATH, "utf-8");
|
||||||
const ids = JSON.parse(data);
|
const ids = JSON.parse(data);
|
||||||
this.topLeagueIds = new Set(ids);
|
this.topLeagueIds = new Set(ids);
|
||||||
this.logger.log(`✅ Loaded ${this.topLeagueIds.size} top league IDs`);
|
this.logger.log(`✅ Loaded ${this.topLeagueIds.size} top league IDs`);
|
||||||
} catch {
|
} 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.
|
* Cron: Every 10 minutes, check for upcoming matches.
|
||||||
* Posts predictions 30 minutes before kickoff.
|
* Posts predictions 30 minutes before kickoff.
|
||||||
*/
|
*/
|
||||||
@Cron('*/10 * * * *')
|
@Cron("*/10 * * * *")
|
||||||
async checkAndPostUpcomingMatches() {
|
async checkAndPostUpcomingMatches() {
|
||||||
if (!this.isEnabled) return;
|
if (!this.isEnabled) return;
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ export class SocialPosterService {
|
|||||||
|
|
||||||
const matches = await this.prisma.liveMatch.findMany({
|
const matches = await this.prisma.liveMatch.findMany({
|
||||||
where: {
|
where: {
|
||||||
sport: 'football',
|
sport: "football",
|
||||||
leagueId: { in: Array.from(this.topLeagueIds) },
|
leagueId: { in: Array.from(this.topLeagueIds) },
|
||||||
mstUtc: {
|
mstUtc: {
|
||||||
gte: minTime,
|
gte: minTime,
|
||||||
@@ -144,7 +144,7 @@ export class SocialPosterService {
|
|||||||
// Step 1: Get prediction from AI Engine
|
// Step 1: Get prediction from AI Engine
|
||||||
const prediction = await this.getPrediction(matchId);
|
const prediction = await this.getPrediction(matchId);
|
||||||
if (!prediction) {
|
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
|
// Step 2: Build prediction card data
|
||||||
@@ -194,9 +194,9 @@ export class SocialPosterService {
|
|||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`✅ Posted: ${match.homeTeam?.name} vs ${match.awayTeam?.name} ` +
|
`✅ Posted: ${match.homeTeam?.name} vs ${match.awayTeam?.name} ` +
|
||||||
`[TW: ${result.twitterPostId ? '✅' : '❌'}, ` +
|
`[TW: ${result.twitterPostId ? "✅" : "❌"}, ` +
|
||||||
`FB: ${result.facebookPostId ? '✅' : '❌'}, ` +
|
`FB: ${result.facebookPostId ? "✅" : "❌"}, ` +
|
||||||
`IG: ${result.instagramPostId ? '✅' : '❌'}]`,
|
`IG: ${result.instagramPostId ? "✅" : "❌"}]`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -229,8 +229,8 @@ export class SocialPosterService {
|
|||||||
): PredictionCardDto {
|
): PredictionCardDto {
|
||||||
// V20+ returns score_prediction.ft / .ht
|
// V20+ returns score_prediction.ft / .ht
|
||||||
const score = prediction.score_prediction || {};
|
const score = prediction.score_prediction || {};
|
||||||
const htScore = score.ht || '0-0';
|
const htScore = score.ht || "0-0";
|
||||||
const ftScore = score.ft || '1-1';
|
const ftScore = score.ft || "1-1";
|
||||||
|
|
||||||
// Extract best bets from bet_summary array
|
// Extract best bets from bet_summary array
|
||||||
const topPicks = this.extractTopPicks(prediction);
|
const topPicks = this.extractTopPicks(prediction);
|
||||||
@@ -247,18 +247,18 @@ export class SocialPosterService {
|
|||||||
return {
|
return {
|
||||||
matchId: match.id,
|
matchId: match.id,
|
||||||
homeTeam:
|
homeTeam:
|
||||||
match.homeTeam?.name || prediction.match_info?.home_team || 'Home',
|
match.homeTeam?.name || prediction.match_info?.home_team || "Home",
|
||||||
awayTeam:
|
awayTeam:
|
||||||
match.awayTeam?.name || prediction.match_info?.away_team || 'Away',
|
match.awayTeam?.name || prediction.match_info?.away_team || "Away",
|
||||||
homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ''),
|
homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ""),
|
||||||
awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ''),
|
awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ""),
|
||||||
leagueName: match.league?.name || prediction.match_info?.league || '',
|
leagueName: match.league?.name || prediction.match_info?.league || "",
|
||||||
matchDate,
|
matchDate,
|
||||||
htScore,
|
htScore,
|
||||||
ftScore,
|
ftScore,
|
||||||
scoreConfidence,
|
scoreConfidence,
|
||||||
topPicks,
|
topPicks,
|
||||||
riskLevel: prediction.risk?.level || 'MEDIUM',
|
riskLevel: prediction.risk?.level || "MEDIUM",
|
||||||
rawPrediction: prediction,
|
rawPrediction: prediction,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -271,16 +271,16 @@ export class SocialPosterService {
|
|||||||
|
|
||||||
// Market code to Turkish/English label mapping
|
// Market code to Turkish/English label mapping
|
||||||
const marketLabels: Record<string, { tr: string; en: string }> = {
|
const marketLabels: Record<string, { tr: string; en: string }> = {
|
||||||
MS: { tr: 'Maç Sonucu', en: 'Match Result' },
|
MS: { tr: "Maç Sonucu", en: "Match Result" },
|
||||||
OU15: { tr: 'Üst 1.5 Gol', en: 'Over 1.5' },
|
OU15: { tr: "Üst 1.5 Gol", en: "Over 1.5" },
|
||||||
OU25: { tr: 'Üst 2.5 Gol', en: 'Over 2.5' },
|
OU25: { tr: "Üst 2.5 Gol", en: "Over 2.5" },
|
||||||
OU35: { tr: 'Üst 3.5 Gol', en: 'Over 3.5' },
|
OU35: { tr: "Üst 3.5 Gol", en: "Over 3.5" },
|
||||||
BTTS: { tr: 'Karşılıklı Gol', en: 'Both Teams Score' },
|
BTTS: { tr: "Karşılıklı Gol", en: "Both Teams Score" },
|
||||||
DC: { tr: 'Çifte Şans', en: 'Double Chance' },
|
DC: { tr: "Çifte Şans", en: "Double Chance" },
|
||||||
HT: { tr: 'İlk Yarı Sonucu', en: 'Half Time Result' },
|
HT: { tr: "İlk Yarı Sonucu", en: "Half Time Result" },
|
||||||
HT_OU05: { tr: 'İY 0.5 Üst/Alt', en: 'HT Over/Under 0.5' },
|
HT_OU05: { tr: "İY 0.5 Üst/Alt", en: "HT Over/Under 0.5" },
|
||||||
OE: { tr: 'Tek/Çift', en: 'Odd/Even' },
|
OE: { tr: "Tek/Çift", en: "Odd/Even" },
|
||||||
HTFT: { tr: 'İY/MS', en: 'HT/FT' },
|
HTFT: { tr: "İY/MS", en: "HT/FT" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const candidates: TopPick[] = betSummary.map((bet) => {
|
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.
|
* Locally during dev, we fetch them from the deployed server via APP_BASE_URL.
|
||||||
*/
|
*/
|
||||||
private resolveLogoUrl(logoUrl: string): string {
|
private resolveLogoUrl(logoUrl: string): string {
|
||||||
if (!logoUrl) return '';
|
if (!logoUrl) return "";
|
||||||
// Already a full URL
|
// 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
|
// 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
|
if (fs.existsSync(localPath)) return logoUrl; // Keep relative, renderer reads local
|
||||||
// Not local → prepend base URL for remote fetch
|
// Not local → prepend base URL for remote fetch
|
||||||
return `${this.appBaseUrl}${logoUrl}`;
|
return `${this.appBaseUrl}${logoUrl}`;
|
||||||
@@ -321,24 +321,24 @@ export class SocialPosterService {
|
|||||||
private formatMatchDate(mstUtc: number | bigint): string {
|
private formatMatchDate(mstUtc: number | bigint): string {
|
||||||
const d = new Date(Number(mstUtc));
|
const d = new Date(Number(mstUtc));
|
||||||
const months = [
|
const months = [
|
||||||
'Oca',
|
"Oca",
|
||||||
'Şub',
|
"Şub",
|
||||||
'Mar',
|
"Mar",
|
||||||
'Nis',
|
"Nis",
|
||||||
'May',
|
"May",
|
||||||
'Haz',
|
"Haz",
|
||||||
'Tem',
|
"Tem",
|
||||||
'Ağu',
|
"Ağu",
|
||||||
'Eyl',
|
"Eyl",
|
||||||
'Eki',
|
"Eki",
|
||||||
'Kas',
|
"Kas",
|
||||||
'Ara',
|
"Ara",
|
||||||
];
|
];
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
const month = months[d.getMonth()];
|
const month = months[d.getMonth()];
|
||||||
const year = d.getFullYear();
|
const year = d.getFullYear();
|
||||||
const hour = String(d.getHours()).padStart(2, '0');
|
const hour = String(d.getHours()).padStart(2, "0");
|
||||||
const min = String(d.getMinutes()).padStart(2, '0');
|
const min = String(d.getMinutes()).padStart(2, "0");
|
||||||
return `${day} ${month} ${year} - ${hour}:${min}`;
|
return `${day} ${month} ${year} - ${hour}:${min}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,7 +383,7 @@ export class SocialPosterService {
|
|||||||
|
|
||||||
const prediction = await this.getPrediction(matchId);
|
const prediction = await this.getPrediction(matchId);
|
||||||
if (!prediction) {
|
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);
|
const card = this.buildCardFromPrediction(match, prediction);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import * as fs from 'fs';
|
import * as fs from "fs";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TwitterService {
|
export class TwitterService {
|
||||||
@@ -9,18 +9,18 @@ export class TwitterService {
|
|||||||
private isEnabled = false;
|
private isEnabled = false;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
const apiKey = this.configService.get<string>('TWITTER_API_KEY');
|
const apiKey = this.configService.get<string>("TWITTER_API_KEY");
|
||||||
const apiSecret = this.configService.get<string>('TWITTER_API_SECRET');
|
const apiSecret = this.configService.get<string>("TWITTER_API_SECRET");
|
||||||
const accessToken = this.configService.get<string>('TWITTER_ACCESS_TOKEN');
|
const accessToken = this.configService.get<string>("TWITTER_ACCESS_TOKEN");
|
||||||
const accessSecret = this.configService.get<string>(
|
const accessSecret = this.configService.get<string>(
|
||||||
'TWITTER_ACCESS_SECRET',
|
"TWITTER_ACCESS_SECRET",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (apiKey && apiSecret && accessToken && accessSecret) {
|
if (apiKey && apiSecret && accessToken && accessSecret) {
|
||||||
void this.initClient(apiKey, apiSecret, accessToken, accessSecret);
|
void this.initClient(apiKey, apiSecret, accessToken, accessSecret);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(
|
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,
|
accessSecret: string,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { TwitterApi } = await import('twitter-api-v2');
|
const { TwitterApi } = await import("twitter-api-v2");
|
||||||
this.client = new TwitterApi({
|
this.client = new TwitterApi({
|
||||||
appKey: apiKey,
|
appKey: apiKey,
|
||||||
appSecret: apiSecret,
|
appSecret: apiSecret,
|
||||||
@@ -40,9 +40,9 @@ export class TwitterService {
|
|||||||
accessSecret,
|
accessSecret,
|
||||||
});
|
});
|
||||||
this.isEnabled = true;
|
this.isEnabled = true;
|
||||||
this.logger.log('✅ Twitter API client initialized');
|
this.logger.log("✅ Twitter API client initialized");
|
||||||
} catch (error) {
|
} 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> {
|
async postWithImage(text: string, imagePath: string): Promise<string | null> {
|
||||||
if (!this.available) {
|
if (!this.available) {
|
||||||
this.logger.warn('Twitter not available, skipping post');
|
this.logger.warn("Twitter not available, skipping post");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export class TwitterService {
|
|||||||
// Step 1: Upload media via v1.1
|
// Step 1: Upload media via v1.1
|
||||||
const mediaData = fs.readFileSync(imagePath);
|
const mediaData = fs.readFileSync(imagePath);
|
||||||
const mediaId = await this.client.v1.uploadMedia(mediaData, {
|
const mediaId = await this.client.v1.uploadMedia(mediaData, {
|
||||||
mimeType: 'image/png',
|
mimeType: "image/png",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 2: Create tweet via v2
|
// Step 2: Create tweet via v2
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
@@ -10,37 +10,37 @@ import {
|
|||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from "class-validator";
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
// ─── Bulletin Match Item (used in CreateBulletinDto) ───
|
// ─── Bulletin Match Item (used in CreateBulletinDto) ───
|
||||||
|
|
||||||
export class BulletinMatchItemDto {
|
export class BulletinMatchItemDto {
|
||||||
@ApiProperty({ example: 1, description: 'Sıra numarası (1-15)' })
|
@ApiProperty({ example: 1, description: "Sıra numarası (1-15)" })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(15)
|
@Max(15)
|
||||||
matchOrder: number;
|
matchOrder: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Blackpool' })
|
@ApiProperty({ example: "Blackpool" })
|
||||||
@IsString()
|
@IsString()
|
||||||
homeTeamName: string;
|
homeTeamName: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Burton Albion' })
|
@ApiProperty({ example: "Burton Albion" })
|
||||||
@IsString()
|
@IsString()
|
||||||
awayTeamName: string;
|
awayTeamName: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'İN1' })
|
@ApiPropertyOptional({ example: "İN1" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
leagueName?: string;
|
leagueName?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '2026-03-28T18:00:00' })
|
@ApiPropertyOptional({ example: "2026-03-28T18:00:00" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
kickoffTime?: string;
|
kickoffTime?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Link to existing match ID' })
|
@ApiPropertyOptional({ description: "Link to existing match ID" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
matchId?: string;
|
matchId?: string;
|
||||||
@@ -49,26 +49,26 @@ export class BulletinMatchItemDto {
|
|||||||
// ─── Create Bulletin DTO ───
|
// ─── Create Bulletin DTO ───
|
||||||
|
|
||||||
export class CreateBulletinDto {
|
export class CreateBulletinDto {
|
||||||
@ApiProperty({ example: 333, description: 'Game cycle number from API' })
|
@ApiProperty({ example: 333, description: "Game cycle number from API" })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
gameCycleNo: number;
|
gameCycleNo: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '27-29 Mart' })
|
@ApiPropertyOptional({ example: "27-29 Mart" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
programName?: string;
|
programName?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '2025-2026' })
|
@ApiPropertyOptional({ example: "2025-2026" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
season?: string;
|
season?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '2026-03-22T10:00:00' })
|
@ApiPropertyOptional({ example: "2026-03-22T10:00:00" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
payinBeginDate?: string;
|
payinBeginDate?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '2026-03-27T20:55:00' })
|
@ApiPropertyOptional({ example: "2026-03-27T20:55:00" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
payinEndDate?: string;
|
payinEndDate?: string;
|
||||||
@@ -83,24 +83,24 @@ export class CreateBulletinDto {
|
|||||||
// ─── Update Results DTO ───
|
// ─── Update Results DTO ───
|
||||||
|
|
||||||
export class MatchResultDto {
|
export class MatchResultDto {
|
||||||
@ApiProperty({ example: 1, description: 'Match order (1-15)' })
|
@ApiProperty({ example: 1, description: "Match order (1-15)" })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(15)
|
@Max(15)
|
||||||
matchOrder: number;
|
matchOrder: number;
|
||||||
|
|
||||||
@ApiProperty({ enum: ['HOME', 'DRAW', 'AWAY'], example: 'HOME' })
|
@ApiProperty({ enum: ["HOME", "DRAW", "AWAY"], example: "HOME" })
|
||||||
@IsEnum({ HOME: 'HOME', DRAW: 'DRAW', AWAY: 'AWAY' })
|
@IsEnum({ HOME: "HOME", DRAW: "DRAW", AWAY: "AWAY" })
|
||||||
result: 'HOME' | 'DRAW' | 'AWAY';
|
result: "HOME" | "DRAW" | "AWAY";
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: false })
|
@ApiPropertyOptional({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isCancelled?: boolean;
|
isCancelled?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['HOME', 'DRAW', 'AWAY'] })
|
@ApiPropertyOptional({ enum: ["HOME", "DRAW", "AWAY"] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum({ HOME: 'HOME', DRAW: 'DRAW', AWAY: 'AWAY' })
|
@IsEnum({ HOME: "HOME", DRAW: "DRAW", AWAY: "AWAY" })
|
||||||
drawResult?: 'HOME' | 'DRAW' | 'AWAY';
|
drawResult?: "HOME" | "DRAW" | "AWAY";
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateResultsDto {
|
export class UpdateResultsDto {
|
||||||
@@ -110,12 +110,12 @@ export class UpdateResultsDto {
|
|||||||
@Type(() => MatchResultDto)
|
@Type(() => MatchResultDto)
|
||||||
results: MatchResultDto[];
|
results: MatchResultDto[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '15 bilen sayısı' })
|
@ApiPropertyOptional({ description: "15 bilen sayısı" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
winners15?: number;
|
winners15?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '15 bilen ödülü (TL)' })
|
@ApiPropertyOptional({ description: "15 bilen ödülü (TL)" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
prize15?: number;
|
prize15?: number;
|
||||||
@@ -150,7 +150,7 @@ export class UpdateResultsDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
prize12?: number;
|
prize12?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Sonraki haftaya devir' })
|
@ApiPropertyOptional({ description: "Sonraki haftaya devir" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
rolloverNext?: number;
|
rolloverNext?: number;
|
||||||
@@ -158,7 +158,7 @@ export class UpdateResultsDto {
|
|||||||
|
|
||||||
// ─── Generate Columns DTO ───
|
// ─── Generate Columns DTO ───
|
||||||
|
|
||||||
export type TotoSelectionType = '1' | 'X' | '2';
|
export type TotoSelectionType = "1" | "X" | "2";
|
||||||
|
|
||||||
export class TotoMatchSelection {
|
export class TotoMatchSelection {
|
||||||
@ApiProperty({ example: 1 })
|
@ApiProperty({ example: 1 })
|
||||||
@@ -169,15 +169,15 @@ export class TotoMatchSelection {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: [String],
|
type: [String],
|
||||||
example: ['1', 'X'],
|
example: ["1", "X"],
|
||||||
description: 'Seçimler: 1=Ev, X=Beraberlik, 2=Deplasman',
|
description: "Seçimler: 1=Ev, X=Beraberlik, 2=Deplasman",
|
||||||
})
|
})
|
||||||
@IsArray()
|
@IsArray()
|
||||||
selections: TotoSelectionType[];
|
selections: TotoSelectionType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GenerateColumnsDto {
|
export class GenerateColumnsDto {
|
||||||
@ApiProperty({ description: 'Bulletin ID' })
|
@ApiProperty({ description: "Bulletin ID" })
|
||||||
@IsString()
|
@IsString()
|
||||||
bulletinId: string;
|
bulletinId: string;
|
||||||
|
|
||||||
@@ -188,8 +188,8 @@ export class GenerateColumnsDto {
|
|||||||
matchSelections: TotoMatchSelection[];
|
matchSelections: TotoMatchSelection[];
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'FULL_SYSTEM',
|
example: "FULL_SYSTEM",
|
||||||
description: 'FULL_SYSTEM | REDUCED_SYSTEM | MANUAL',
|
description: "FULL_SYSTEM | REDUCED_SYSTEM | MANUAL",
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -197,7 +197,7 @@ export class GenerateColumnsDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 100,
|
example: 100,
|
||||||
description: 'Max kolon sayısı (reduced system için)',
|
description: "Max kolon sayısı (reduced system için)",
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -207,23 +207,23 @@ export class GenerateColumnsDto {
|
|||||||
// ─── Generate AI Prediction DTO ───
|
// ─── Generate AI Prediction DTO ───
|
||||||
|
|
||||||
export class GenerateSporTotoPredictionDto {
|
export class GenerateSporTotoPredictionDto {
|
||||||
@ApiProperty({ description: 'Bulletin ID' })
|
@ApiProperty({ description: "Bulletin ID" })
|
||||||
@IsString()
|
@IsString()
|
||||||
bulletinId: string;
|
bulletinId: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'BALANCED',
|
example: "BALANCED",
|
||||||
enum: ['CONSERVATIVE', 'BALANCED', 'AGGRESSIVE', 'FORMULA_6PCT'],
|
enum: ["CONSERVATIVE", "BALANCED", "AGGRESSIVE", "FORMULA_6PCT"],
|
||||||
description:
|
description:
|
||||||
'CONSERVATIVE(100 col), BALANCED(500), AGGRESSIVE(2500), FORMULA_6PCT(%6 sampling)',
|
"CONSERVATIVE(100 col), BALANCED(500), AGGRESSIVE(2500), FORMULA_6PCT(%6 sampling)",
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
strategy?: 'CONSERVATIVE' | 'BALANCED' | 'AGGRESSIVE' | 'FORMULA_6PCT';
|
strategy?: "CONSERVATIVE" | "BALANCED" | "AGGRESSIVE" | "FORMULA_6PCT";
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 500,
|
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()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@@ -231,7 +231,7 @@ export class GenerateSporTotoPredictionDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 200,
|
example: 200,
|
||||||
description: 'Max kolon sayısı override',
|
description: "Max kolon sayısı override",
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -241,14 +241,14 @@ export class GenerateSporTotoPredictionDto {
|
|||||||
// ─── Evaluate Columns DTO ───
|
// ─── Evaluate Columns DTO ───
|
||||||
|
|
||||||
export class EvaluateColumnsDto {
|
export class EvaluateColumnsDto {
|
||||||
@ApiProperty({ description: 'Bulletin ID' })
|
@ApiProperty({ description: "Bulletin ID" })
|
||||||
@IsString()
|
@IsString()
|
||||||
bulletinId: string;
|
bulletinId: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: [String],
|
type: [String],
|
||||||
example: ['11X2X1XX21X1121'],
|
example: ["11X2X1XX21X1121"],
|
||||||
description: 'Array of 15-char column strings',
|
description: "Array of 15-char column strings",
|
||||||
})
|
})
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaService } from '../../../database/prisma.service';
|
import { PrismaService } from "../../../database/prisma.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spor Toto Analitik Servisi
|
* Spor Toto Analitik Servisi
|
||||||
@@ -91,8 +91,8 @@ export class TotoAnalyticsService {
|
|||||||
consecutiveRollovers: number;
|
consecutiveRollovers: number;
|
||||||
}> {
|
}> {
|
||||||
const bulletins = await this.prisma.totoBulletin.findMany({
|
const bulletins = await this.prisma.totoBulletin.findMany({
|
||||||
where: { status: 'COMPLETED' },
|
where: { status: "COMPLETED" },
|
||||||
orderBy: { gameCycleNo: 'desc' },
|
orderBy: { gameCycleNo: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { result: true },
|
include: { result: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
|
||||||
export interface TotoMatchSelectionInput {
|
export interface TotoMatchSelectionInput {
|
||||||
matchOrder: number;
|
matchOrder: number;
|
||||||
selections: ('1' | 'X' | '2')[];
|
selections: ("1" | "X" | "2")[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeneratedColumn {
|
export interface GeneratedColumn {
|
||||||
@@ -38,7 +38,7 @@ export class TotoCombinatoricsService {
|
|||||||
for (let i = 1; i <= 15; i++) {
|
for (let i = 1; i <= 15; i++) {
|
||||||
const sel = selectionsMap.get(i);
|
const sel = selectionsMap.get(i);
|
||||||
if (!sel || sel.length === 0) {
|
if (!sel || sel.length === 0) {
|
||||||
orderedSelections.push(['1']); // Default: ev sahibi
|
orderedSelections.push(["1"]); // Default: ev sahibi
|
||||||
} else {
|
} else {
|
||||||
orderedSelections.push(sel);
|
orderedSelections.push(sel);
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ export class TotoCombinatoricsService {
|
|||||||
|
|
||||||
// Tüm kombinasyonları üret
|
// Tüm kombinasyonları üret
|
||||||
const columns: GeneratedColumn[] = [];
|
const columns: GeneratedColumn[] = [];
|
||||||
this.generateCombinations(orderedSelections, 0, '', columns);
|
this.generateCombinations(orderedSelections, 0, "", columns);
|
||||||
|
|
||||||
return columns;
|
return columns;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spor Toto API response types
|
* Spor Toto API response types
|
||||||
@@ -45,25 +45,25 @@ export interface SporTotoApiResponse {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class TotoFetcherService {
|
export class TotoFetcherService {
|
||||||
private readonly logger = new Logger(TotoFetcherService.name);
|
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
|
* Fetch current bulletin from Spor Toto API
|
||||||
*/
|
*/
|
||||||
async fetchCurrentBulletin(): Promise<SporTotoApiResponse | null> {
|
async fetchCurrentBulletin(): Promise<SporTotoApiResponse | null> {
|
||||||
try {
|
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, {
|
const response = await axios.get<SporTotoApiResponse>(this.apiUrl, {
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: "application/json",
|
||||||
'User-Agent': 'SuggestBet/1.0',
|
"User-Agent": "SuggestBet/1.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.data?.isSuccess || !response.data?.data) {
|
if (!response.data?.isSuccess || !response.data?.data) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
'Spor Toto API returned unsuccessful response',
|
"Spor Toto API returned unsuccessful response",
|
||||||
response.data?.message,
|
response.data?.message,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
@@ -80,7 +80,7 @@ export class TotoFetcherService {
|
|||||||
error.response?.status,
|
error.response?.status,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.error('Spor Toto fetch failed', error);
|
this.logger.error("Spor Toto fetch failed", error);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -93,30 +93,30 @@ export class TotoFetcherService {
|
|||||||
homeTeam: string;
|
homeTeam: string;
|
||||||
awayTeam: string;
|
awayTeam: string;
|
||||||
} {
|
} {
|
||||||
const parts = eventName.split('-');
|
const parts = eventName.split("-");
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
return {
|
return {
|
||||||
homeTeam: parts[0].trim(),
|
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
|
* Map API result/winner to TotoMatchResult enum value
|
||||||
* API returns: "1" (HOME), "0" (DRAW), "2" (AWAY)
|
* 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;
|
if (!winner) return null;
|
||||||
switch (winner) {
|
switch (winner) {
|
||||||
case '1':
|
case "1":
|
||||||
return 'HOME';
|
return "HOME";
|
||||||
case '0':
|
case "0":
|
||||||
case 'X':
|
case "X":
|
||||||
return 'DRAW';
|
return "DRAW";
|
||||||
case '2':
|
case "2":
|
||||||
return 'AWAY';
|
return "AWAY";
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from "@nestjs/axios";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { PrismaService } from '../../../database/prisma.service';
|
import { PrismaService } from "../../../database/prisma.service";
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from "rxjs";
|
||||||
import {
|
import {
|
||||||
TotoCombinatoricsService,
|
TotoCombinatoricsService,
|
||||||
TotoMatchSelectionInput,
|
TotoMatchSelectionInput,
|
||||||
} from './toto-combinatorics.service';
|
} from "./toto-combinatorics.service";
|
||||||
import { TotoAnalyticsService } from './toto-analytics.service';
|
import { TotoAnalyticsService } from "./toto-analytics.service";
|
||||||
|
|
||||||
// ═══════════ TYPES ═══════════
|
// ═══════════ TYPES ═══════════
|
||||||
|
|
||||||
export type PredictionStrategy =
|
export type PredictionStrategy =
|
||||||
| 'CONSERVATIVE'
|
| "CONSERVATIVE"
|
||||||
| 'BALANCED'
|
| "BALANCED"
|
||||||
| 'AGGRESSIVE'
|
| "AGGRESSIVE"
|
||||||
| 'FORMULA_6PCT';
|
| "FORMULA_6PCT";
|
||||||
|
|
||||||
export type TotoSelection = '1' | 'X' | '2';
|
export type TotoSelection = "1" | "X" | "2";
|
||||||
|
|
||||||
export interface MatchPredictionAnalysis {
|
export interface MatchPredictionAnalysis {
|
||||||
matchOrder: number;
|
matchOrder: number;
|
||||||
@@ -27,7 +27,7 @@ export interface MatchPredictionAnalysis {
|
|||||||
/** Linked matchId from DB (null if not found) */
|
/** Linked matchId from DB (null if not found) */
|
||||||
linkedMatchId: string | null;
|
linkedMatchId: string | null;
|
||||||
/** AI Engine prediction source */
|
/** AI Engine prediction source */
|
||||||
predictionSource: 'AI_ENGINE' | 'HISTORICAL_FORM' | 'FALLBACK';
|
predictionSource: "AI_ENGINE" | "HISTORICAL_FORM" | "FALLBACK";
|
||||||
/** Raw AI probabilities for each outcome */
|
/** Raw AI probabilities for each outcome */
|
||||||
probabilities: { home: number; draw: number; away: number };
|
probabilities: { home: number; draw: number; away: number };
|
||||||
/** AI confidence (0-100) */
|
/** AI confidence (0-100) */
|
||||||
@@ -62,7 +62,7 @@ export interface PredictionResult {
|
|||||||
effectivePool: number;
|
effectivePool: number;
|
||||||
ev15: number;
|
ev15: number;
|
||||||
evPerColumn: number;
|
evPerColumn: number;
|
||||||
recommendation: 'PLAY' | 'WAIT' | 'HIGH_VALUE';
|
recommendation: "PLAY" | "WAIT" | "HIGH_VALUE";
|
||||||
recommendationReason: string;
|
recommendationReason: string;
|
||||||
};
|
};
|
||||||
/** System info */
|
/** System info */
|
||||||
@@ -140,7 +140,7 @@ export class TotoPredictionService {
|
|||||||
private readonly analytics: TotoAnalyticsService,
|
private readonly analytics: TotoAnalyticsService,
|
||||||
) {
|
) {
|
||||||
this.aiEngineUrl =
|
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(
|
async generatePrediction(
|
||||||
bulletinId: string,
|
bulletinId: string,
|
||||||
strategy: PredictionStrategy = 'BALANCED',
|
strategy: PredictionStrategy = "BALANCED",
|
||||||
maxBudget?: number,
|
maxBudget?: number,
|
||||||
): Promise<PredictionResult> {
|
): Promise<PredictionResult> {
|
||||||
const config = STRATEGY_CONFIGS[strategy];
|
const config = STRATEGY_CONFIGS[strategy];
|
||||||
@@ -156,7 +156,7 @@ export class TotoPredictionService {
|
|||||||
// 1. Bülteni getir
|
// 1. Bülteni getir
|
||||||
const bulletin = await this.prisma.totoBulletin.findUnique({
|
const bulletin = await this.prisma.totoBulletin.findUnique({
|
||||||
where: { id: bulletinId },
|
where: { id: bulletinId },
|
||||||
include: { matches: { orderBy: { matchOrder: 'asc' } } },
|
include: { matches: { orderBy: { matchOrder: "asc" } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!bulletin) {
|
if (!bulletin) {
|
||||||
@@ -286,10 +286,10 @@ export class TotoPredictionService {
|
|||||||
// 2. AI Engine'den tahmin al
|
// 2. AI Engine'den tahmin al
|
||||||
let probabilities = { home: 0.33, draw: 0.33, away: 0.34 };
|
let probabilities = { home: 0.33, draw: 0.33, away: 0.34 };
|
||||||
let confidence = 33;
|
let confidence = 33;
|
||||||
let aiPick: TotoSelection = '1';
|
let aiPick: TotoSelection = "1";
|
||||||
let predictionSource: MatchPredictionAnalysis['predictionSource'] =
|
let predictionSource: MatchPredictionAnalysis["predictionSource"] =
|
||||||
'FALLBACK';
|
"FALLBACK";
|
||||||
let reasoning = 'Eşleşme bulunamadı, eşit dağılım kullanıldı';
|
let reasoning = "Eşleşme bulunamadı, eşit dağılım kullanıldı";
|
||||||
|
|
||||||
if (linkedMatchId) {
|
if (linkedMatchId) {
|
||||||
const aiResult = await this.callAiEngine(linkedMatchId);
|
const aiResult = await this.callAiEngine(linkedMatchId);
|
||||||
@@ -298,7 +298,7 @@ export class TotoPredictionService {
|
|||||||
probabilities = aiResult.probabilities;
|
probabilities = aiResult.probabilities;
|
||||||
confidence = aiResult.confidence;
|
confidence = aiResult.confidence;
|
||||||
aiPick = aiResult.pick;
|
aiPick = aiResult.pick;
|
||||||
predictionSource = 'AI_ENGINE';
|
predictionSource = "AI_ENGINE";
|
||||||
reasoning = aiResult.reasoning;
|
reasoning = aiResult.reasoning;
|
||||||
} else {
|
} else {
|
||||||
// AI Engine erişilemez → tarihsel form analizi
|
// AI Engine erişilemez → tarihsel form analizi
|
||||||
@@ -306,7 +306,7 @@ export class TotoPredictionService {
|
|||||||
probabilities = formResult.probabilities;
|
probabilities = formResult.probabilities;
|
||||||
confidence = formResult.confidence;
|
confidence = formResult.confidence;
|
||||||
aiPick = formResult.pick;
|
aiPick = formResult.pick;
|
||||||
predictionSource = 'HISTORICAL_FORM';
|
predictionSource = "HISTORICAL_FORM";
|
||||||
reasoning = formResult.reasoning;
|
reasoning = formResult.reasoning;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -316,7 +316,7 @@ export class TotoPredictionService {
|
|||||||
probabilities = formResult.probabilities;
|
probabilities = formResult.probabilities;
|
||||||
confidence = formResult.confidence;
|
confidence = formResult.confidence;
|
||||||
aiPick = formResult.pick;
|
aiPick = formResult.pick;
|
||||||
predictionSource = 'HISTORICAL_FORM';
|
predictionSource = "HISTORICAL_FORM";
|
||||||
reasoning = formResult.reasoning;
|
reasoning = formResult.reasoning;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,7 +362,7 @@ export class TotoPredictionService {
|
|||||||
>(
|
>(
|
||||||
`SELECT id FROM live_matches
|
`SELECT id FROM live_matches
|
||||||
WHERE LOWER(match_name) LIKE $1 AND LOWER(match_name) LIKE $2
|
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`,
|
LIMIT 1`,
|
||||||
`%${homeNorm}%`,
|
`%${homeNorm}%`,
|
||||||
`%${awayNorm}%`,
|
`%${awayNorm}%`,
|
||||||
@@ -384,7 +384,7 @@ export class TotoPredictionService {
|
|||||||
const match = await this.prisma.$queryRawUnsafe<Array<{ id: string }>>(
|
const match = await this.prisma.$queryRawUnsafe<Array<{ id: string }>>(
|
||||||
`SELECT id FROM matches
|
`SELECT id FROM matches
|
||||||
WHERE LOWER(match_name) LIKE $1 AND LOWER(match_name) LIKE $2
|
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
|
ORDER BY mst_utc DESC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
`%${homeNorm}%`,
|
`%${homeNorm}%`,
|
||||||
@@ -413,14 +413,14 @@ export class TotoPredictionService {
|
|||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/ı/g, 'i')
|
.replace(/ı/g, "i")
|
||||||
.replace(/ğ/g, 'g')
|
.replace(/ğ/g, "g")
|
||||||
.replace(/ü/g, 'u')
|
.replace(/ü/g, "u")
|
||||||
.replace(/ş/g, 's')
|
.replace(/ş/g, "s")
|
||||||
.replace(/ö/g, 'o')
|
.replace(/ö/g, "o")
|
||||||
.replace(/ç/g, 'c')
|
.replace(/ç/g, "c")
|
||||||
.replace(/\./g, '')
|
.replace(/\./g, "")
|
||||||
.replace(/\s+/g, ' ');
|
.replace(/\s+/g, " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════ AI ENGINE INTEGRATION ═══════════
|
// ═══════════ AI ENGINE INTEGRATION ═══════════
|
||||||
@@ -460,9 +460,9 @@ export class TotoPredictionService {
|
|||||||
}>
|
}>
|
||||||
).find(
|
).find(
|
||||||
(b) =>
|
(b) =>
|
||||||
b.market?.toLowerCase().includes('maç sonucu') ||
|
b.market?.toLowerCase().includes("maç sonucu") ||
|
||||||
b.market?.toLowerCase().includes('match result') ||
|
b.market?.toLowerCase().includes("match result") ||
|
||||||
b.market === '1X2',
|
b.market === "1X2",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Score prediction'dan olasılıklar çıkar
|
// Score prediction'dan olasılıklar çıkar
|
||||||
@@ -495,23 +495,23 @@ export class TotoPredictionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pick'i Toto formatına çevir
|
// Pick'i Toto formatına çevir
|
||||||
let pick: TotoSelection = '1';
|
let pick: TotoSelection = "1";
|
||||||
if (msPick) {
|
if (msPick) {
|
||||||
const rawPick = msPick.pick?.toLowerCase();
|
const rawPick = msPick.pick?.toLowerCase();
|
||||||
if (
|
if (
|
||||||
rawPick?.includes('2') ||
|
rawPick?.includes("2") ||
|
||||||
rawPick?.includes('away') ||
|
rawPick?.includes("away") ||
|
||||||
rawPick?.includes('deplasman')
|
rawPick?.includes("deplasman")
|
||||||
) {
|
) {
|
||||||
pick = '2';
|
pick = "2";
|
||||||
} else if (
|
} else if (
|
||||||
rawPick?.includes('x') ||
|
rawPick?.includes("x") ||
|
||||||
rawPick?.includes('draw') ||
|
rawPick?.includes("draw") ||
|
||||||
rawPick?.includes('beraberlik')
|
rawPick?.includes("beraberlik")
|
||||||
) {
|
) {
|
||||||
pick = 'X';
|
pick = "X";
|
||||||
} else {
|
} else {
|
||||||
pick = '1';
|
pick = "1";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No explicit MS pick → use probabilities
|
// No explicit MS pick → use probabilities
|
||||||
@@ -519,16 +519,16 @@ export class TotoPredictionService {
|
|||||||
probabilities.away > probabilities.home &&
|
probabilities.away > probabilities.home &&
|
||||||
probabilities.away > probabilities.draw
|
probabilities.away > probabilities.draw
|
||||||
) {
|
) {
|
||||||
pick = '2';
|
pick = "2";
|
||||||
} else if (probabilities.draw > probabilities.home) {
|
} else if (probabilities.draw > probabilities.home) {
|
||||||
pick = 'X';
|
pick = "X";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const confidence = Math.round(
|
const confidence = Math.round(
|
||||||
(msPick?.calibrated_confidence ?? msPick?.confidence ?? 50) *
|
(msPick?.calibrated_confidence ?? msPick?.confidence ?? 50) *
|
||||||
(typeof (msPick?.calibrated_confidence ?? msPick?.confidence) ===
|
(typeof (msPick?.calibrated_confidence ?? msPick?.confidence) ===
|
||||||
'number' &&
|
"number" &&
|
||||||
(msPick?.calibrated_confidence ?? msPick?.confidence ?? 0) <= 1
|
(msPick?.calibrated_confidence ?? msPick?.confidence ?? 0) <= 1
|
||||||
? 100
|
? 100
|
||||||
: 1),
|
: 1),
|
||||||
@@ -542,7 +542,7 @@ export class TotoPredictionService {
|
|||||||
pick,
|
pick,
|
||||||
reasoning:
|
reasoning:
|
||||||
reasons.length > 0
|
reasons.length > 0
|
||||||
? reasons.join(' | ')
|
? reasons.join(" | ")
|
||||||
: `AI Engine: ${pick} (confidence: ${confidence}%)`,
|
: `AI Engine: ${pick} (confidence: ${confidence}%)`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -596,21 +596,21 @@ export class TotoPredictionService {
|
|||||||
return {
|
return {
|
||||||
probabilities: { home: 0.33, draw: 0.33, away: 0.34 },
|
probabilities: { home: 0.33, draw: 0.33, away: 0.34 },
|
||||||
confidence: 33,
|
confidence: 33,
|
||||||
pick: '1',
|
pick: "1",
|
||||||
reasoning: 'Tarihsel veri bulunamadı, eşit dağılım',
|
reasoning: "Tarihsel veri bulunamadı, eşit dağılım",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ev sahibi form analizi
|
// Ev sahibi form analizi
|
||||||
const homeWins = homeMatches.filter((m) => m.winner === 'home').length;
|
const homeWins = homeMatches.filter((m) => m.winner === "home").length;
|
||||||
const homeDraws = homeMatches.filter((m) => m.winner === 'draw').length;
|
const homeDraws = homeMatches.filter((m) => m.winner === "draw").length;
|
||||||
const homeLosses = homeMatches.filter((m) => m.winner === 'away').length;
|
const homeLosses = homeMatches.filter((m) => m.winner === "away").length;
|
||||||
const homeTotal = homeMatches.length || 1;
|
const homeTotal = homeMatches.length || 1;
|
||||||
|
|
||||||
// Deplasman form analizi
|
// Deplasman form analizi
|
||||||
const awayWins = awayMatches.filter((m) => m.winner === 'away').length;
|
const awayWins = awayMatches.filter((m) => m.winner === "away").length;
|
||||||
const awayDraws = awayMatches.filter((m) => m.winner === 'draw').length;
|
const awayDraws = awayMatches.filter((m) => m.winner === "draw").length;
|
||||||
const awayLosses = awayMatches.filter((m) => m.winner === 'home').length;
|
const awayLosses = awayMatches.filter((m) => m.winner === "home").length;
|
||||||
const awayTotal = awayMatches.length || 1;
|
const awayTotal = awayMatches.length || 1;
|
||||||
|
|
||||||
// Basit form bazlı olasılık
|
// Basit form bazlı olasılık
|
||||||
@@ -630,14 +630,14 @@ export class TotoPredictionService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// En yüksek olasılık
|
// En yüksek olasılık
|
||||||
let pick: TotoSelection = '1';
|
let pick: TotoSelection = "1";
|
||||||
if (
|
if (
|
||||||
probabilities.away > probabilities.home &&
|
probabilities.away > probabilities.home &&
|
||||||
probabilities.away > probabilities.draw
|
probabilities.away > probabilities.draw
|
||||||
) {
|
) {
|
||||||
pick = '2';
|
pick = "2";
|
||||||
} else if (probabilities.draw > probabilities.home) {
|
} else if (probabilities.draw > probabilities.home) {
|
||||||
pick = 'X';
|
pick = "X";
|
||||||
}
|
}
|
||||||
|
|
||||||
const confidence = Math.round(
|
const confidence = Math.round(
|
||||||
@@ -656,8 +656,8 @@ export class TotoPredictionService {
|
|||||||
return {
|
return {
|
||||||
probabilities: { home: 0.33, draw: 0.33, away: 0.34 },
|
probabilities: { home: 0.33, draw: 0.33, away: 0.34 },
|
||||||
confidence: 33,
|
confidence: 33,
|
||||||
pick: '1',
|
pick: "1",
|
||||||
reasoning: 'Form analizi yapılamadı, eşit dağılım',
|
reasoning: "Form analizi yapılamadı, eşit dağılım",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -685,9 +685,9 @@ export class TotoPredictionService {
|
|||||||
} {
|
} {
|
||||||
// Olasılıkları sırala
|
// Olasılıkları sırala
|
||||||
const probs: Array<{ pick: TotoSelection; prob: number }> = [
|
const probs: Array<{ pick: TotoSelection; prob: number }> = [
|
||||||
{ pick: '1' as TotoSelection, prob: probabilities.home },
|
{ pick: "1" as TotoSelection, prob: probabilities.home },
|
||||||
{ pick: 'X' as TotoSelection, prob: probabilities.draw },
|
{ pick: "X" as TotoSelection, prob: probabilities.draw },
|
||||||
{ pick: '2' as TotoSelection, prob: probabilities.away },
|
{ pick: "2" as TotoSelection, prob: probabilities.away },
|
||||||
].sort((a, b) => b.prob - a.prob);
|
].sort((a, b) => b.prob - a.prob);
|
||||||
|
|
||||||
const topProb = probs[0].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ı`;
|
contrarianReasoning = `İkili: ${probs[0].pick} + ${probs[1].pick} — Orta güven, varyans koruması`;
|
||||||
} else {
|
} else {
|
||||||
// Düşük güven → üçlü kapatma
|
// 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`;
|
contrarianReasoning = `Kapatma: 1X2 — Düşük güven (${confidence}%), maç çok belirsiz`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,7 +741,7 @@ export class TotoPredictionService {
|
|||||||
rolloverAmount: number,
|
rolloverAmount: number,
|
||||||
columnCount: number,
|
columnCount: number,
|
||||||
totalCost: number,
|
totalCost: number,
|
||||||
): Promise<PredictionResult['evReport']> {
|
): Promise<PredictionResult["evReport"]> {
|
||||||
const effectivePool = poolTotal + rolloverAmount;
|
const effectivePool = poolTotal + rolloverAmount;
|
||||||
const distribution =
|
const distribution =
|
||||||
this.analytics.calculatePoolDistribution(effectivePool);
|
this.analytics.calculatePoolDistribution(effectivePool);
|
||||||
@@ -765,20 +765,20 @@ export class TotoPredictionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Karar
|
// Karar
|
||||||
let recommendation: PredictionResult['evReport']['recommendation'];
|
let recommendation: PredictionResult["evReport"]["recommendation"];
|
||||||
let recommendationReason: string;
|
let recommendationReason: string;
|
||||||
|
|
||||||
if (rolloverAmount > 50_000_000) {
|
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.`;
|
recommendationReason = `🔥 ${(rolloverAmount / 1_000_000).toFixed(1)}M TL devir! Tarihi fırsat. Agresif oyna.`;
|
||||||
} else if (rolloverAmount > 20_000_000) {
|
} 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)`;
|
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) {
|
} else if (rolloverAmount > 5_000_000) {
|
||||||
recommendation = 'PLAY';
|
recommendation = "PLAY";
|
||||||
recommendationReason = `✅ Orta düzey devir: ${(rolloverAmount / 1_000_000).toFixed(1)}M TL`;
|
recommendationReason = `✅ Orta düzey devir: ${(rolloverAmount / 1_000_000).toFixed(1)}M TL`;
|
||||||
} else {
|
} else {
|
||||||
recommendation = 'WAIT';
|
recommendation = "WAIT";
|
||||||
recommendationReason = `⏳ Devir düşük (${(rolloverAmount / 1_000_000).toFixed(1)}M TL). Havuz büyümesini bekle.`;
|
recommendationReason = `⏳ Devir düşük (${(rolloverAmount / 1_000_000).toFixed(1)}M TL). Havuz büyümesini bekle.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
@@ -19,21 +19,21 @@ import {
|
|||||||
ApiParam,
|
ApiParam,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
} from '@nestjs/swagger';
|
} from "@nestjs/swagger";
|
||||||
import { SporTotoService } from './spor-toto.service';
|
import { SporTotoService } from "./spor-toto.service";
|
||||||
import {
|
import {
|
||||||
CreateBulletinDto,
|
CreateBulletinDto,
|
||||||
UpdateResultsDto,
|
UpdateResultsDto,
|
||||||
GenerateColumnsDto,
|
GenerateColumnsDto,
|
||||||
GenerateSporTotoPredictionDto,
|
GenerateSporTotoPredictionDto,
|
||||||
EvaluateColumnsDto,
|
EvaluateColumnsDto,
|
||||||
} from './dto/spor-toto.dto';
|
} from "./dto/spor-toto.dto";
|
||||||
import { Public, Roles } from '../../common/decorators';
|
import { Public, Roles } from "../../common/decorators";
|
||||||
import { JwtAuthGuard } from '../auth/guards/auth.guards';
|
import { JwtAuthGuard } from "../auth/guards/auth.guards";
|
||||||
import { TotoBulletinStatus } from '@prisma/client';
|
import { TotoBulletinStatus } from "@prisma/client";
|
||||||
|
|
||||||
@ApiTags('Spor Toto')
|
@ApiTags("Spor Toto")
|
||||||
@Controller('spor-toto')
|
@Controller("spor-toto")
|
||||||
export class SporTotoController {
|
export class SporTotoController {
|
||||||
private readonly logger = new Logger(SporTotoController.name);
|
private readonly logger = new Logger(SporTotoController.name);
|
||||||
|
|
||||||
@@ -41,51 +41,51 @@ export class SporTotoController {
|
|||||||
|
|
||||||
// ═══════════ BULLETINS ═══════════
|
// ═══════════ BULLETINS ═══════════
|
||||||
|
|
||||||
@Post('sync')
|
@Post("sync")
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Roles('admin')
|
@Roles("admin")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Sync current bulletin from Spor Toto API',
|
summary: "Sync current bulletin from Spor Toto API",
|
||||||
description:
|
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({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Sync result with action (created/updated/unchanged)',
|
description: "Sync result with action (created/updated/unchanged)",
|
||||||
})
|
})
|
||||||
async syncFromApi() {
|
async syncFromApi() {
|
||||||
const result = await this.sporTotoService.syncFromApi();
|
const result = await this.sporTotoService.syncFromApi();
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('bulletins')
|
@Get("bulletins")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'List Spor Toto bulletins',
|
summary: "List Spor Toto bulletins",
|
||||||
description:
|
description:
|
||||||
'Returns a paginated list of bulletins, optionally filtered by status.',
|
"Returns a paginated list of bulletins, optionally filtered by status.",
|
||||||
})
|
})
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: 'status',
|
name: "status",
|
||||||
required: false,
|
required: false,
|
||||||
enum: TotoBulletinStatus,
|
enum: TotoBulletinStatus,
|
||||||
description: 'Filter by bulletin status',
|
description: "Filter by bulletin status",
|
||||||
})
|
})
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: 'limit',
|
name: "limit",
|
||||||
required: false,
|
required: false,
|
||||||
type: Number,
|
type: Number,
|
||||||
description: 'Max results (default: 10)',
|
description: "Max results (default: 10)",
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Array of bulletins with matches and results',
|
description: "Array of bulletins with matches and results",
|
||||||
})
|
})
|
||||||
async listBulletins(
|
async listBulletins(
|
||||||
@Query('status') status?: TotoBulletinStatus,
|
@Query("status") status?: TotoBulletinStatus,
|
||||||
@Query('limit') limit?: string,
|
@Query("limit") limit?: string,
|
||||||
) {
|
) {
|
||||||
const bulletins = await this.sporTotoService.listBulletins(
|
const bulletins = await this.sporTotoService.listBulletins(
|
||||||
status,
|
status,
|
||||||
@@ -94,95 +94,95 @@ export class SporTotoController {
|
|||||||
return { success: true, data: bulletins };
|
return { success: true, data: bulletins };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('bulletins/:id')
|
@Get("bulletins/:id")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Get bulletin details',
|
summary: "Get bulletin details",
|
||||||
description:
|
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({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Bulletin with matches and results',
|
description: "Bulletin with matches and results",
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 404, description: 'Bulletin not found' })
|
@ApiResponse({ status: 404, description: "Bulletin not found" })
|
||||||
async getBulletin(@Param('id') id: string) {
|
async getBulletin(@Param("id") id: string) {
|
||||||
const bulletin = await this.sporTotoService.getBulletinById(id);
|
const bulletin = await this.sporTotoService.getBulletinById(id);
|
||||||
return { success: true, data: bulletin };
|
return { success: true, data: bulletin };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulletins')
|
@Post("bulletins")
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Roles('admin')
|
@Roles("admin")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Create a bulletin manually',
|
summary: "Create a bulletin manually",
|
||||||
description:
|
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 })
|
@ApiBody({ type: CreateBulletinDto })
|
||||||
@ApiResponse({ status: 201, description: 'Created bulletin with matches' })
|
@ApiResponse({ status: 201, description: "Created bulletin with matches" })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 409,
|
status: 409,
|
||||||
description: 'Bulletin with this gameCycleNo already exists',
|
description: "Bulletin with this gameCycleNo already exists",
|
||||||
})
|
})
|
||||||
async createBulletin(@Body() dto: CreateBulletinDto) {
|
async createBulletin(@Body() dto: CreateBulletinDto) {
|
||||||
const bulletin = await this.sporTotoService.createBulletin(dto);
|
const bulletin = await this.sporTotoService.createBulletin(dto);
|
||||||
return { success: true, data: bulletin };
|
return { success: true, data: bulletin };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('bulletins/:id/results')
|
@Patch("bulletins/:id/results")
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Roles('admin')
|
@Roles("admin")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Update bulletin match results',
|
summary: "Update bulletin match results",
|
||||||
description:
|
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 })
|
@ApiBody({ type: UpdateResultsDto })
|
||||||
@ApiResponse({ status: 200, description: 'Updated bulletin with results' })
|
@ApiResponse({ status: 200, description: "Updated bulletin with results" })
|
||||||
@ApiResponse({ status: 404, description: 'Bulletin not found' })
|
@ApiResponse({ status: 404, description: "Bulletin not found" })
|
||||||
async updateResults(@Param('id') id: string, @Body() dto: UpdateResultsDto) {
|
async updateResults(@Param("id") id: string, @Body() dto: UpdateResultsDto) {
|
||||||
const bulletin = await this.sporTotoService.updateResults(id, dto);
|
const bulletin = await this.sporTotoService.updateResults(id, dto);
|
||||||
return { success: true, data: bulletin };
|
return { success: true, data: bulletin };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════ STATS & ANALYTICS ═══════════
|
// ═══════════ STATS & ANALYTICS ═══════════
|
||||||
|
|
||||||
@Get('bulletins/:id/stats')
|
@Get("bulletins/:id/stats")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Get bulletin pool & EV statistics',
|
summary: "Get bulletin pool & EV statistics",
|
||||||
description:
|
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' })
|
@ApiParam({ name: "id", description: "Bulletin UUID" })
|
||||||
@ApiResponse({ status: 200, description: 'Pool distribution and EV stats' })
|
@ApiResponse({ status: 200, description: "Pool distribution and EV stats" })
|
||||||
async getBulletinStats(@Param('id') id: string) {
|
async getBulletinStats(@Param("id") id: string) {
|
||||||
const stats = await this.sporTotoService.getBulletinStats(id);
|
const stats = await this.sporTotoService.getBulletinStats(id);
|
||||||
return { success: true, data: stats };
|
return { success: true, data: stats };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('history')
|
@Get("history")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Get rollover history and trends',
|
summary: "Get rollover history and trends",
|
||||||
description:
|
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({
|
@ApiQuery({
|
||||||
name: 'limit',
|
name: "limit",
|
||||||
required: false,
|
required: false,
|
||||||
type: Number,
|
type: Number,
|
||||||
description: 'Number of results (default: 20)',
|
description: "Number of results (default: 20)",
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: 'Rollover history with trend data' })
|
@ApiResponse({ status: 200, description: "Rollover history with trend data" })
|
||||||
async getRolloverHistory(@Query('limit') limit?: string) {
|
async getRolloverHistory(@Query("limit") limit?: string) {
|
||||||
const history = await this.sporTotoService.getRolloverHistory(
|
const history = await this.sporTotoService.getRolloverHistory(
|
||||||
Number(limit) || 20,
|
Number(limit) || 20,
|
||||||
);
|
);
|
||||||
@@ -191,38 +191,38 @@ export class SporTotoController {
|
|||||||
|
|
||||||
// ═══════════ COLUMNS ═══════════
|
// ═══════════ COLUMNS ═══════════
|
||||||
|
|
||||||
@Post('columns/generate')
|
@Post("columns/generate")
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Generate Spor Toto columns (full or reduced system)',
|
summary: "Generate Spor Toto columns (full or reduced system)",
|
||||||
description:
|
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 })
|
@ApiBody({ type: GenerateColumnsDto })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
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) {
|
async generateColumns(@Body() dto: GenerateColumnsDto) {
|
||||||
const result = await this.sporTotoService.generateColumns(dto);
|
const result = await this.sporTotoService.generateColumns(dto);
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('columns/evaluate')
|
@Post("columns/evaluate")
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Evaluate columns against results',
|
summary: "Evaluate columns against results",
|
||||||
description:
|
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 })
|
@ApiBody({ type: EvaluateColumnsDto })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Evaluation results with correct counts per column',
|
description: "Evaluation results with correct counts per column",
|
||||||
})
|
})
|
||||||
async evaluateColumns(@Body() dto: EvaluateColumnsDto) {
|
async evaluateColumns(@Body() dto: EvaluateColumnsDto) {
|
||||||
const result = await this.sporTotoService.evaluateColumns(
|
const result = await this.sporTotoService.evaluateColumns(
|
||||||
@@ -234,24 +234,24 @@ export class SporTotoController {
|
|||||||
|
|
||||||
// ═══════════ AI PREDICTION ═══════════
|
// ═══════════ AI PREDICTION ═══════════
|
||||||
|
|
||||||
@Post('predict')
|
@Post("predict")
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Generate AI predictions with contrarian strategy',
|
summary: "Generate AI predictions with contrarian strategy",
|
||||||
description:
|
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 })
|
@ApiBody({ type: GenerateSporTotoPredictionDto })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description:
|
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) {
|
async generatePrediction(@Body() dto: GenerateSporTotoPredictionDto) {
|
||||||
this.logger.log(
|
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);
|
const result = await this.sporTotoService.generatePrediction(dto);
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { SporTotoController } from './spor-toto.controller';
|
import { SporTotoController } from "./spor-toto.controller";
|
||||||
import { SporTotoService } from './spor-toto.service';
|
import { SporTotoService } from "./spor-toto.service";
|
||||||
import { TotoFetcherService } from './services/toto-fetcher.service';
|
import { TotoFetcherService } from "./services/toto-fetcher.service";
|
||||||
import { TotoCombinatoricsService } from './services/toto-combinatorics.service';
|
import { TotoCombinatoricsService } from "./services/toto-combinatorics.service";
|
||||||
import { TotoAnalyticsService } from './services/toto-analytics.service';
|
import { TotoAnalyticsService } from "./services/toto-analytics.service";
|
||||||
import { TotoPredictionService } from './services/toto-prediction.service';
|
import { TotoPredictionService } from "./services/toto-prediction.service";
|
||||||
import { DatabaseModule } from '../../database/database.module';
|
import { DatabaseModule } from "../../database/database.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, HttpModule, ConfigModule],
|
imports: [DatabaseModule, HttpModule, ConfigModule],
|
||||||
|
|||||||
@@ -3,25 +3,25 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import { TotoFetcherService } from './services/toto-fetcher.service';
|
import { TotoFetcherService } from "./services/toto-fetcher.service";
|
||||||
import {
|
import {
|
||||||
TotoCombinatoricsService,
|
TotoCombinatoricsService,
|
||||||
TotoMatchSelectionInput,
|
TotoMatchSelectionInput,
|
||||||
} from './services/toto-combinatorics.service';
|
} from "./services/toto-combinatorics.service";
|
||||||
import { TotoAnalyticsService } from './services/toto-analytics.service';
|
import { TotoAnalyticsService } from "./services/toto-analytics.service";
|
||||||
import {
|
import {
|
||||||
TotoPredictionService,
|
TotoPredictionService,
|
||||||
PredictionStrategy,
|
PredictionStrategy,
|
||||||
} from './services/toto-prediction.service';
|
} from "./services/toto-prediction.service";
|
||||||
import {
|
import {
|
||||||
CreateBulletinDto,
|
CreateBulletinDto,
|
||||||
UpdateResultsDto,
|
UpdateResultsDto,
|
||||||
GenerateColumnsDto,
|
GenerateColumnsDto,
|
||||||
GenerateSporTotoPredictionDto,
|
GenerateSporTotoPredictionDto,
|
||||||
} from './dto/spor-toto.dto';
|
} from "./dto/spor-toto.dto";
|
||||||
import { TotoBulletinStatus, TotoMatchResult, Prisma } from '@prisma/client';
|
import { TotoBulletinStatus, TotoMatchResult, Prisma } from "@prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SporTotoService {
|
export class SporTotoService {
|
||||||
@@ -41,13 +41,13 @@ export class SporTotoService {
|
|||||||
* Fetch and sync current bulletin from Spor Toto API
|
* Fetch and sync current bulletin from Spor Toto API
|
||||||
*/
|
*/
|
||||||
async syncFromApi(): Promise<{
|
async syncFromApi(): Promise<{
|
||||||
action: 'created' | 'updated' | 'unchanged';
|
action: "created" | "updated" | "unchanged";
|
||||||
gameCycleNo: number;
|
gameCycleNo: number;
|
||||||
matchCount: number;
|
matchCount: number;
|
||||||
}> {
|
}> {
|
||||||
const apiResponse = await this.fetcher.fetchCurrentBulletin();
|
const apiResponse = await this.fetcher.fetchCurrentBulletin();
|
||||||
if (!apiResponse?.data) {
|
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;
|
const apiData = apiResponse.data;
|
||||||
@@ -84,7 +84,7 @@ export class SporTotoService {
|
|||||||
|
|
||||||
// Check if all matches have results → mark COMPLETED
|
// Check if all matches have results → mark COMPLETED
|
||||||
const allHaveResults = apiData.events.every((e) => e.winner !== null);
|
const allHaveResults = apiData.events.every((e) => e.winner !== null);
|
||||||
if (allHaveResults && existing.status !== 'COMPLETED') {
|
if (allHaveResults && existing.status !== "COMPLETED") {
|
||||||
await this.prisma.totoBulletin.update({
|
await this.prisma.totoBulletin.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { status: TotoBulletinStatus.COMPLETED },
|
data: { status: TotoBulletinStatus.COMPLETED },
|
||||||
@@ -93,7 +93,7 @@ export class SporTotoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: hasChanges ? 'updated' : 'unchanged',
|
action: hasChanges ? "updated" : "unchanged",
|
||||||
gameCycleNo: apiData.gameCycleNo,
|
gameCycleNo: apiData.gameCycleNo,
|
||||||
matchCount: apiData.events.length,
|
matchCount: apiData.events.length,
|
||||||
};
|
};
|
||||||
@@ -132,7 +132,7 @@ export class SporTotoService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: 'created',
|
action: "created",
|
||||||
gameCycleNo: apiData.gameCycleNo,
|
gameCycleNo: apiData.gameCycleNo,
|
||||||
matchCount: matchData.length,
|
matchCount: matchData.length,
|
||||||
};
|
};
|
||||||
@@ -187,10 +187,10 @@ export class SporTotoService {
|
|||||||
|
|
||||||
return this.prisma.totoBulletin.findMany({
|
return this.prisma.totoBulletin.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { gameCycleNo: 'desc' },
|
orderBy: { gameCycleNo: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: {
|
include: {
|
||||||
matches: { orderBy: { matchOrder: 'asc' } },
|
matches: { orderBy: { matchOrder: "asc" } },
|
||||||
result: true,
|
result: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -203,13 +203,13 @@ export class SporTotoService {
|
|||||||
const bulletin = await this.prisma.totoBulletin.findUnique({
|
const bulletin = await this.prisma.totoBulletin.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
matches: { orderBy: { matchOrder: 'asc' } },
|
matches: { orderBy: { matchOrder: "asc" } },
|
||||||
result: true,
|
result: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!bulletin) {
|
if (!bulletin) {
|
||||||
throw new NotFoundException('Bulletin not found');
|
throw new NotFoundException("Bulletin not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return bulletin;
|
return bulletin;
|
||||||
@@ -227,7 +227,7 @@ export class SporTotoService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!bulletin) {
|
if (!bulletin) {
|
||||||
throw new NotFoundException('Bulletin not found');
|
throw new NotFoundException("Bulletin not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update individual match results
|
// Update individual match results
|
||||||
@@ -347,10 +347,10 @@ export class SporTotoService {
|
|||||||
this.combinatorics.calculateColumnCount(matchSelections);
|
this.combinatorics.calculateColumnCount(matchSelections);
|
||||||
|
|
||||||
let columns;
|
let columns;
|
||||||
const strategy = dto.strategy || 'FULL_SYSTEM';
|
const strategy = dto.strategy || "FULL_SYSTEM";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
strategy === 'REDUCED_SYSTEM' &&
|
strategy === "REDUCED_SYSTEM" &&
|
||||||
dto.maxColumns &&
|
dto.maxColumns &&
|
||||||
totalColumnCount > dto.maxColumns
|
totalColumnCount > dto.maxColumns
|
||||||
) {
|
) {
|
||||||
@@ -381,33 +381,33 @@ export class SporTotoService {
|
|||||||
async evaluateColumns(bulletinId: string, columnPredictions: string[]) {
|
async evaluateColumns(bulletinId: string, columnPredictions: string[]) {
|
||||||
const bulletin = await this.prisma.totoBulletin.findUnique({
|
const bulletin = await this.prisma.totoBulletin.findUnique({
|
||||||
where: { id: bulletinId },
|
where: { id: bulletinId },
|
||||||
include: { matches: { orderBy: { matchOrder: 'asc' } } },
|
include: { matches: { orderBy: { matchOrder: "asc" } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!bulletin) {
|
if (!bulletin) {
|
||||||
throw new NotFoundException('Bulletin not found');
|
throw new NotFoundException("Bulletin not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build results string (15 chars)
|
// Build results string (15 chars)
|
||||||
const resultMap: Record<string, string> = {
|
const resultMap: Record<string, string> = {
|
||||||
HOME: '1',
|
HOME: "1",
|
||||||
DRAW: 'X',
|
DRAW: "X",
|
||||||
AWAY: '2',
|
AWAY: "2",
|
||||||
};
|
};
|
||||||
|
|
||||||
const resultsString = bulletin.matches
|
const resultsString = bulletin.matches
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
if (m.isCancelled && m.drawResult) {
|
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 {
|
return {
|
||||||
complete: false,
|
complete: false,
|
||||||
message: 'Bazı maçların sonuçları henüz girilmedi',
|
message: "Bazı maçların sonuçları henüz girilmedi",
|
||||||
resultsString,
|
resultsString,
|
||||||
evaluations: [],
|
evaluations: [],
|
||||||
};
|
};
|
||||||
@@ -452,7 +452,7 @@ export class SporTotoService {
|
|||||||
* AI Engine ile akıllı sistem kuponu üret
|
* AI Engine ile akıllı sistem kuponu üret
|
||||||
*/
|
*/
|
||||||
async generatePrediction(dto: GenerateSporTotoPredictionDto) {
|
async generatePrediction(dto: GenerateSporTotoPredictionDto) {
|
||||||
const strategy: PredictionStrategy = dto.strategy || 'BALANCED';
|
const strategy: PredictionStrategy = dto.strategy || "BALANCED";
|
||||||
return this.prediction.generatePrediction(
|
return this.prediction.generatePrediction(
|
||||||
dto.bulletinId,
|
dto.bulletinId,
|
||||||
strategy,
|
strategy,
|
||||||
|
|||||||
@@ -4,25 +4,25 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from "class-validator";
|
||||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
|
||||||
|
|
||||||
export class CreateUserDto {
|
export class CreateUserDto {
|
||||||
@ApiPropertyOptional({ example: 'user@example.com' })
|
@ApiPropertyOptional({ example: "user@example.com" })
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'password123', minLength: 8 })
|
@ApiPropertyOptional({ example: "password123", minLength: 8 })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8)
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'John' })
|
@ApiPropertyOptional({ example: "John" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Doe' })
|
@ApiPropertyOptional({ example: "Doe" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
@@ -34,12 +34,12 @@ export class CreateUserDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateUserDto extends PartialType(CreateUserDto) {
|
export class UpdateUserDto extends PartialType(CreateUserDto) {
|
||||||
@ApiPropertyOptional({ example: 'John' })
|
@ApiPropertyOptional({ example: "John" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Doe' })
|
@ApiPropertyOptional({ example: "Doe" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
@@ -51,29 +51,29 @@ export class UpdateUserDto extends PartialType(CreateUserDto) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateProfileDto {
|
export class UpdateProfileDto {
|
||||||
@ApiPropertyOptional({ example: 'John' })
|
@ApiPropertyOptional({ example: "John" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Doe' })
|
@ApiPropertyOptional({ example: "Doe" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChangePasswordDto {
|
export class ChangePasswordDto {
|
||||||
@ApiProperty({ example: 'oldPassword123' })
|
@ApiProperty({ example: "oldPassword123" })
|
||||||
@IsString()
|
@IsString()
|
||||||
currentPassword: string;
|
currentPassword: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'newPassword456', minLength: 8 })
|
@ApiProperty({ example: "newPassword456", minLength: 8 })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8)
|
||||||
newPassword: string;
|
newPassword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { Exclude, Expose } from 'class-transformer';
|
import { Exclude, Expose } from "class-transformer";
|
||||||
|
|
||||||
@Exclude()
|
@Exclude()
|
||||||
export class UserResponseDto {
|
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 {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
} from '@nestjs/swagger';
|
} from "@nestjs/swagger";
|
||||||
import { BaseController } from '../../common/base';
|
import { BaseController } from "../../common/base";
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from "./users.service";
|
||||||
import {
|
import {
|
||||||
CreateUserDto,
|
CreateUserDto,
|
||||||
UpdateUserDto,
|
UpdateUserDto,
|
||||||
UpdateProfileDto,
|
UpdateProfileDto,
|
||||||
ChangePasswordDto,
|
ChangePasswordDto,
|
||||||
} from './dto/user.dto';
|
} from "./dto/user.dto";
|
||||||
import { CurrentUser, Roles } from '../../common/decorators';
|
import { CurrentUser, Roles } from "../../common/decorators";
|
||||||
import {
|
import {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
createSuccessResponse,
|
createSuccessResponse,
|
||||||
} from '../../common/types/api-response.type';
|
} from "../../common/types/api-response.type";
|
||||||
import { User } from '@prisma/client';
|
import { User } from "@prisma/client";
|
||||||
|
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from "class-transformer";
|
||||||
import { UserResponseDto } from './dto/user.dto';
|
import { UserResponseDto } from "./dto/user.dto";
|
||||||
|
|
||||||
interface AuthenticatedUser {
|
interface AuthenticatedUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,20 +29,20 @@ interface AuthenticatedUser {
|
|||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags('Users')
|
@ApiTags("Users")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('users')
|
@Controller("users")
|
||||||
export class UsersController extends BaseController<
|
export class UsersController extends BaseController<
|
||||||
User,
|
User,
|
||||||
CreateUserDto,
|
CreateUserDto,
|
||||||
UpdateUserDto
|
UpdateUserDto
|
||||||
> {
|
> {
|
||||||
constructor(private readonly usersService: UsersService) {
|
constructor(private readonly usersService: UsersService) {
|
||||||
super(usersService, 'User');
|
super(usersService, "User");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('me')
|
@Get("me")
|
||||||
@ApiOperation({ summary: 'Get current authenticated user profile' })
|
@ApiOperation({ summary: "Get current authenticated user profile" })
|
||||||
@ApiOkResponse({ type: UserResponseDto })
|
@ApiOkResponse({ type: UserResponseDto })
|
||||||
async getMe(
|
async getMe(
|
||||||
@CurrentUser() user: AuthenticatedUser,
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
@@ -50,12 +50,12 @@ export class UsersController extends BaseController<
|
|||||||
const fullUser = await this.usersService.findOneWithDetails(user.id);
|
const fullUser = await this.usersService.findOneWithDetails(user.id);
|
||||||
return createSuccessResponse(
|
return createSuccessResponse(
|
||||||
plainToInstance(UserResponseDto, fullUser),
|
plainToInstance(UserResponseDto, fullUser),
|
||||||
'User profile retrieved successfully',
|
"User profile retrieved successfully",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('me')
|
@Put("me")
|
||||||
@ApiOperation({ summary: 'Update current user profile' })
|
@ApiOperation({ summary: "Update current user profile" })
|
||||||
@ApiOkResponse({ type: UserResponseDto })
|
@ApiOkResponse({ type: UserResponseDto })
|
||||||
async updateMe(
|
async updateMe(
|
||||||
@CurrentUser() user: AuthenticatedUser,
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
@@ -64,13 +64,13 @@ export class UsersController extends BaseController<
|
|||||||
const updatedUser = await this.usersService.updateProfile(user.id, dto);
|
const updatedUser = await this.usersService.updateProfile(user.id, dto);
|
||||||
return createSuccessResponse(
|
return createSuccessResponse(
|
||||||
plainToInstance(UserResponseDto, updatedUser),
|
plainToInstance(UserResponseDto, updatedUser),
|
||||||
'User profile updated successfully',
|
"User profile updated successfully",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('me/password')
|
@Patch("me/password")
|
||||||
@ApiOperation({ summary: 'Change current user password' })
|
@ApiOperation({ summary: "Change current user password" })
|
||||||
@ApiOkResponse({ description: 'Password changed successfully' })
|
@ApiOkResponse({ description: "Password changed successfully" })
|
||||||
async changePassword(
|
async changePassword(
|
||||||
@CurrentUser() user: AuthenticatedUser,
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
@Body() dto: ChangePasswordDto,
|
@Body() dto: ChangePasswordDto,
|
||||||
@@ -80,24 +80,24 @@ export class UsersController extends BaseController<
|
|||||||
dto.currentPassword,
|
dto.currentPassword,
|
||||||
dto.newPassword,
|
dto.newPassword,
|
||||||
);
|
);
|
||||||
return createSuccessResponse(null, 'Password changed successfully');
|
return createSuccessResponse(null, "Password changed successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override create to require admin role
|
// Override create to require admin role
|
||||||
@Roles('admin')
|
@Roles("admin")
|
||||||
async create(
|
async create(
|
||||||
...args: Parameters<
|
...args: Parameters<
|
||||||
BaseController<User, CreateUserDto, UpdateUserDto>['create']
|
BaseController<User, CreateUserDto, UpdateUserDto>["create"]
|
||||||
>
|
>
|
||||||
) {
|
) {
|
||||||
return super.create(...args);
|
return super.create(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override delete to require admin role
|
// Override delete to require admin role
|
||||||
@Roles('admin')
|
@Roles("admin")
|
||||||
async delete(
|
async delete(
|
||||||
...args: Parameters<
|
...args: Parameters<
|
||||||
BaseController<User, CreateUserDto, UpdateUserDto>['delete']
|
BaseController<User, CreateUserDto, UpdateUserDto>["delete"]
|
||||||
>
|
>
|
||||||
) {
|
) {
|
||||||
return super.delete(...args);
|
return super.delete(...args);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { UsersController } from './users.controller';
|
import { UsersController } from "./users.controller";
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from "./users.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [UsersController],
|
controllers: [UsersController],
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from "bcrypt";
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import { BaseService } from '../../common/base';
|
import { BaseService } from "../../common/base";
|
||||||
import { CreateUserDto, UpdateUserDto, UpdateProfileDto } from './dto/user.dto';
|
import { CreateUserDto, UpdateUserDto, UpdateProfileDto } from "./dto/user.dto";
|
||||||
import { User, UserRole } from '@prisma/client';
|
import { User, UserRole } from "@prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService extends BaseService<
|
export class UsersService extends BaseService<
|
||||||
@@ -16,7 +16,7 @@ export class UsersService extends BaseService<
|
|||||||
UpdateUserDto
|
UpdateUserDto
|
||||||
> {
|
> {
|
||||||
constructor(prisma: PrismaService) {
|
constructor(prisma: PrismaService) {
|
||||||
super(prisma, 'User');
|
super(prisma, "User");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +26,7 @@ export class UsersService extends BaseService<
|
|||||||
// Check if email already exists
|
// Check if email already exists
|
||||||
const existingUser = await this.findOneBy({ email: dto.email });
|
const existingUser = await this.findOneBy({ email: dto.email });
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
throw new ConflictException('EMAIL_ALREADY_EXISTS');
|
throw new ConflictException("EMAIL_ALREADY_EXISTS");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash password
|
// Hash password
|
||||||
@@ -177,7 +177,7 @@ export class UsersService extends BaseService<
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('USER_NOT_FOUND');
|
throw new UnauthorizedException("USER_NOT_FOUND");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCurrentPasswordValid = await this.comparePassword(
|
const isCurrentPasswordValid = await this.comparePassword(
|
||||||
@@ -186,7 +186,7 @@ export class UsersService extends BaseService<
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!isCurrentPasswordValid) {
|
if (!isCurrentPasswordValid) {
|
||||||
throw new UnauthorizedException('INVALID_CURRENT_PASSWORD');
|
throw new UnauthorizedException("INVALID_CURRENT_PASSWORD");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedNewPassword = await this.hashPassword(newPassword);
|
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
|
* Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/backtest-accuracy.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from "@prisma/client";
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ const prisma = new PrismaClient();
|
|||||||
// Configuration
|
// 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 CONCURRENT_REQUESTS = 5;
|
||||||
const MAX_MATCHES = 1000;
|
const MAX_MATCHES = 1000;
|
||||||
|
|
||||||
@@ -60,14 +60,14 @@ function determineActualOutcome(
|
|||||||
htScoreHome: number | null,
|
htScoreHome: number | null,
|
||||||
htScoreAway: number | null,
|
htScoreAway: number | null,
|
||||||
): { ms: string; ou25: string; btts: string; htft: string } {
|
): { ms: string; ou25: string; btts: string; htft: string } {
|
||||||
const ms = scoreHome > scoreAway ? '1' : scoreHome < scoreAway ? '2' : 'X';
|
const ms = scoreHome > scoreAway ? "1" : scoreHome < scoreAway ? "2" : "X";
|
||||||
const ou25 = scoreHome + scoreAway > 2.5 ? 'Over' : 'Under';
|
const ou25 = scoreHome + scoreAway > 2.5 ? "Over" : "Under";
|
||||||
const btts = scoreHome > 0 && scoreAway > 0 ? 'Yes' : 'No';
|
const btts = scoreHome > 0 && scoreAway > 0 ? "Yes" : "No";
|
||||||
|
|
||||||
let htft = 'unknown';
|
let htft = "unknown";
|
||||||
if (htScoreHome !== null && htScoreAway !== null) {
|
if (htScoreHome !== null && htScoreAway !== null) {
|
||||||
const htResult =
|
const htResult =
|
||||||
htScoreHome > htScoreAway ? '1' : htScoreHome < htScoreAway ? '2' : 'X';
|
htScoreHome > htScoreAway ? "1" : htScoreHome < htScoreAway ? "2" : "X";
|
||||||
htft = `${htResult}/${ms}`;
|
htft = `${htResult}/${ms}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ function extractPrediction(response: unknown): {
|
|||||||
ms: string;
|
ms: string;
|
||||||
ou25: string;
|
ou25: string;
|
||||||
btts: string;
|
btts: string;
|
||||||
probs: BacktestResult['probabilities'];
|
probs: BacktestResult["probabilities"];
|
||||||
mainPick: string;
|
mainPick: string;
|
||||||
mainMarket: string;
|
mainMarket: string;
|
||||||
} {
|
} {
|
||||||
@@ -87,9 +87,9 @@ function extractPrediction(response: unknown): {
|
|||||||
|
|
||||||
const mainPickObj = data?.main_pick as Record<string, unknown> | undefined;
|
const mainPickObj = data?.main_pick as Record<string, unknown> | undefined;
|
||||||
const mainPick =
|
const mainPick =
|
||||||
typeof mainPickObj?.pick === 'string' ? mainPickObj.pick : '';
|
typeof mainPickObj?.pick === "string" ? mainPickObj.pick : "";
|
||||||
const mainMarket =
|
const mainMarket =
|
||||||
typeof mainPickObj?.market === 'string' ? mainPickObj.market : '';
|
typeof mainPickObj?.market === "string" ? mainPickObj.market : "";
|
||||||
|
|
||||||
// Extract MS from probabilities or main pick
|
// Extract MS from probabilities or main pick
|
||||||
const msProbs = (predictions?.ms || data?.ms || {}) as Record<
|
const msProbs = (predictions?.ms || data?.ms || {}) as Record<
|
||||||
@@ -97,27 +97,27 @@ function extractPrediction(response: unknown): {
|
|||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
const homeProb =
|
const homeProb =
|
||||||
typeof msProbs['1'] === 'number'
|
typeof msProbs["1"] === "number"
|
||||||
? msProbs['1']
|
? msProbs["1"]
|
||||||
: typeof msProbs.home_prob === 'number'
|
: typeof msProbs.home_prob === "number"
|
||||||
? msProbs.home_prob
|
? msProbs.home_prob
|
||||||
: 0;
|
: 0;
|
||||||
const drawProb =
|
const drawProb =
|
||||||
typeof msProbs['X'] === 'number'
|
typeof msProbs["X"] === "number"
|
||||||
? msProbs['X']
|
? msProbs["X"]
|
||||||
: typeof msProbs.draw_prob === 'number'
|
: typeof msProbs.draw_prob === "number"
|
||||||
? msProbs.draw_prob
|
? msProbs.draw_prob
|
||||||
: 0;
|
: 0;
|
||||||
const awayProb =
|
const awayProb =
|
||||||
typeof msProbs['2'] === 'number'
|
typeof msProbs["2"] === "number"
|
||||||
? msProbs['2']
|
? msProbs["2"]
|
||||||
: typeof msProbs.away_prob === 'number'
|
: typeof msProbs.away_prob === "number"
|
||||||
? msProbs.away_prob
|
? msProbs.away_prob
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
let ms = '1';
|
let ms = "1";
|
||||||
if (drawProb > homeProb && drawProb > awayProb) ms = 'X';
|
if (drawProb > homeProb && drawProb > awayProb) ms = "X";
|
||||||
else if (awayProb > homeProb) ms = '2';
|
else if (awayProb > homeProb) ms = "2";
|
||||||
|
|
||||||
// Extract OU25
|
// Extract OU25
|
||||||
const ou25Probs = (predictions?.ou25 || data?.ou25 || {}) as Record<
|
const ou25Probs = (predictions?.ou25 || data?.ou25 || {}) as Record<
|
||||||
@@ -125,18 +125,18 @@ function extractPrediction(response: unknown): {
|
|||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
const overProb =
|
const overProb =
|
||||||
typeof ou25Probs.Over === 'number'
|
typeof ou25Probs.Over === "number"
|
||||||
? ou25Probs.Over
|
? ou25Probs.Over
|
||||||
: typeof ou25Probs.over_prob === 'number'
|
: typeof ou25Probs.over_prob === "number"
|
||||||
? ou25Probs.over_prob
|
? ou25Probs.over_prob
|
||||||
: 0;
|
: 0;
|
||||||
const underProb =
|
const underProb =
|
||||||
typeof ou25Probs.Under === 'number'
|
typeof ou25Probs.Under === "number"
|
||||||
? ou25Probs.Under
|
? ou25Probs.Under
|
||||||
: typeof ou25Probs.under_prob === 'number'
|
: typeof ou25Probs.under_prob === "number"
|
||||||
? ou25Probs.under_prob
|
? ou25Probs.under_prob
|
||||||
: 0;
|
: 0;
|
||||||
const ou25 = overProb > underProb ? 'Over' : 'Under';
|
const ou25 = overProb > underProb ? "Over" : "Under";
|
||||||
|
|
||||||
// Extract BTTS
|
// Extract BTTS
|
||||||
const bttsProbs = (predictions?.btts || data?.btts || {}) as Record<
|
const bttsProbs = (predictions?.btts || data?.btts || {}) as Record<
|
||||||
@@ -144,18 +144,18 @@ function extractPrediction(response: unknown): {
|
|||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
const bttsYes =
|
const bttsYes =
|
||||||
typeof bttsProbs.Yes === 'number'
|
typeof bttsProbs.Yes === "number"
|
||||||
? bttsProbs.Yes
|
? bttsProbs.Yes
|
||||||
: typeof bttsProbs.yes_prob === 'number'
|
: typeof bttsProbs.yes_prob === "number"
|
||||||
? bttsProbs.yes_prob
|
? bttsProbs.yes_prob
|
||||||
: 0;
|
: 0;
|
||||||
const bttsNo =
|
const bttsNo =
|
||||||
typeof bttsProbs.No === 'number'
|
typeof bttsProbs.No === "number"
|
||||||
? bttsProbs.No
|
? bttsProbs.No
|
||||||
: typeof bttsProbs.no_prob === 'number'
|
: typeof bttsProbs.no_prob === "number"
|
||||||
? bttsProbs.no_prob
|
? bttsProbs.no_prob
|
||||||
: 0;
|
: 0;
|
||||||
const btts = bttsYes > bttsNo ? 'Yes' : 'No';
|
const btts = bttsYes > bttsNo ? "Yes" : "No";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ms,
|
ms,
|
||||||
@@ -197,11 +197,11 @@ async function processBatch(batch: TestMatch[]): Promise<BacktestResult[]> {
|
|||||||
|
|
||||||
// Check main pick
|
// Check main pick
|
||||||
let mainPickCorrect = false;
|
let mainPickCorrect = false;
|
||||||
if (pred.mainMarket === 'MS') {
|
if (pred.mainMarket === "MS") {
|
||||||
mainPickCorrect = pred.mainPick === actual.ms;
|
mainPickCorrect = pred.mainPick === actual.ms;
|
||||||
} else if (pred.mainMarket === 'OU25') {
|
} else if (pred.mainMarket === "OU25") {
|
||||||
mainPickCorrect = pred.mainPick === actual.ou25;
|
mainPickCorrect = pred.mainPick === actual.ou25;
|
||||||
} else if (pred.mainMarket === 'BTTS') {
|
} else if (pred.mainMarket === "BTTS") {
|
||||||
mainPickCorrect = pred.mainPick === actual.btts;
|
mainPickCorrect = pred.mainPick === actual.btts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,8 +226,8 @@ async function processBatch(batch: TestMatch[]): Promise<BacktestResult[]> {
|
|||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
async function runBacktest(): Promise<void> {
|
async function runBacktest(): Promise<void> {
|
||||||
console.log('🎯 BACKTEST ACCURACY — V30 Betting Engine');
|
console.log("🎯 BACKTEST ACCURACY — V30 Betting Engine");
|
||||||
console.log('════════════════════════════════════════════════════════');
|
console.log("════════════════════════════════════════════════════════");
|
||||||
|
|
||||||
// 1. Health check
|
// 1. Health check
|
||||||
try {
|
try {
|
||||||
@@ -236,12 +236,12 @@ async function runBacktest(): Promise<void> {
|
|||||||
});
|
});
|
||||||
console.log(`✅ AI Engine: ${JSON.stringify(health.data)}`);
|
console.log(`✅ AI Engine: ${JSON.stringify(health.data)}`);
|
||||||
} catch {
|
} catch {
|
||||||
console.error('❌ AI Engine not reachable at', AI_ENGINE_URL);
|
console.error("❌ AI Engine not reachable at", AI_ENGINE_URL);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Load finished matches with features
|
// 2. Load finished matches with features
|
||||||
console.log('\n📥 Loading test matches...');
|
console.log("\n📥 Loading test matches...");
|
||||||
const matches = await prisma.$queryRaw<TestMatch[]>`
|
const matches = await prisma.$queryRaw<TestMatch[]>`
|
||||||
SELECT m.id, m.score_home AS "scoreHome", m.score_away AS "scoreAway",
|
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"
|
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}`);
|
console.log(` 📊 Test matches: ${matches.length}`);
|
||||||
|
|
||||||
// 3. Run predictions in batches
|
// 3. Run predictions in batches
|
||||||
console.log('\n🤖 Running predictions...');
|
console.log("\n🤖 Running predictions...");
|
||||||
const allResults: BacktestResult[] = [];
|
const allResults: BacktestResult[] = [];
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
|
|
||||||
@@ -277,7 +277,7 @@ async function runBacktest(): Promise<void> {
|
|||||||
allResults.length) *
|
allResults.length) *
|
||||||
100
|
100
|
||||||
).toFixed(1)
|
).toFixed(1)
|
||||||
: '0';
|
: "0";
|
||||||
console.log(
|
console.log(
|
||||||
` 📊 ${processed}/${matches.length} — Success: ${allResults.length} — MS Acc: ${currentMsAcc}%`,
|
` 📊 ${processed}/${matches.length} — Success: ${allResults.length} — MS Acc: ${currentMsAcc}%`,
|
||||||
);
|
);
|
||||||
@@ -287,7 +287,7 @@ async function runBacktest(): Promise<void> {
|
|||||||
// 4. Calculate metrics
|
// 4. Calculate metrics
|
||||||
const total = allResults.length;
|
const total = allResults.length;
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
console.error('❌ No results to analyze');
|
console.error("❌ No results to analyze");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,22 +303,22 @@ async function runBacktest(): Promise<void> {
|
|||||||
const mainPickCorrect = allResults.filter((r) => r.mainPickCorrect).length;
|
const mainPickCorrect = allResults.filter((r) => r.mainPickCorrect).length;
|
||||||
|
|
||||||
// Actual distribution
|
// Actual distribution
|
||||||
const actHome = allResults.filter((r) => r.actual.ms === '1').length;
|
const actHome = allResults.filter((r) => r.actual.ms === "1").length;
|
||||||
const actDraw = allResults.filter((r) => r.actual.ms === 'X').length;
|
const actDraw = allResults.filter((r) => r.actual.ms === "X").length;
|
||||||
const actAway = allResults.filter((r) => r.actual.ms === '2').length;
|
const actAway = allResults.filter((r) => r.actual.ms === "2").length;
|
||||||
|
|
||||||
// Predicted distribution
|
// Predicted distribution
|
||||||
const predHome = allResults.filter((r) => r.predicted.ms === '1').length;
|
const predHome = allResults.filter((r) => r.predicted.ms === "1").length;
|
||||||
const predDraw = allResults.filter((r) => r.predicted.ms === 'X').length;
|
const predDraw = allResults.filter((r) => r.predicted.ms === "X").length;
|
||||||
const predAway = allResults.filter((r) => r.predicted.ms === '2').length;
|
const predAway = allResults.filter((r) => r.predicted.ms === "2").length;
|
||||||
|
|
||||||
// Confidence calibration (based on max probability)
|
// Confidence calibration (based on max probability)
|
||||||
const buckets: Record<string, { correct: number; total: number }> = {
|
const buckets: Record<string, { correct: number; total: number }> = {
|
||||||
'33-40%': { correct: 0, total: 0 },
|
"33-40%": { correct: 0, total: 0 },
|
||||||
'40-50%': { correct: 0, total: 0 },
|
"40-50%": { correct: 0, total: 0 },
|
||||||
'50-60%': { correct: 0, total: 0 },
|
"50-60%": { correct: 0, total: 0 },
|
||||||
'60-70%': { correct: 0, total: 0 },
|
"60-70%": { correct: 0, total: 0 },
|
||||||
'70%+': { correct: 0, total: 0 },
|
"70%+": { correct: 0, total: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const r of allResults) {
|
for (const r of allResults) {
|
||||||
@@ -329,25 +329,25 @@ async function runBacktest(): Promise<void> {
|
|||||||
);
|
);
|
||||||
const key =
|
const key =
|
||||||
maxProb >= 0.7
|
maxProb >= 0.7
|
||||||
? '70%+'
|
? "70%+"
|
||||||
: maxProb >= 0.6
|
: maxProb >= 0.6
|
||||||
? '60-70%'
|
? "60-70%"
|
||||||
: maxProb >= 0.5
|
: maxProb >= 0.5
|
||||||
? '50-60%'
|
? "50-60%"
|
||||||
: maxProb >= 0.4
|
: maxProb >= 0.4
|
||||||
? '40-50%'
|
? "40-50%"
|
||||||
: '33-40%';
|
: "33-40%";
|
||||||
buckets[key].total++;
|
buckets[key].total++;
|
||||||
if (r.predicted.ms === r.actual.ms) buckets[key].correct++;
|
if (r.predicted.ms === r.actual.ms) buckets[key].correct++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Print Report
|
// 5. Print Report
|
||||||
console.log('\n════════════════════════════════════════════════════════');
|
console.log("\n════════════════════════════════════════════════════════");
|
||||||
console.log('📊 BACKTEST ACCURACY REPORT');
|
console.log("📊 BACKTEST ACCURACY REPORT");
|
||||||
console.log('════════════════════════════════════════════════════════');
|
console.log("════════════════════════════════════════════════════════");
|
||||||
console.log(` Total Matches Analyzed: ${total}`);
|
console.log(` Total Matches Analyzed: ${total}`);
|
||||||
console.log('');
|
console.log("");
|
||||||
console.log(' 🎯 Market Accuracy:');
|
console.log(" 🎯 Market Accuracy:");
|
||||||
console.log(
|
console.log(
|
||||||
` ⚽ Match Result (MS): ${((msCorrect / total) * 100).toFixed(2)}% (${msCorrect}/${total})`,
|
` ⚽ 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})`,
|
` 🏆 Main Pick Success: ${((mainPickCorrect / total) * 100).toFixed(2)}% (${mainPickCorrect}/${total})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('\n 📊 MS Distribution:');
|
console.log("\n 📊 MS Distribution:");
|
||||||
console.log(
|
console.log(
|
||||||
` Actual: 1: ${actHome} (${((actHome / total) * 100).toFixed(1)}%) | X: ${actDraw} (${((actDraw / total) * 100).toFixed(1)}%) | 2: ${actAway} (${((actAway / total) * 100).toFixed(1)}%)`,
|
` 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)}%)`,
|
` 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)) {
|
for (const [range, bucket] of Object.entries(buckets)) {
|
||||||
if (bucket.total === 0) continue;
|
if (bucket.total === 0) continue;
|
||||||
const acc = (bucket.correct / bucket.total) * 100;
|
const acc = (bucket.correct / bucket.total) * 100;
|
||||||
const bar = '█'.repeat(Math.round(acc / 3));
|
const bar = "█".repeat(Math.round(acc / 3));
|
||||||
console.log(
|
console.log(
|
||||||
` ${range.padEnd(8)} : ${acc.toFixed(1)}% acc (n=${bucket.total}) ${bar}`,
|
` ${range.padEnd(8)} : ${acc.toFixed(1)}% acc (n=${bucket.total}) ${bar}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Per-market deep dive
|
// 6. Per-market deep dive
|
||||||
console.log('\n 📊 OU25 Breakdown:');
|
console.log("\n 📊 OU25 Breakdown:");
|
||||||
const actOver = allResults.filter((r) => r.actual.ou25 === 'Over').length;
|
const actOver = allResults.filter((r) => r.actual.ou25 === "Over").length;
|
||||||
const actUnder = total - actOver;
|
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;
|
const predUnder = total - predOver;
|
||||||
console.log(
|
console.log(
|
||||||
` Actual: Over: ${actOver} (${((actOver / total) * 100).toFixed(1)}%) | Under: ${actUnder} (${((actUnder / total) * 100).toFixed(1)}%)`,
|
` 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)}%)`,
|
` Predicted: Over: ${predOver} (${((predOver / total) * 100).toFixed(1)}%) | Under: ${predUnder} (${((predUnder / total) * 100).toFixed(1)}%)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('\n 📊 BTTS Breakdown:');
|
console.log("\n 📊 BTTS Breakdown:");
|
||||||
const actBttsYes = allResults.filter((r) => r.actual.btts === 'Yes').length;
|
const actBttsYes = allResults.filter((r) => r.actual.btts === "Yes").length;
|
||||||
const actBttsNo = total - actBttsYes;
|
const actBttsNo = total - actBttsYes;
|
||||||
const predBttsYes = allResults.filter(
|
const predBttsYes = allResults.filter(
|
||||||
(r) => r.predicted.btts === 'Yes',
|
(r) => r.predicted.btts === "Yes",
|
||||||
).length;
|
).length;
|
||||||
const predBttsNo = total - predBttsYes;
|
const predBttsNo = total - predBttsYes;
|
||||||
console.log(
|
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)}%)`,
|
` Predicted: Yes: ${predBttsYes} (${((predBttsYes / total) * 100).toFixed(1)}%) | No: ${predBttsNo} (${((predBttsNo / total) * 100).toFixed(1)}%)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('════════════════════════════════════════════════════════');
|
console.log("════════════════════════════════════════════════════════");
|
||||||
console.log('✅ Backtest complete!');
|
console.log("✅ Backtest complete!");
|
||||||
|
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
runBacktest().catch((err: unknown) => {
|
runBacktest().catch((err: unknown) => {
|
||||||
console.error('❌ Backtest failed:', err);
|
console.error("❌ Backtest failed:", err);
|
||||||
void prisma.$disconnect();
|
void prisma.$disconnect();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,18 +9,18 @@
|
|||||||
* Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/batch-predict.ts
|
* Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/batch-predict.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from "@prisma/client";
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
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 BATCH_SIZE = 5;
|
||||||
const MAX_MATCHES_TO_PROCESS = 1000; // Limit for local testing/batch capacity
|
const MAX_MATCHES_TO_PROCESS = 1000; // Limit for local testing/batch capacity
|
||||||
|
|
||||||
async function runBatchPrediction() {
|
async function runBatchPrediction() {
|
||||||
console.log('🗓 BATCH PREDICTION PIPELINE STARTING');
|
console.log("🗓 BATCH PREDICTION PIPELINE STARTING");
|
||||||
console.log('════════════════════════════════════════════════════════');
|
console.log("════════════════════════════════════════════════════════");
|
||||||
|
|
||||||
// 1. Health check
|
// 1. Health check
|
||||||
try {
|
try {
|
||||||
@@ -30,20 +30,20 @@ async function runBatchPrediction() {
|
|||||||
console.log(`✅ AI Engine Health: ${JSON.stringify(health.data)}`);
|
console.log(`✅ AI Engine Health: ${JSON.stringify(health.data)}`);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
} catch (e) {
|
} 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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Load upcoming matches (Not Started)
|
// 2. Load upcoming matches (Not Started)
|
||||||
const upcomingMatches = await prisma.match.findMany({
|
const upcomingMatches = await prisma.match.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: 'NS',
|
status: "NS",
|
||||||
mstUtc: {
|
mstUtc: {
|
||||||
gte: Math.floor(Date.now() / 1000), // Future matches
|
gte: Math.floor(Date.now() / 1000), // Future matches
|
||||||
},
|
},
|
||||||
sport: 'football',
|
sport: "football",
|
||||||
},
|
},
|
||||||
orderBy: { mstUtc: 'asc' },
|
orderBy: { mstUtc: "asc" },
|
||||||
take: MAX_MATCHES_TO_PROCESS,
|
take: MAX_MATCHES_TO_PROCESS,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -105,7 +105,7 @@ async function runBatchPrediction() {
|
|||||||
const err = e as Error;
|
const err = e as Error;
|
||||||
console.error(
|
console.error(
|
||||||
` ❌ Failed for match ${match.id}:`,
|
` ❌ Failed for match ${match.id}:`,
|
||||||
err?.message || 'Unknown error',
|
err?.message || "Unknown error",
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -116,12 +116,12 @@ async function runBatchPrediction() {
|
|||||||
processedCount += batch.length;
|
processedCount += batch.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n════════════════════════════════════════════════════════');
|
console.log("\n════════════════════════════════════════════════════════");
|
||||||
console.log(`🎉 BATCH PROCESS COMPLETE`);
|
console.log(`🎉 BATCH PROCESS COMPLETE`);
|
||||||
console.log(` Total Processed: ${processedCount}`);
|
console.log(` Total Processed: ${processedCount}`);
|
||||||
console.log(` Successfully Updated/Created: ${successCount}`);
|
console.log(` Successfully Updated/Created: ${successCount}`);
|
||||||
console.log(` Failed: ${processedCount - successCount}`);
|
console.log(` Failed: ${processedCount - successCount}`);
|
||||||
console.log('════════════════════════════════════════════════════════');
|
console.log("════════════════════════════════════════════════════════");
|
||||||
|
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🔍 Checking for potential duplicate matches...');
|
console.log("🔍 Checking for potential duplicate matches...");
|
||||||
|
|
||||||
// Group by unique match characteristics
|
// Group by unique match characteristics
|
||||||
// Since we can't easily do GROUP BY with HAVING count > 1 in Prisma standard API without raw query,
|
// 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) {
|
if (duplicates.length === 0) {
|
||||||
console.log(
|
console.log(
|
||||||
'✅ No duplicate matches found based on (HomeTeam + AwayTeam + Date).',
|
"✅ No duplicate matches found based on (HomeTeam + AwayTeam + Date).",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ async function main() {
|
|||||||
console.log(
|
console.log(
|
||||||
`📅 ${date} | ${homeTeam?.name} vs ${awayTeam?.name} (Count: ${group.count})`,
|
`📅 ${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
|
// Check details of the duplicates to see if one is complete and one is not
|
||||||
for (const id of group.ids) {
|
for (const id of group.ids) {
|
||||||
@@ -73,20 +73,20 @@ async function main() {
|
|||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
const counts = [
|
const counts = [
|
||||||
match.oddCategories.length > 0 ? 'Odds' : '',
|
match.oddCategories.length > 0 ? "Odds" : "",
|
||||||
match.footballTeamStats.length > 0 ? 'Stats' : '',
|
match.footballTeamStats.length > 0 ? "Stats" : "",
|
||||||
match.playerEvents.length > 0 ? 'Events' : '',
|
match.playerEvents.length > 0 ? "Events" : "",
|
||||||
match.officials.length > 0 ? 'Officials' : '',
|
match.officials.length > 0 ? "Officials" : "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(', ');
|
.join(", ");
|
||||||
|
|
||||||
console.log(
|
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
|
* 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_STATUSES = ["Finished", "Played", "FT", "AET", "PEN", "Ended"];
|
||||||
const FINISHED_STATES = ['Finished', 'post', 'FT', 'postGame'];
|
const FINISHED_STATES = ["Finished", "post", "FT", "postGame"];
|
||||||
const LIVE_STATUSES = [
|
const LIVE_STATUSES = [
|
||||||
'LIVE',
|
"LIVE",
|
||||||
'1H',
|
"1H",
|
||||||
'2H',
|
"2H",
|
||||||
'HT',
|
"HT",
|
||||||
'1Q',
|
"1Q",
|
||||||
'2Q',
|
"2Q",
|
||||||
'3Q',
|
"3Q",
|
||||||
'4Q',
|
"4Q",
|
||||||
'Playing',
|
"Playing",
|
||||||
'Half Time',
|
"Half Time",
|
||||||
];
|
];
|
||||||
const LIVE_STATES = ['live', 'firsthalf', 'secondhalf'];
|
const LIVE_STATES = ["live", "firsthalf", "secondhalf"];
|
||||||
|
|
||||||
async function cleanupLiveMatches() {
|
async function cleanupLiveMatches() {
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('🧹 Live matches temizliği başlıyor...');
|
console.log("🧹 Live matches temizliği başlıyor...");
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const finishedGraceMs = 6 * 60 * 60 * 1000;
|
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(` Toplam live_matches: ${totalBefore}`);
|
||||||
console.log(` Geçmiş zamanlı kayıt: ${outdatedCount}`);
|
console.log(` Geçmiş zamanlı kayıt: ${outdatedCount}`);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -83,7 +83,7 @@ async function cleanupLiveMatches() {
|
|||||||
|
|
||||||
const totalAfter = await prisma.liveMatch.count();
|
const totalAfter = await prisma.liveMatch.count();
|
||||||
|
|
||||||
console.log('\n✅ Temizlik tamamlandı!');
|
console.log("\n✅ Temizlik tamamlandı!");
|
||||||
console.log(` Silinen maç: ${deleted.count}`);
|
console.log(` Silinen maç: ${deleted.count}`);
|
||||||
console.log(` Kalan maç: ${totalAfter}`);
|
console.log(` Kalan maç: ${totalAfter}`);
|
||||||
|
|
||||||
@@ -93,12 +93,12 @@ async function cleanupLiveMatches() {
|
|||||||
GROUP BY state
|
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) => {
|
(states as any).forEach((s: any) => {
|
||||||
console.log(` ${s.state || 'null'}: ${s.count}`);
|
console.log(` ${s.state || "null"}: ${s.count}`);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Hata:', error);
|
console.error("❌ Hata:", error);
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/compute-elo-ratings.ts
|
* 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
|
// Types
|
||||||
@@ -72,9 +72,9 @@ function getResultChar(
|
|||||||
scoreAway: number,
|
scoreAway: number,
|
||||||
isHomeTeam: boolean,
|
isHomeTeam: boolean,
|
||||||
): string {
|
): string {
|
||||||
if (scoreHome > scoreAway) return isHomeTeam ? 'W' : 'L';
|
if (scoreHome > scoreAway) return isHomeTeam ? "W" : "L";
|
||||||
if (scoreHome < scoreAway) return isHomeTeam ? 'L' : 'W';
|
if (scoreHome < scoreAway) return isHomeTeam ? "L" : "W";
|
||||||
return 'D';
|
return "D";
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateFormElo(recentResults: string[]): number {
|
function calculateFormElo(recentResults: string[]): number {
|
||||||
@@ -86,7 +86,7 @@ function calculateFormElo(recentResults: string[]): number {
|
|||||||
for (let i = 0; i < recentResults.length; i++) {
|
for (let i = 0; i < recentResults.length; i++) {
|
||||||
const weight = Math.pow(FORM_DECAY, i); // Most recent = highest weight
|
const weight = Math.pow(FORM_DECAY, i); // Most recent = highest weight
|
||||||
const result = recentResults[i];
|
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;
|
formScore += score * weight;
|
||||||
totalWeight += 3 * weight; // Max possible per match
|
totalWeight += 3 * weight; // Max possible per match
|
||||||
}
|
}
|
||||||
@@ -105,14 +105,14 @@ async function computeEloRatings(): Promise<void> {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('🏟️ ELO Rating Computation — Starting...');
|
console.log("🏟️ ELO Rating Computation — Starting...");
|
||||||
console.log('─'.repeat(60));
|
console.log("─".repeat(60));
|
||||||
|
|
||||||
// 1. Fetch all finished football matches in chronological order
|
// 1. Fetch all finished football matches in chronological order
|
||||||
const matches: MatchRecord[] = await prisma.match.findMany({
|
const matches: MatchRecord[] = await prisma.match.findMany({
|
||||||
where: {
|
where: {
|
||||||
sport: 'football',
|
sport: "football",
|
||||||
status: 'FT',
|
status: "FT",
|
||||||
scoreHome: { not: null },
|
scoreHome: { not: null },
|
||||||
scoreAway: { not: null },
|
scoreAway: { not: null },
|
||||||
homeTeamId: { not: null },
|
homeTeamId: { not: null },
|
||||||
@@ -126,7 +126,7 @@ async function computeEloRatings(): Promise<void> {
|
|||||||
scoreAway: true,
|
scoreAway: true,
|
||||||
mstUtc: true,
|
mstUtc: true,
|
||||||
},
|
},
|
||||||
orderBy: { mstUtc: 'asc' },
|
orderBy: { mstUtc: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
@@ -228,7 +228,7 @@ async function computeEloRatings(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 4. Bulk upsert to team_elo_ratings
|
// 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 BATCH_SIZE = 500;
|
||||||
const teams = Array.from(eloMap.entries());
|
const teams = Array.from(eloMap.entries());
|
||||||
@@ -246,7 +246,7 @@ async function computeEloRatings(): Promise<void> {
|
|||||||
awayElo: Math.round(state.awayElo * 10) / 10,
|
awayElo: Math.round(state.awayElo * 10) / 10,
|
||||||
formElo: Math.round(state.formElo * 10) / 10,
|
formElo: Math.round(state.formElo * 10) / 10,
|
||||||
matchesPlayed: state.matchesPlayed,
|
matchesPlayed: state.matchesPlayed,
|
||||||
recentForm: state.recentResults.join(''),
|
recentForm: state.recentResults.join(""),
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
teamId,
|
teamId,
|
||||||
@@ -255,7 +255,7 @@ async function computeEloRatings(): Promise<void> {
|
|||||||
awayElo: Math.round(state.awayElo * 10) / 10,
|
awayElo: Math.round(state.awayElo * 10) / 10,
|
||||||
formElo: Math.round(state.formElo * 10) / 10,
|
formElo: Math.round(state.formElo * 10) / 10,
|
||||||
matchesPlayed: state.matchesPlayed,
|
matchesPlayed: state.matchesPlayed,
|
||||||
recentForm: state.recentResults.join(''),
|
recentForm: state.recentResults.join(""),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -276,38 +276,38 @@ async function computeEloRatings(): Promise<void> {
|
|||||||
.map((s) => s.overallElo)
|
.map((s) => s.overallElo)
|
||||||
.sort((a, b) => b - a);
|
.sort((a, b) => b - a);
|
||||||
|
|
||||||
console.log('─'.repeat(60));
|
console.log("─".repeat(60));
|
||||||
console.log('📊 ELO Rating Summary:');
|
console.log("📊 ELO Rating Summary:");
|
||||||
console.log(` Teams rated: ${eloMap.size.toLocaleString()}`);
|
console.log(` Teams rated: ${eloMap.size.toLocaleString()}`);
|
||||||
console.log(` Matches used: ${processed.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(
|
console.log(
|
||||||
` Lowest ELO: ${overallElos[overallElos.length - 1]?.toFixed(1) ?? 'N/A'}`,
|
` Lowest ELO: ${overallElos[overallElos.length - 1]?.toFixed(1) ?? "N/A"}`,
|
||||||
);
|
);
|
||||||
console.log(
|
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(` Duration: ${elapsedTotal}s`);
|
||||||
console.log('─'.repeat(60));
|
console.log("─".repeat(60));
|
||||||
|
|
||||||
// Top 20 teams
|
// Top 20 teams
|
||||||
const topTeams = await prisma.teamEloRating.findMany({
|
const topTeams = await prisma.teamEloRating.findMany({
|
||||||
orderBy: { overallElo: 'desc' },
|
orderBy: { overallElo: "desc" },
|
||||||
take: 20,
|
take: 20,
|
||||||
include: { team: { select: { name: true } } },
|
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) => {
|
topTeams.forEach((t, i) => {
|
||||||
const form = t.recentForm.split('').join('-');
|
const form = t.recentForm.split("").join("-");
|
||||||
console.log(
|
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}`,
|
` ${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) {
|
} catch (error) {
|
||||||
console.error('❌ ELO computation failed:', error);
|
console.error("❌ ELO computation failed:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'reflect-metadata';
|
import "reflect-metadata";
|
||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
import * as path from 'node:path';
|
import * as path from "node:path";
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||||
import { AppModule } from '../app.module';
|
import { AppModule } from "../app.module";
|
||||||
|
|
||||||
type JsonRecord = Record<string, unknown>;
|
type JsonRecord = Record<string, unknown>;
|
||||||
type SwaggerPaths = Record<string, Record<string, JsonRecord>>;
|
type SwaggerPaths = Record<string, Record<string, JsonRecord>>;
|
||||||
@@ -14,7 +14,7 @@ interface PostmanResponse {
|
|||||||
originalRequest: JsonRecord;
|
originalRequest: JsonRecord;
|
||||||
status: string;
|
status: string;
|
||||||
code: number;
|
code: number;
|
||||||
_postman_previewlanguage: 'json';
|
_postman_previewlanguage: "json";
|
||||||
header: Array<{ key: string; value: string }>;
|
header: Array<{ key: string; value: string }>;
|
||||||
body: string;
|
body: string;
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ interface PostmanItem {
|
|||||||
|
|
||||||
interface AiEndpointDefinition {
|
interface AiEndpointDefinition {
|
||||||
name: string;
|
name: string;
|
||||||
method: 'GET' | 'POST';
|
method: "GET" | "POST";
|
||||||
path: string;
|
path: string;
|
||||||
description: string;
|
description: string;
|
||||||
query?: Array<{ key: string; value: string; description: string }>;
|
query?: Array<{ key: string; value: string; description: string }>;
|
||||||
@@ -40,7 +40,7 @@ function refName(ref: string | undefined): string | null {
|
|||||||
if (!ref) {
|
if (!ref) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const parts = ref.split('/');
|
const parts = ref.split("/");
|
||||||
return parts[parts.length - 1] ?? null;
|
return parts[parts.length - 1] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,12 +48,13 @@ function resolveSchema(
|
|||||||
schema: unknown,
|
schema: unknown,
|
||||||
schemas: SwaggerSchemas,
|
schemas: SwaggerSchemas,
|
||||||
): JsonRecord | null {
|
): JsonRecord | null {
|
||||||
if (!schema || typeof schema !== 'object') {
|
if (!schema || typeof schema !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schemaObject = schema as JsonRecord;
|
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) {
|
if (schemaRef) {
|
||||||
const name = refName(schemaRef);
|
const name = refName(schemaRef);
|
||||||
return name ? (schemas[name] ?? null) : null;
|
return name ? (schemas[name] ?? null) : null;
|
||||||
@@ -73,31 +74,31 @@ function examplePrimitive(schema: JsonRecord): unknown {
|
|||||||
return schema.enum[0];
|
return schema.enum[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = typeof schema.type === 'string' ? schema.type : 'string';
|
const type = typeof schema.type === "string" ? schema.type : "string";
|
||||||
const format = typeof schema.format === 'string' ? schema.format : '';
|
const format = typeof schema.format === "string" ? schema.format : "";
|
||||||
|
|
||||||
if (type === 'string') {
|
if (type === "string") {
|
||||||
if (format === 'email') {
|
if (format === "email") {
|
||||||
return 'user@example.com';
|
return "user@example.com";
|
||||||
}
|
}
|
||||||
if (format === 'date-time') {
|
if (format === "date-time") {
|
||||||
return '2026-04-14T00:00:00.000Z';
|
return "2026-04-14T00:00:00.000Z";
|
||||||
}
|
}
|
||||||
if (format === 'date') {
|
if (format === "date") {
|
||||||
return '2026-04-14';
|
return "2026-04-14";
|
||||||
}
|
}
|
||||||
if (format === 'uuid') {
|
if (format === "uuid") {
|
||||||
return '11111111-1111-1111-1111-111111111111';
|
return "11111111-1111-1111-1111-111111111111";
|
||||||
}
|
}
|
||||||
return 'string';
|
return "string";
|
||||||
}
|
}
|
||||||
if (type === 'integer' || type === 'number') {
|
if (type === "integer" || type === "number") {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
if (type === 'boolean') {
|
if (type === "boolean") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return 'string';
|
return "string";
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildExampleFromSchema(
|
function buildExampleFromSchema(
|
||||||
@@ -110,7 +111,7 @@ function buildExampleFromSchema(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schemaRef = typeof resolved.$ref === 'string' ? resolved.$ref : null;
|
const schemaRef = typeof resolved.$ref === "string" ? resolved.$ref : null;
|
||||||
if (schemaRef) {
|
if (schemaRef) {
|
||||||
const name = refName(schemaRef);
|
const name = refName(schemaRef);
|
||||||
if (!name || visited.has(name)) {
|
if (!name || visited.has(name)) {
|
||||||
@@ -124,7 +125,7 @@ function buildExampleFromSchema(
|
|||||||
if (Array.isArray(resolved.allOf) && resolved.allOf.length > 0) {
|
if (Array.isArray(resolved.allOf) && resolved.allOf.length > 0) {
|
||||||
return resolved.allOf.reduce<JsonRecord>((accumulator, part) => {
|
return resolved.allOf.reduce<JsonRecord>((accumulator, part) => {
|
||||||
const partial = buildExampleFromSchema(part, schemas, visited);
|
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, ...(partial as JsonRecord) };
|
||||||
}
|
}
|
||||||
return accumulator;
|
return accumulator;
|
||||||
@@ -139,12 +140,12 @@ function buildExampleFromSchema(
|
|||||||
return buildExampleFromSchema(resolved.anyOf[0], schemas, visited);
|
return buildExampleFromSchema(resolved.anyOf[0], schemas, visited);
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = typeof resolved.type === 'string' ? resolved.type : 'object';
|
const type = typeof resolved.type === "string" ? resolved.type : "object";
|
||||||
if (type === 'array') {
|
if (type === "array") {
|
||||||
return [buildExampleFromSchema(resolved.items, schemas, visited)];
|
return [buildExampleFromSchema(resolved.items, schemas, visited)];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'object' || resolved.properties) {
|
if (type === "object" || resolved.properties) {
|
||||||
const properties = (resolved.properties ?? {}) as JsonRecord;
|
const properties = (resolved.properties ?? {}) as JsonRecord;
|
||||||
const output: JsonRecord = {};
|
const output: JsonRecord = {};
|
||||||
for (const [key, value] of Object.entries(properties)) {
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
@@ -159,23 +160,23 @@ function buildExampleFromSchema(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function swaggerSchemaFromContent(content: unknown): unknown {
|
function swaggerSchemaFromContent(content: unknown): unknown {
|
||||||
if (!content || typeof content !== 'object') {
|
if (!content || typeof content !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const contentObject = content as JsonRecord;
|
const contentObject = content as JsonRecord;
|
||||||
const jsonContent = contentObject['application/json'];
|
const jsonContent = contentObject["application/json"];
|
||||||
if (jsonContent && typeof jsonContent === 'object') {
|
if (jsonContent && typeof jsonContent === "object") {
|
||||||
return (jsonContent as JsonRecord).schema ?? null;
|
return (jsonContent as JsonRecord).schema ?? null;
|
||||||
}
|
}
|
||||||
const firstContent = Object.values(contentObject)[0];
|
const firstContent = Object.values(contentObject)[0];
|
||||||
if (firstContent && typeof firstContent === 'object') {
|
if (firstContent && typeof firstContent === "object") {
|
||||||
return (firstContent as JsonRecord).schema ?? null;
|
return (firstContent as JsonRecord).schema ?? null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPostmanPath(pathname: string): string {
|
function toPostmanPath(pathname: string): string {
|
||||||
return pathname.replace(/\{([^}]+)\}/g, '{{$1}}');
|
return pathname.replace(/\{([^}]+)\}/g, "{{$1}}");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRequestBody(
|
function buildRequestBody(
|
||||||
@@ -213,25 +214,26 @@ function buildResponses(
|
|||||||
name: `${method.toUpperCase()} ${rawPath} - ${statusCode}`,
|
name: `${method.toUpperCase()} ${rawPath} - ${statusCode}`,
|
||||||
originalRequest: {
|
originalRequest: {
|
||||||
method: method.toUpperCase(),
|
method: method.toUpperCase(),
|
||||||
header: [{ key: 'Content-Type', value: 'application/json' }],
|
header: [{ key: "Content-Type", value: "application/json" }],
|
||||||
body: body
|
body: body
|
||||||
? {
|
? {
|
||||||
mode: 'raw',
|
mode: "raw",
|
||||||
raw: body,
|
raw: body,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
url: {
|
url: {
|
||||||
raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`,
|
raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`,
|
||||||
host: [`{{${baseUrlVariable}}}`],
|
host: [`{{${baseUrlVariable}}}`],
|
||||||
path: rawPath.split('/').filter(Boolean),
|
path: rawPath.split("/").filter(Boolean),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
status: typeof responseRecord.description === 'string'
|
status:
|
||||||
? responseRecord.description
|
typeof responseRecord.description === "string"
|
||||||
: `HTTP ${statusCode}`,
|
? responseRecord.description
|
||||||
|
: `HTTP ${statusCode}`,
|
||||||
code: Number.isFinite(numericStatus) ? numericStatus : 200,
|
code: Number.isFinite(numericStatus) ? numericStatus : 200,
|
||||||
_postman_previewlanguage: 'json',
|
_postman_previewlanguage: "json",
|
||||||
header: [{ key: 'Content-Type', value: 'application/json' }],
|
header: [{ key: "Content-Type", value: "application/json" }],
|
||||||
body: JSON.stringify(example ?? {}, null, 2),
|
body: JSON.stringify(example ?? {}, null, 2),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -243,14 +245,14 @@ function buildQueryParams(operation: JsonRecord): Array<JsonRecord> {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
return parameters
|
return parameters
|
||||||
.filter((parameter) => parameter.in === 'query')
|
.filter((parameter) => parameter.in === "query")
|
||||||
.map((parameter) => ({
|
.map((parameter) => ({
|
||||||
key: String(parameter.name ?? ''),
|
key: String(parameter.name ?? ""),
|
||||||
value:
|
value:
|
||||||
parameter.schema && typeof parameter.schema === 'object'
|
parameter.schema && typeof parameter.schema === "object"
|
||||||
? String(((parameter.schema as JsonRecord).default ?? ''))
|
? String((parameter.schema as JsonRecord).default ?? "")
|
||||||
: '',
|
: "",
|
||||||
description: String(parameter.description ?? ''),
|
description: String(parameter.description ?? ""),
|
||||||
disabled: parameter.required === true ? false : true,
|
disabled: parameter.required === true ? false : true,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -258,8 +260,8 @@ function buildQueryParams(operation: JsonRecord): Array<JsonRecord> {
|
|||||||
function buildHeaders(operation: JsonRecord): Array<JsonRecord> {
|
function buildHeaders(operation: JsonRecord): Array<JsonRecord> {
|
||||||
const headers: Array<JsonRecord> = [
|
const headers: Array<JsonRecord> = [
|
||||||
{
|
{
|
||||||
key: 'Content-Type',
|
key: "Content-Type",
|
||||||
value: 'application/json',
|
value: "application/json",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -268,8 +270,8 @@ function buildHeaders(operation: JsonRecord): Array<JsonRecord> {
|
|||||||
: [];
|
: [];
|
||||||
if (security.length > 0) {
|
if (security.length > 0) {
|
||||||
headers.push({
|
headers.push({
|
||||||
key: 'Authorization',
|
key: "Authorization",
|
||||||
value: 'Bearer {{accessToken}}',
|
value: "Bearer {{accessToken}}",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,20 +294,22 @@ function createRequestItem(
|
|||||||
method: method.toUpperCase(),
|
method: method.toUpperCase(),
|
||||||
header: headers,
|
header: headers,
|
||||||
description:
|
description:
|
||||||
typeof operation.description === 'string'
|
typeof operation.description === "string"
|
||||||
? operation.description
|
? operation.description
|
||||||
: (typeof operation.summary === 'string' ? operation.summary : ''),
|
: typeof operation.summary === "string"
|
||||||
|
? operation.summary
|
||||||
|
: "",
|
||||||
url: {
|
url: {
|
||||||
raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`,
|
raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`,
|
||||||
host: [`{{${baseUrlVariable}}}`],
|
host: [`{{${baseUrlVariable}}}`],
|
||||||
path: rawPath.split('/').filter(Boolean),
|
path: rawPath.split("/").filter(Boolean),
|
||||||
query,
|
query,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (body) {
|
if (body) {
|
||||||
request.body = {
|
request.body = {
|
||||||
mode: 'raw',
|
mode: "raw",
|
||||||
raw: body,
|
raw: body,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -335,18 +339,19 @@ function buildNestFolders(document: JsonRecord): PostmanItem[] {
|
|||||||
|
|
||||||
for (const [rawPath, pathItem] of Object.entries(paths)) {
|
for (const [rawPath, pathItem] of Object.entries(paths)) {
|
||||||
for (const [method, operationObject] of Object.entries(pathItem)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const operation = operationObject as JsonRecord;
|
const operation = operationObject;
|
||||||
const tags = Array.isArray(operation.tags) ? operation.tags : [];
|
const tags = Array.isArray(operation.tags) ? operation.tags : [];
|
||||||
const folderName =
|
const folderName =
|
||||||
typeof tags[0] === 'string' && tags[0].trim().length > 0
|
typeof tags[0] === "string" && tags[0].trim().length > 0
|
||||||
? tags[0]
|
? tags[0]
|
||||||
: 'Misc';
|
: "Misc";
|
||||||
const requestName =
|
const requestName =
|
||||||
typeof operation.summary === 'string' && operation.summary.trim().length > 0
|
typeof operation.summary === "string" &&
|
||||||
|
operation.summary.trim().length > 0
|
||||||
? operation.summary
|
? operation.summary
|
||||||
: `${method.toUpperCase()} ${rawPath}`;
|
: `${method.toUpperCase()} ${rawPath}`;
|
||||||
|
|
||||||
@@ -354,7 +359,7 @@ function buildNestFolders(document: JsonRecord): PostmanItem[] {
|
|||||||
requestName,
|
requestName,
|
||||||
method,
|
method,
|
||||||
rawPath,
|
rawPath,
|
||||||
'beBaseUrl',
|
"beBaseUrl",
|
||||||
operation,
|
operation,
|
||||||
safeSchemas,
|
safeSchemas,
|
||||||
);
|
);
|
||||||
@@ -379,8 +384,8 @@ function createAiRequest(
|
|||||||
): PostmanItem {
|
): PostmanItem {
|
||||||
const url: JsonRecord = {
|
const url: JsonRecord = {
|
||||||
raw: `{{aiBaseUrl}}${endpoint.path}`,
|
raw: `{{aiBaseUrl}}${endpoint.path}`,
|
||||||
host: ['{{aiBaseUrl}}'],
|
host: ["{{aiBaseUrl}}"],
|
||||||
path: endpoint.path.split('/').filter(Boolean),
|
path: endpoint.path.split("/").filter(Boolean),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (endpoint.query && endpoint.query.length > 0) {
|
if (endpoint.query && endpoint.query.length > 0) {
|
||||||
@@ -393,14 +398,14 @@ function createAiRequest(
|
|||||||
|
|
||||||
const request: JsonRecord = {
|
const request: JsonRecord = {
|
||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
header: [{ key: 'Content-Type', value: 'application/json' }],
|
header: [{ key: "Content-Type", value: "application/json" }],
|
||||||
description: endpoint.description,
|
description: endpoint.description,
|
||||||
url,
|
url,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (endpoint.body) {
|
if (endpoint.body) {
|
||||||
request.body = {
|
request.body = {
|
||||||
mode: 'raw',
|
mode: "raw",
|
||||||
raw: JSON.stringify(endpoint.body, null, 2),
|
raw: JSON.stringify(endpoint.body, null, 2),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -412,10 +417,10 @@ function createAiRequest(
|
|||||||
{
|
{
|
||||||
name: `${endpoint.method} ${endpoint.path}`,
|
name: `${endpoint.method} ${endpoint.path}`,
|
||||||
originalRequest: request,
|
originalRequest: request,
|
||||||
status: 'OK',
|
status: "OK",
|
||||||
code: 200,
|
code: 200,
|
||||||
_postman_previewlanguage: 'json',
|
_postman_previewlanguage: "json",
|
||||||
header: [{ key: 'Content-Type', value: 'application/json' }],
|
header: [{ key: "Content-Type", value: "application/json" }],
|
||||||
body: JSON.stringify(endpoint.response, null, 2),
|
body: JSON.stringify(endpoint.response, null, 2),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -425,105 +430,105 @@ function createAiRequest(
|
|||||||
function buildAiFolder(): PostmanItem {
|
function buildAiFolder(): PostmanItem {
|
||||||
const v20Endpoints: AiEndpointDefinition[] = [
|
const v20Endpoints: AiEndpointDefinition[] = [
|
||||||
{
|
{
|
||||||
name: 'Root',
|
name: "Root",
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
path: '/',
|
path: "/",
|
||||||
description: 'AI engine root status endpoint',
|
description: "AI engine root status endpoint",
|
||||||
response: {
|
response: {
|
||||||
status: 'Suggest-Bet AI Engine v20+',
|
status: "Suggest-Bet AI Engine v20+",
|
||||||
engine: 'V20 Plus Single Match Orchestrator',
|
engine: "V20 Plus Single Match Orchestrator",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Health',
|
name: "Health",
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
path: '/health',
|
path: "/health",
|
||||||
description: 'AI engine health endpoint',
|
description: "AI engine health endpoint",
|
||||||
response: { status: 'healthy', engine: 'v20plus', ready: true },
|
response: { status: "healthy", engine: "v20plus", ready: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Analyze Match',
|
name: "Analyze Match",
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
path: '/v20plus/analyze/{{match_id}}',
|
path: "/v20plus/analyze/{{match_id}}",
|
||||||
description: 'Full V20+ single match analysis',
|
description: "Full V20+ single match analysis",
|
||||||
response: {
|
response: {
|
||||||
model_version: 'v30.0',
|
model_version: "v30.0",
|
||||||
match_info: { match_id: '{{match_id}}' },
|
match_info: { match_id: "{{match_id}}" },
|
||||||
main_pick: { market: 'OU25', pick: '2.5 Üst' },
|
main_pick: { market: "OU25", pick: "2.5 Üst" },
|
||||||
market_board: {},
|
market_board: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Analyze HTMS',
|
name: "Analyze HTMS",
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
path: '/v20plus/analyze-htms/{{match_id}}',
|
path: "/v20plus/analyze-htms/{{match_id}}",
|
||||||
description: 'Half-time result analysis endpoint',
|
description: "Half-time result analysis endpoint",
|
||||||
response: { match_id: '{{match_id}}', market: 'HT' },
|
response: { match_id: "{{match_id}}", market: "HT" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Analyze HTFT',
|
name: "Analyze HTFT",
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
path: '/v20plus/analyze-htft/{{match_id}}',
|
path: "/v20plus/analyze-htft/{{match_id}}",
|
||||||
description: 'Half-time/full-time analysis endpoint',
|
description: "Half-time/full-time analysis endpoint",
|
||||||
query: [
|
query: [
|
||||||
{
|
{
|
||||||
key: 'timeout_sec',
|
key: "timeout_sec",
|
||||||
value: '30',
|
value: "30",
|
||||||
description: 'Timeout between 3 and 120 seconds',
|
description: "Timeout between 3 and 120 seconds",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: {
|
response: {
|
||||||
engine: 'v20plus.1',
|
engine: "v20plus.1",
|
||||||
match_info: { match_id: '{{match_id}}' },
|
match_info: { match_id: "{{match_id}}" },
|
||||||
ht_ft_probs: { '1/1': 0.25, 'X/X': 0.18 },
|
ht_ft_probs: { "1/1": 0.25, "X/X": 0.18 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Generate Coupon',
|
name: "Generate Coupon",
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
path: '/v20plus/coupon',
|
path: "/v20plus/coupon",
|
||||||
description: 'Generate V20+ coupon from selected matches',
|
description: "Generate V20+ coupon from selected matches",
|
||||||
body: {
|
body: {
|
||||||
match_ids: ['match-1', 'match-2'],
|
match_ids: ["match-1", "match-2"],
|
||||||
strategy: 'BALANCED',
|
strategy: "BALANCED",
|
||||||
max_matches: 4,
|
max_matches: 4,
|
||||||
min_confidence: 55,
|
min_confidence: 55,
|
||||||
},
|
},
|
||||||
response: {
|
response: {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
strategy: 'BALANCED',
|
strategy: "BALANCED",
|
||||||
bets: [],
|
bets: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Daily Banker',
|
name: "Daily Banker",
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
path: '/v20plus/daily-banker',
|
path: "/v20plus/daily-banker",
|
||||||
description: 'Get daily banker picks',
|
description: "Get daily banker picks",
|
||||||
query: [
|
query: [
|
||||||
{
|
{
|
||||||
key: 'count',
|
key: "count",
|
||||||
value: '3',
|
value: "3",
|
||||||
description: 'Number of banker picks',
|
description: "Number of banker picks",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: { count: 3, bankers: [] },
|
response: { count: 3, bankers: [] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Reversal Watchlist',
|
name: "Reversal Watchlist",
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
path: '/v20plus/reversal-watchlist',
|
path: "/v20plus/reversal-watchlist",
|
||||||
description: 'Reversal watchlist candidates',
|
description: "Reversal watchlist candidates",
|
||||||
query: [
|
query: [
|
||||||
{ key: 'count', value: '20', description: 'Result size' },
|
{ key: "count", value: "20", description: "Result size" },
|
||||||
{ key: 'horizon_hours', value: '72', description: 'Future horizon' },
|
{ key: "horizon_hours", value: "72", description: "Future horizon" },
|
||||||
{ key: 'min_score', value: '45', description: 'Minimum score' },
|
{ key: "min_score", value: "45", description: "Minimum score" },
|
||||||
{
|
{
|
||||||
key: 'top_leagues_only',
|
key: "top_leagues_only",
|
||||||
value: 'false',
|
value: "false",
|
||||||
description: 'Filter to top leagues',
|
description: "Filter to top leagues",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: { count: 0, items: [] },
|
response: { count: 0, items: [] },
|
||||||
@@ -532,42 +537,42 @@ function buildAiFolder(): PostmanItem {
|
|||||||
|
|
||||||
const v2Endpoints: AiEndpointDefinition[] = [
|
const v2Endpoints: AiEndpointDefinition[] = [
|
||||||
{
|
{
|
||||||
name: 'V2 Health',
|
name: "V2 Health",
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
path: '/v2/health',
|
path: "/v2/health",
|
||||||
description: 'V2 betting engine health',
|
description: "V2 betting engine health",
|
||||||
response: {
|
response: {
|
||||||
status: 'healthy',
|
status: "healthy",
|
||||||
engine: 'v2.betting_engine',
|
engine: "v2.betting_engine",
|
||||||
models_loaded: true,
|
models_loaded: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'V2 Analyze Match',
|
name: "V2 Analyze Match",
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
path: '/v2/analyze/{{match_id}}',
|
path: "/v2/analyze/{{match_id}}",
|
||||||
description: 'V2 leakage-free match analysis',
|
description: "V2 leakage-free match analysis",
|
||||||
response: {
|
response: {
|
||||||
model_version: 'v2.betting_engine',
|
model_version: "v2.betting_engine",
|
||||||
match_info: { match_id: '{{match_id}}' },
|
match_info: { match_id: "{{match_id}}" },
|
||||||
main_pick: { market: 'MS', pick: '1' },
|
main_pick: { market: "MS", pick: "1" },
|
||||||
market_board: {
|
market_board: {
|
||||||
MS: { pick: '1', confidence: 58.4 },
|
MS: { pick: "1", confidence: 58.4 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'AI Engine',
|
name: "AI Engine",
|
||||||
item: [
|
item: [
|
||||||
{
|
{
|
||||||
name: 'V20+',
|
name: "V20+",
|
||||||
item: v20Endpoints.map((endpoint) => createAiRequest(endpoint, 'V20+')),
|
item: v20Endpoints.map((endpoint) => createAiRequest(endpoint, "V20+")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'V2',
|
name: "V2",
|
||||||
item: v2Endpoints.map((endpoint) => createAiRequest(endpoint, 'V2')),
|
item: v2Endpoints.map((endpoint) => createAiRequest(endpoint, "V2")),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -575,21 +580,21 @@ function buildAiFolder(): PostmanItem {
|
|||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
const outputDir = path.join(projectRoot, 'mds');
|
const outputDir = path.join(projectRoot, "mds");
|
||||||
const outputFile = path.join(
|
const outputFile = path.join(
|
||||||
outputDir,
|
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 });
|
const app = await NestFactory.create(AppModule, { logger: false });
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix("api");
|
||||||
|
|
||||||
const swaggerConfig = new DocumentBuilder()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
.setTitle('Suggest Bet Backend API')
|
.setTitle("Suggest Bet Backend API")
|
||||||
.setDescription('Postman collection export source')
|
.setDescription("Postman collection export source")
|
||||||
.setVersion('1.0')
|
.setVersion("1.0")
|
||||||
.addBearerAuth()
|
.addBearerAuth()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -600,25 +605,25 @@ async function run(): Promise<void> {
|
|||||||
|
|
||||||
const collection: JsonRecord = {
|
const collection: JsonRecord = {
|
||||||
info: {
|
info: {
|
||||||
name: 'Suggest-Bet Platform API',
|
name: "Suggest-Bet Platform API",
|
||||||
description:
|
description:
|
||||||
'Auto-generated Postman collection for Nest backend and AI engine endpoints.',
|
"Auto-generated Postman collection for Nest backend and AI engine endpoints.",
|
||||||
schema:
|
schema:
|
||||||
'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
|
"https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||||
},
|
},
|
||||||
variable: [
|
variable: [
|
||||||
{ key: 'beBaseUrl', value: 'http://localhost:3005' },
|
{ key: "beBaseUrl", value: "http://localhost:3005" },
|
||||||
{ key: 'aiBaseUrl', value: 'http://localhost:8000' },
|
{ key: "aiBaseUrl", value: "http://localhost:8000" },
|
||||||
{ key: 'accessToken', value: '' },
|
{ key: "accessToken", value: "" },
|
||||||
{ key: 'match_id', value: 'sample-match-id' },
|
{ key: "match_id", value: "sample-match-id" },
|
||||||
],
|
],
|
||||||
auth: {
|
auth: {
|
||||||
type: 'bearer',
|
type: "bearer",
|
||||||
bearer: [{ key: 'token', value: '{{accessToken}}', type: 'string' }],
|
bearer: [{ key: "token", value: "{{accessToken}}", type: "string" }],
|
||||||
},
|
},
|
||||||
item: [
|
item: [
|
||||||
{
|
{
|
||||||
name: 'Nest API',
|
name: "Nest API",
|
||||||
item: buildNestFolders(document),
|
item: buildNestFolders(document),
|
||||||
},
|
},
|
||||||
buildAiFolder(),
|
buildAiFolder(),
|
||||||
@@ -626,7 +631,7 @@ async function run(): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
mkdirSync(outputDir, { recursive: true });
|
mkdirSync(outputDir, { recursive: true });
|
||||||
writeFileSync(outputFile, JSON.stringify(collection, null, 2), 'utf8');
|
writeFileSync(outputFile, JSON.stringify(collection, null, 2), "utf8");
|
||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import 'reflect-metadata';
|
import "reflect-metadata";
|
||||||
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import * as path from 'node:path';
|
import * as path from "node:path";
|
||||||
import ts from 'typescript';
|
import ts from "typescript";
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
|
||||||
import { AppModule } from '../app.module';
|
import { AppModule } from "../app.module";
|
||||||
|
|
||||||
type HttpMethod =
|
type HttpMethod =
|
||||||
| 'get'
|
| "get"
|
||||||
| 'post'
|
| "post"
|
||||||
| 'put'
|
| "put"
|
||||||
| 'patch'
|
| "patch"
|
||||||
| 'delete'
|
| "delete"
|
||||||
| 'options'
|
| "options"
|
||||||
| 'head'
|
| "head"
|
||||||
| 'all';
|
| "all";
|
||||||
|
|
||||||
interface TsDecoratorMeta {
|
interface TsDecoratorMeta {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -42,14 +42,14 @@ interface TsMethodMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const HTTP_DECORATOR_TO_METHOD: Record<string, HttpMethod> = {
|
const HTTP_DECORATOR_TO_METHOD: Record<string, HttpMethod> = {
|
||||||
Get: 'get',
|
Get: "get",
|
||||||
Post: 'post',
|
Post: "post",
|
||||||
Put: 'put',
|
Put: "put",
|
||||||
Patch: 'patch',
|
Patch: "patch",
|
||||||
Delete: 'delete',
|
Delete: "delete",
|
||||||
Options: 'options',
|
Options: "options",
|
||||||
Head: 'head',
|
Head: "head",
|
||||||
All: 'all',
|
All: "all",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDecorators(node: ts.Node): readonly ts.Decorator[] {
|
function getDecorators(node: ts.Node): readonly ts.Decorator[] {
|
||||||
@@ -105,7 +105,7 @@ function collectControllerFiles(dirPath: string): string[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.isFile() && entry.name.endsWith('.controller.ts')) {
|
if (entry.isFile() && entry.name.endsWith(".controller.ts")) {
|
||||||
files.push(absolutePath);
|
files.push(absolutePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,9 +115,9 @@ function collectControllerFiles(dirPath: string): string[] {
|
|||||||
|
|
||||||
function normalizeRoutePart(value: string | undefined): string {
|
function normalizeRoutePart(value: string | undefined): string {
|
||||||
if (!value || value === "''" || value === '""') {
|
if (!value || value === "''" || value === '""') {
|
||||||
return '';
|
return "";
|
||||||
}
|
}
|
||||||
return value.trim().replace(/^\/+|\/+$/g, '');
|
return value.trim().replace(/^\/+|\/+$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSwaggerPath(
|
function buildSwaggerPath(
|
||||||
@@ -131,18 +131,18 @@ function buildSwaggerPath(
|
|||||||
normalizeRoutePart(routePath),
|
normalizeRoutePart(routePath),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
return `/${parts.join('/')}`;
|
return `/${parts.join("/")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectTsEndpointMetadata(
|
function collectTsEndpointMetadata(
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
): Map<string, TsMethodMeta> {
|
): Map<string, TsMethodMeta> {
|
||||||
const modulesDir = path.join(projectRoot, 'src', 'modules');
|
const modulesDir = path.join(projectRoot, "src", "modules");
|
||||||
const controllerFiles = collectControllerFiles(modulesDir);
|
const controllerFiles = collectControllerFiles(modulesDir);
|
||||||
const metadataByOperationId = new Map<string, TsMethodMeta>();
|
const metadataByOperationId = new Map<string, TsMethodMeta>();
|
||||||
|
|
||||||
for (const filePath of controllerFiles) {
|
for (const filePath of controllerFiles) {
|
||||||
const sourceText = readFileSync(filePath, 'utf8');
|
const sourceText = readFileSync(filePath, "utf8");
|
||||||
const sourceFile = ts.createSourceFile(
|
const sourceFile = ts.createSourceFile(
|
||||||
filePath,
|
filePath,
|
||||||
sourceText,
|
sourceText,
|
||||||
@@ -164,7 +164,7 @@ function collectTsEndpointMetadata(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const controllerDecorator = classDecorators.find(
|
const controllerDecorator = classDecorators.find(
|
||||||
(decorator) => decorator.name === 'Controller',
|
(decorator) => decorator.name === "Controller",
|
||||||
);
|
);
|
||||||
if (!controllerDecorator) {
|
if (!controllerDecorator) {
|
||||||
return;
|
return;
|
||||||
@@ -221,7 +221,7 @@ function collectTsEndpointMetadata(
|
|||||||
routePath,
|
routePath,
|
||||||
returnType,
|
returnType,
|
||||||
hasPublicDecorator: methodDecorators.some(
|
hasPublicDecorator: methodDecorators.some(
|
||||||
(decorator) => decorator.name === 'Public',
|
(decorator) => decorator.name === "Public",
|
||||||
),
|
),
|
||||||
methodDecorators: methodDecorators.map((decorator) => decorator.name),
|
methodDecorators: methodDecorators.map((decorator) => decorator.name),
|
||||||
params,
|
params,
|
||||||
@@ -235,10 +235,10 @@ function collectTsEndpointMetadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refName(ref?: string): string | null {
|
function refName(ref?: string): string | null {
|
||||||
if (!ref || typeof ref !== 'string') {
|
if (!ref || typeof ref !== "string") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const parts = ref.split('/');
|
const parts = ref.split("/");
|
||||||
return parts[parts.length - 1] ?? null;
|
return parts[parts.length - 1] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,13 +246,13 @@ function collectSchemaRefs(
|
|||||||
value: unknown,
|
value: unknown,
|
||||||
refs = new Set<string>(),
|
refs = new Set<string>(),
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
if (!value || typeof value !== 'object') {
|
if (!value || typeof value !== "object") {
|
||||||
return refs;
|
return refs;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordValue = value as Record<string, unknown>;
|
const recordValue = value as Record<string, unknown>;
|
||||||
const maybeRef = recordValue.$ref;
|
const maybeRef = recordValue.$ref;
|
||||||
if (typeof maybeRef === 'string') {
|
if (typeof maybeRef === "string") {
|
||||||
const name = refName(maybeRef);
|
const name = refName(maybeRef);
|
||||||
if (name) {
|
if (name) {
|
||||||
refs.add(name);
|
refs.add(name);
|
||||||
@@ -267,22 +267,22 @@ function collectSchemaRefs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function schemaTypeSummary(schema: unknown): string {
|
function schemaTypeSummary(schema: unknown): string {
|
||||||
if (!schema || typeof schema !== 'object') {
|
if (!schema || typeof schema !== "object") {
|
||||||
return 'unknown';
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
const schemaObj = schema as Record<string, unknown>;
|
const schemaObj = schema as Record<string, unknown>;
|
||||||
if (typeof schemaObj.$ref === 'string') {
|
if (typeof schemaObj.$ref === "string") {
|
||||||
return refName(schemaObj.$ref) ?? 'unknown';
|
return refName(schemaObj.$ref) ?? "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = typeof schemaObj.type === 'string' ? schemaObj.type : 'object';
|
const type = typeof schemaObj.type === "string" ? schemaObj.type : "object";
|
||||||
if (type === 'array') {
|
if (type === "array") {
|
||||||
return `array<${schemaTypeSummary(schemaObj.items)}>`;
|
return `array<${schemaTypeSummary(schemaObj.items)}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(schemaObj.enum) && schemaObj.enum.length > 0) {
|
if (Array.isArray(schemaObj.enum) && schemaObj.enum.length > 0) {
|
||||||
return `${type}(${schemaObj.enum.join(' | ')})`;
|
return `${type}(${schemaObj.enum.join(" | ")})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return type;
|
return type;
|
||||||
@@ -295,35 +295,35 @@ function normalizeParameters(parameters: unknown[] = []) {
|
|||||||
.map((parameter) => {
|
.map((parameter) => {
|
||||||
const schema = (parameter.schema ?? {}) as Record<string, unknown>;
|
const schema = (parameter.schema ?? {}) as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
name: typeof parameter.name === 'string' ? parameter.name : '',
|
name: typeof parameter.name === "string" ? parameter.name : "",
|
||||||
in: typeof parameter.in === 'string' ? parameter.in : '',
|
in: typeof parameter.in === "string" ? parameter.in : "",
|
||||||
required: Boolean(parameter.required),
|
required: Boolean(parameter.required),
|
||||||
description:
|
description:
|
||||||
typeof parameter.description === 'string'
|
typeof parameter.description === "string"
|
||||||
? parameter.description
|
? parameter.description
|
||||||
: null,
|
: null,
|
||||||
type: schemaTypeSummary(schema),
|
type: schemaTypeSummary(schema),
|
||||||
enum: Array.isArray(schema.enum) ? schema.enum : [],
|
enum: Array.isArray(schema.enum) ? schema.enum : [],
|
||||||
default: schema.default ?? null,
|
default: schema.default ?? null,
|
||||||
format: typeof schema.format === 'string' ? schema.format : null,
|
format: typeof schema.format === "string" ? schema.format : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: parsed.filter((item) => item.in === 'path'),
|
path: parsed.filter((item) => item.in === "path"),
|
||||||
query: parsed.filter((item) => item.in === 'query'),
|
query: parsed.filter((item) => item.in === "query"),
|
||||||
header: parsed.filter((item) => item.in === 'header'),
|
header: parsed.filter((item) => item.in === "header"),
|
||||||
cookie: parsed.filter((item) => item.in === 'cookie'),
|
cookie: parsed.filter((item) => item.in === "cookie"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRequestBody(requestBody: unknown) {
|
function normalizeRequestBody(requestBody: unknown) {
|
||||||
if (!requestBody || typeof requestBody !== 'object') {
|
if (!requestBody || typeof requestBody !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestBodyObj = requestBody as Record<string, unknown>;
|
const requestBodyObj = requestBody as Record<string, unknown>;
|
||||||
if (typeof requestBodyObj.$ref === 'string') {
|
if (typeof requestBodyObj.$ref === "string") {
|
||||||
return {
|
return {
|
||||||
required: false,
|
required: false,
|
||||||
contentTypes: [],
|
contentTypes: [],
|
||||||
@@ -378,9 +378,9 @@ function normalizeResponses(responses: Record<string, unknown>) {
|
|||||||
return {
|
return {
|
||||||
status: Number(statusCode),
|
status: Number(statusCode),
|
||||||
description:
|
description:
|
||||||
typeof responseObj.description === 'string'
|
typeof responseObj.description === "string"
|
||||||
? responseObj.description
|
? responseObj.description
|
||||||
: '',
|
: "",
|
||||||
contentTypes,
|
contentTypes,
|
||||||
schemaTypes,
|
schemaTypes,
|
||||||
schemaRefs: [...refs].sort(),
|
schemaRefs: [...refs].sort(),
|
||||||
@@ -392,25 +392,25 @@ function normalizeResponses(responses: Record<string, unknown>) {
|
|||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
const outputDir = path.join(projectRoot, 'mds');
|
const outputDir = path.join(projectRoot, "mds");
|
||||||
const outputFile = path.join(
|
const outputFile = path.join(
|
||||||
outputDir,
|
outputDir,
|
||||||
'backend_endpoints_swagger_summary.json',
|
"backend_endpoints_swagger_summary.json",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Predictions module is conditionally loaded with REDIS_ENABLED in AppModule.
|
// Predictions module is conditionally loaded with REDIS_ENABLED in AppModule.
|
||||||
// Force-enable here to include all backend endpoints in one Swagger export.
|
// 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 tsMetadata = collectTsEndpointMetadata(projectRoot);
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule, { logger: false });
|
const app = await NestFactory.create(AppModule, { logger: false });
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix("api");
|
||||||
|
|
||||||
const swaggerConfig = new DocumentBuilder()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
.setTitle('Suggest Bet Backend API')
|
.setTitle("Suggest Bet Backend API")
|
||||||
.setDescription('Auto-generated endpoint summary from Swagger document')
|
.setDescription("Auto-generated endpoint summary from Swagger document")
|
||||||
.setVersion('1.0')
|
.setVersion("1.0")
|
||||||
.addBearerAuth()
|
.addBearerAuth()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -419,7 +419,7 @@ async function run() {
|
|||||||
|
|
||||||
const endpoints: Array<Record<string, unknown>> = [];
|
const endpoints: Array<Record<string, unknown>> = [];
|
||||||
const seenOperationIds = new Set<string>();
|
const seenOperationIds = new Set<string>();
|
||||||
const globalPrefix = 'api';
|
const globalPrefix = "api";
|
||||||
|
|
||||||
const sortedPaths = Object.keys(paths).sort((a, b) => a.localeCompare(b));
|
const sortedPaths = Object.keys(paths).sort((a, b) => a.localeCompare(b));
|
||||||
for (const endpointPath of sortedPaths) {
|
for (const endpointPath of sortedPaths) {
|
||||||
@@ -427,7 +427,7 @@ async function run() {
|
|||||||
|
|
||||||
const methods = Object.keys(pathItem)
|
const methods = Object.keys(pathItem)
|
||||||
.filter((method) =>
|
.filter((method) =>
|
||||||
['get', 'post', 'put', 'patch', 'delete', 'options', 'head'].includes(
|
["get", "post", "put", "patch", "delete", "options", "head"].includes(
|
||||||
method,
|
method,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -436,7 +436,7 @@ async function run() {
|
|||||||
for (const method of methods) {
|
for (const method of methods) {
|
||||||
const operation = pathItem[method] as Record<string, unknown>;
|
const operation = pathItem[method] as Record<string, unknown>;
|
||||||
const operationId =
|
const operationId =
|
||||||
typeof operation.operationId === 'string' ? operation.operationId : '';
|
typeof operation.operationId === "string" ? operation.operationId : "";
|
||||||
|
|
||||||
if (operationId) {
|
if (operationId) {
|
||||||
seenOperationIds.add(operationId);
|
seenOperationIds.add(operationId);
|
||||||
@@ -464,13 +464,13 @@ async function run() {
|
|||||||
const tsBodyParams =
|
const tsBodyParams =
|
||||||
tsMeta?.params
|
tsMeta?.params
|
||||||
.filter((param) =>
|
.filter((param) =>
|
||||||
param.decorators.some((decorator) => decorator.name === 'Body'),
|
param.decorators.some((decorator) => decorator.name === "Body"),
|
||||||
)
|
)
|
||||||
.map((param) => ({
|
.map((param) => ({
|
||||||
name: param.name,
|
name: param.name,
|
||||||
type: param.type,
|
type: param.type,
|
||||||
bodyKey:
|
bodyKey:
|
||||||
param.decorators.find((decorator) => decorator.name === 'Body')
|
param.decorators.find((decorator) => decorator.name === "Body")
|
||||||
?.firstArg ?? null,
|
?.firstArg ?? null,
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
@@ -482,9 +482,9 @@ async function run() {
|
|||||||
tag: tags[0] ?? null,
|
tag: tags[0] ?? null,
|
||||||
tags,
|
tags,
|
||||||
summary:
|
summary:
|
||||||
typeof operation.summary === 'string' ? operation.summary : null,
|
typeof operation.summary === "string" ? operation.summary : null,
|
||||||
description:
|
description:
|
||||||
typeof operation.description === 'string'
|
typeof operation.description === "string"
|
||||||
? operation.description
|
? operation.description
|
||||||
: null,
|
: null,
|
||||||
auth: {
|
auth: {
|
||||||
@@ -527,10 +527,10 @@ async function run() {
|
|||||||
tsMeta.controllerRoute,
|
tsMeta.controllerRoute,
|
||||||
tsMeta.routePath,
|
tsMeta.routePath,
|
||||||
),
|
),
|
||||||
tag: tsMeta.controller.replace(/Controller$/, ''),
|
tag: tsMeta.controller.replace(/Controller$/, ""),
|
||||||
tags: [tsMeta.controller.replace(/Controller$/, '')],
|
tags: [tsMeta.controller.replace(/Controller$/, "")],
|
||||||
summary: null,
|
summary: null,
|
||||||
description: 'Not present in generated Swagger document',
|
description: "Not present in generated Swagger document",
|
||||||
auth: {
|
auth: {
|
||||||
swaggerSecurityRequired: null,
|
swaggerSecurityRequired: null,
|
||||||
swaggerSecuritySchemes: [],
|
swaggerSecuritySchemes: [],
|
||||||
@@ -546,13 +546,13 @@ async function run() {
|
|||||||
body: null,
|
body: null,
|
||||||
tsBodyParams: tsMeta.params
|
tsBodyParams: tsMeta.params
|
||||||
.filter((param) =>
|
.filter((param) =>
|
||||||
param.decorators.some((decorator) => decorator.name === 'Body'),
|
param.decorators.some((decorator) => decorator.name === "Body"),
|
||||||
)
|
)
|
||||||
.map((param) => ({
|
.map((param) => ({
|
||||||
name: param.name,
|
name: param.name,
|
||||||
type: param.type,
|
type: param.type,
|
||||||
bodyKey:
|
bodyKey:
|
||||||
param.decorators.find((decorator) => decorator.name === 'Body')
|
param.decorators.find((decorator) => decorator.name === "Body")
|
||||||
?.firstArg ?? null,
|
?.firstArg ?? null,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -569,19 +569,19 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
endpoints.sort((a, b) => {
|
endpoints.sort((a, b) => {
|
||||||
const pathA = typeof a.path === 'string' ? a.path : '';
|
const pathA = typeof a.path === "string" ? a.path : "";
|
||||||
const pathB = typeof b.path === 'string' ? b.path : '';
|
const pathB = typeof b.path === "string" ? b.path : "";
|
||||||
if (pathA !== pathB) {
|
if (pathA !== pathB) {
|
||||||
return pathA.localeCompare(pathB);
|
return pathA.localeCompare(pathB);
|
||||||
}
|
}
|
||||||
return (typeof a.method === 'string' ? a.method : '').localeCompare(
|
return (typeof a.method === "string" ? a.method : "").localeCompare(
|
||||||
typeof b.method === 'string' ? b.method : '',
|
typeof b.method === "string" ? b.method : "",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const tagStats = new Map<string, number>();
|
const tagStats = new Map<string, number>();
|
||||||
for (const endpoint of endpoints) {
|
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);
|
tagStats.set(tag, (tagStats.get(tag) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,7 +591,7 @@ async function run() {
|
|||||||
.body as Record<string, unknown> | null;
|
.body as Record<string, unknown> | null;
|
||||||
if (requestBody && Array.isArray(requestBody.schemaRefs)) {
|
if (requestBody && Array.isArray(requestBody.schemaRefs)) {
|
||||||
for (const schemaName of requestBody.schemaRefs) {
|
for (const schemaName of requestBody.schemaRefs) {
|
||||||
if (typeof schemaName === 'string') {
|
if (typeof schemaName === "string") {
|
||||||
referencedSchemas.add(schemaName);
|
referencedSchemas.add(schemaName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -604,7 +604,7 @@ async function run() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const schemaName of status.schemaRefs) {
|
for (const schemaName of status.schemaRefs) {
|
||||||
if (typeof schemaName === 'string') {
|
if (typeof schemaName === "string") {
|
||||||
referencedSchemas.add(schemaName);
|
referencedSchemas.add(schemaName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -626,16 +626,16 @@ async function run() {
|
|||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
generatedBy: 'src/scripts/export-swagger-endpoints-summary.ts',
|
generatedBy: "src/scripts/export-swagger-endpoints-summary.ts",
|
||||||
project: 'Suggest-Bet-BE',
|
project: "Suggest-Bet-BE",
|
||||||
swagger: {
|
swagger: {
|
||||||
docsPath: '/api/docs',
|
docsPath: "/api/docs",
|
||||||
globalPrefix: '/api',
|
globalPrefix: "/api",
|
||||||
endpointCountInSwagger: endpoints.filter((item) => item.inSwagger).length,
|
endpointCountInSwagger: endpoints.filter((item) => item.inSwagger).length,
|
||||||
endpointCountTotal: endpoints.length,
|
endpointCountTotal: endpoints.length,
|
||||||
warnings: [
|
warnings: [
|
||||||
'Swagger output reflects loaded modules for current environment.',
|
"Swagger output reflects loaded modules for current environment.",
|
||||||
'This export forces REDIS_ENABLED=true to include conditional Prediction endpoints.',
|
"This export forces REDIS_ENABLED=true to include conditional Prediction endpoints.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
@@ -668,7 +668,7 @@ async function run() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
mkdirSync(outputDir, { recursive: true });
|
mkdirSync(outputDir, { recursive: true });
|
||||||
writeFileSync(outputFile, JSON.stringify(summary, null, 2), 'utf8');
|
writeFileSync(outputFile, JSON.stringify(summary, null, 2), "utf8");
|
||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|
||||||
@@ -680,7 +680,7 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void run().catch((error: unknown) => {
|
void run().catch((error: unknown) => {
|
||||||
console.error('❌ Failed to export Swagger endpoint summary');
|
console.error("❌ Failed to export Swagger endpoint summary");
|
||||||
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/populate-feature-store.ts
|
* 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();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -180,16 +180,16 @@ function buildFormIndex(
|
|||||||
|
|
||||||
const homeResult =
|
const homeResult =
|
||||||
match.scoreHome > match.scoreAway
|
match.scoreHome > match.scoreAway
|
||||||
? 'W'
|
? "W"
|
||||||
: match.scoreHome < match.scoreAway
|
: match.scoreHome < match.scoreAway
|
||||||
? 'L'
|
? "L"
|
||||||
: 'D';
|
: "D";
|
||||||
const awayResult =
|
const awayResult =
|
||||||
match.scoreAway > match.scoreHome
|
match.scoreAway > match.scoreHome
|
||||||
? 'W'
|
? "W"
|
||||||
: match.scoreAway < match.scoreHome
|
: match.scoreAway < match.scoreHome
|
||||||
? 'L'
|
? "L"
|
||||||
: 'D';
|
: "D";
|
||||||
|
|
||||||
homeState.results.unshift(homeResult);
|
homeState.results.unshift(homeResult);
|
||||||
awayState.results.unshift(awayResult);
|
awayState.results.unshift(awayResult);
|
||||||
@@ -222,14 +222,14 @@ function extractFormFeatures(formState: TeamFormState): {
|
|||||||
|
|
||||||
let winStreak = 0;
|
let winStreak = 0;
|
||||||
for (const r of formState.results) {
|
for (const r of formState.results) {
|
||||||
if (r === 'W') winStreak++;
|
if (r === "W") winStreak++;
|
||||||
else break;
|
else break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form score: (W=3, D=1, L=0) over last 5, normalized to 0-100
|
// Form score: (W=3, D=1, L=0) over last 5, normalized to 0-100
|
||||||
const last5Results = formState.results.slice(0, 5);
|
const last5Results = formState.results.slice(0, 5);
|
||||||
const points = last5Results.reduce(
|
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,
|
0,
|
||||||
);
|
);
|
||||||
const maxPoints = last5Results.length * 3 || 1;
|
const maxPoints = last5Results.length * 3 || 1;
|
||||||
@@ -302,20 +302,20 @@ async function loadOddsIndex(): Promise<Map<string, OddsData>> {
|
|||||||
let bttsY = 0;
|
let bttsY = 0;
|
||||||
|
|
||||||
for (const s of selections) {
|
for (const s of selections) {
|
||||||
if (s.cat === 'Maç Sonucu') {
|
if (s.cat === "Maç Sonucu") {
|
||||||
if (s.sel === '1') msH = s.odds;
|
if (s.sel === "1") msH = s.odds;
|
||||||
else if (s.sel === 'X' || s.sel === '0') msD = s.odds;
|
else if (s.sel === "X" || s.sel === "0") msD = s.odds;
|
||||||
else if (s.sel === '2') msA = s.odds;
|
else if (s.sel === "2") msA = s.odds;
|
||||||
} else if (s.cat === 'Alt/Üst 2,5') {
|
} else if (s.cat === "Alt/Üst 2,5") {
|
||||||
if (
|
if (
|
||||||
s.sel.toLowerCase().includes('üst') ||
|
s.sel.toLowerCase().includes("üst") ||
|
||||||
s.sel.toLowerCase().includes('over')
|
s.sel.toLowerCase().includes("over")
|
||||||
)
|
)
|
||||||
ou25O = s.odds;
|
ou25O = s.odds;
|
||||||
} else if (s.cat === 'Karşılıklı Gol') {
|
} else if (s.cat === "Karşılıklı Gol") {
|
||||||
if (
|
if (
|
||||||
s.sel.toLowerCase().includes('var') ||
|
s.sel.toLowerCase().includes("var") ||
|
||||||
s.sel.toLowerCase().includes('yes')
|
s.sel.toLowerCase().includes("yes")
|
||||||
)
|
)
|
||||||
bttsY = s.odds;
|
bttsY = s.odds;
|
||||||
}
|
}
|
||||||
@@ -411,7 +411,7 @@ function buildLeagueIndex(matches: MatchRow[]): Map<string, LeagueStats> {
|
|||||||
const leagueMap = new Map<string, LeagueStats>();
|
const leagueMap = new Map<string, LeagueStats>();
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const key = match.leagueId ?? 'unknown';
|
const key = match.leagueId ?? "unknown";
|
||||||
let stats = leagueMap.get(key);
|
let stats = leagueMap.get(key);
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
stats = { totalMatches: 0, totalGoals: 0, homeWins: 0, over25Count: 0 };
|
stats = { totalMatches: 0, totalGoals: 0, homeWins: 0, over25Count: 0 };
|
||||||
@@ -520,15 +520,15 @@ async function populateFeatureStore(): Promise<void> {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('🧠 Feature Store Population — Starting...');
|
console.log("🧠 Feature Store Population — Starting...");
|
||||||
console.log('─'.repeat(60));
|
console.log("─".repeat(60));
|
||||||
|
|
||||||
// Load all finished football matches
|
// Load all finished football matches
|
||||||
console.log('📥 Loading matches...');
|
console.log("📥 Loading matches...");
|
||||||
const rawMatches = await prisma.match.findMany({
|
const rawMatches = await prisma.match.findMany({
|
||||||
where: {
|
where: {
|
||||||
sport: 'football',
|
sport: "football",
|
||||||
status: 'FT',
|
status: "FT",
|
||||||
scoreHome: { not: null },
|
scoreHome: { not: null },
|
||||||
scoreAway: { not: null },
|
scoreAway: { not: null },
|
||||||
homeTeamId: { not: null },
|
homeTeamId: { not: null },
|
||||||
@@ -543,7 +543,7 @@ async function populateFeatureStore(): Promise<void> {
|
|||||||
scoreAway: true,
|
scoreAway: true,
|
||||||
mstUtc: true,
|
mstUtc: true,
|
||||||
},
|
},
|
||||||
orderBy: { mstUtc: 'asc' },
|
orderBy: { mstUtc: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const matches: MatchRow[] = rawMatches.map((m) => ({
|
const matches: MatchRow[] = rawMatches.map((m) => ({
|
||||||
@@ -559,31 +559,31 @@ async function populateFeatureStore(): Promise<void> {
|
|||||||
console.log(` 📊 Matches loaded: ${matches.length.toLocaleString()}`);
|
console.log(` 📊 Matches loaded: ${matches.length.toLocaleString()}`);
|
||||||
|
|
||||||
// Pre-compute all indexes
|
// 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();
|
const eloMap = await loadEloMap();
|
||||||
|
|
||||||
console.log(' 📈 Pillar 2: Building form index...');
|
console.log(" 📈 Pillar 2: Building form index...");
|
||||||
const formIndex = buildFormIndex(matches);
|
const formIndex = buildFormIndex(matches);
|
||||||
|
|
||||||
console.log(' 💰 Pillar 3: Loading odds data...');
|
console.log(" 💰 Pillar 3: Loading odds data...");
|
||||||
const oddsIndex = await loadOddsIndex();
|
const oddsIndex = await loadOddsIndex();
|
||||||
|
|
||||||
console.log(' ⚔️ Pillar 5: Building H2H index...');
|
console.log(" ⚔️ Pillar 5: Building H2H index...");
|
||||||
const h2hIndex = buildH2HIndex(matches);
|
const h2hIndex = buildH2HIndex(matches);
|
||||||
|
|
||||||
console.log(' 📋 Pillar 6: Loading referee data...');
|
console.log(" 📋 Pillar 6: Loading referee data...");
|
||||||
const refereeIndex = await loadRefereeIndex(matches);
|
const refereeIndex = await loadRefereeIndex(matches);
|
||||||
|
|
||||||
console.log(' 🏟️ Pillar 7: Building league DNA...');
|
console.log(" 🏟️ Pillar 7: Building league DNA...");
|
||||||
const leagueIndex = buildLeagueIndex(matches);
|
const leagueIndex = buildLeagueIndex(matches);
|
||||||
|
|
||||||
console.log('\n✅ All indexes built!');
|
console.log("\n✅ All indexes built!");
|
||||||
console.log('─'.repeat(60));
|
console.log("─".repeat(60));
|
||||||
|
|
||||||
// Build feature vectors and batch upsert
|
// Build feature vectors and batch upsert
|
||||||
console.log('💾 Writing features to database...');
|
console.log("💾 Writing features to database...");
|
||||||
|
|
||||||
const BATCH_SIZE = 1000;
|
const BATCH_SIZE = 1000;
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
@@ -651,7 +651,7 @@ async function populateFeatureStore(): Promise<void> {
|
|||||||
const refTotal = refStats?.totalMatches ?? 0;
|
const refTotal = refStats?.totalMatches ?? 0;
|
||||||
|
|
||||||
// Pillar 7: League DNA
|
// Pillar 7: League DNA
|
||||||
const leagueKey = match.leagueId ?? 'unknown';
|
const leagueKey = match.leagueId ?? "unknown";
|
||||||
const leagueStats = leagueIndex.get(leagueKey) ?? {
|
const leagueStats = leagueIndex.get(leagueKey) ?? {
|
||||||
totalMatches: 1,
|
totalMatches: 1,
|
||||||
totalGoals: 0,
|
totalGoals: 0,
|
||||||
@@ -730,7 +730,7 @@ async function populateFeatureStore(): Promise<void> {
|
|||||||
),
|
),
|
||||||
// Meta
|
// Meta
|
||||||
missingPlayersImpact: 0,
|
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);
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
console.log('─'.repeat(60));
|
console.log("─".repeat(60));
|
||||||
console.log(`✅ Feature Store population complete!`);
|
console.log(`✅ Feature Store population complete!`);
|
||||||
console.log(` Features written: ${processed.toLocaleString()}`);
|
console.log(` Features written: ${processed.toLocaleString()}`);
|
||||||
console.log(` Skipped: ${skipped}`);
|
console.log(` Skipped: ${skipped}`);
|
||||||
@@ -758,9 +758,9 @@ async function populateFeatureStore(): Promise<void> {
|
|||||||
// Verify
|
// Verify
|
||||||
const count = await prisma.footballAiFeature.count();
|
const count = await prisma.footballAiFeature.count();
|
||||||
console.log(` DB row count: ${count.toLocaleString()}`);
|
console.log(` DB row count: ${count.toLocaleString()}`);
|
||||||
console.log('─'.repeat(60));
|
console.log("─".repeat(60));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Feature store population failed:', error);
|
console.error("❌ Feature store population failed:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
process.env.PORT = process.env.PORT || '3005';
|
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.AI_ENGINE_URL =
|
||||||
|
process.env.AI_ENGINE_URL || "http://127.0.0.1:8000";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// 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