Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1346924387 | |||
| e4c74025e5 | |||
| c8e7e4e927 |
@@ -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
|
|
||||||
+330
-695
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { UserRole } from "@prisma/client";
|
||||||
|
|
||||||
|
export const APP_ROLES = {
|
||||||
|
user: UserRole.user,
|
||||||
|
superadmin: UserRole.superadmin,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ADMIN_ROLES = [APP_ROLES.superadmin] as const;
|
||||||
|
|
||||||
|
export function normalizeRole(role: string | null | undefined): string {
|
||||||
|
return role?.trim().toLowerCase() ?? "";
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import axios, {
|
||||||
|
AxiosError,
|
||||||
|
AxiosInstance,
|
||||||
|
AxiosRequestConfig,
|
||||||
|
AxiosResponse,
|
||||||
|
} from "axios";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
export type AiCircuitState = "closed" | "open";
|
||||||
|
|
||||||
|
export interface AiEngineClientOptions {
|
||||||
|
baseUrl: string;
|
||||||
|
logger: Logger;
|
||||||
|
serviceName: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
maxRetries?: number;
|
||||||
|
retryDelayMs?: number;
|
||||||
|
circuitBreakerThreshold?: number;
|
||||||
|
circuitBreakerCooldownMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiEngineRequestConfig extends AxiosRequestConfig {
|
||||||
|
retryCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiEngineClientSnapshot {
|
||||||
|
state: AiCircuitState;
|
||||||
|
consecutiveFailures: number;
|
||||||
|
openedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AiEngineRequestError extends Error {
|
||||||
|
status?: number;
|
||||||
|
detail?: unknown;
|
||||||
|
isCircuitOpen: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
options: {
|
||||||
|
status?: number;
|
||||||
|
detail?: unknown;
|
||||||
|
isCircuitOpen?: boolean;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AiEngineRequestError";
|
||||||
|
this.status = options.status;
|
||||||
|
this.detail = options.detail;
|
||||||
|
this.isCircuitOpen = options.isCircuitOpen ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AiEngineClient {
|
||||||
|
private readonly axiosClient: AxiosInstance;
|
||||||
|
private readonly logger: Logger;
|
||||||
|
private readonly serviceName: string;
|
||||||
|
private readonly defaultTimeoutMs: number;
|
||||||
|
private readonly maxRetries: number;
|
||||||
|
private readonly retryDelayMs: number;
|
||||||
|
private readonly circuitBreakerThreshold: number;
|
||||||
|
private readonly circuitBreakerCooldownMs: number;
|
||||||
|
|
||||||
|
private consecutiveFailures = 0;
|
||||||
|
private circuitOpenedAt: number | null = null;
|
||||||
|
|
||||||
|
constructor(options: AiEngineClientOptions) {
|
||||||
|
this.logger = options.logger;
|
||||||
|
this.serviceName = options.serviceName;
|
||||||
|
this.defaultTimeoutMs = options.timeoutMs ?? 30000;
|
||||||
|
this.maxRetries = options.maxRetries ?? 2;
|
||||||
|
this.retryDelayMs = options.retryDelayMs ?? 750;
|
||||||
|
this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 3;
|
||||||
|
this.circuitBreakerCooldownMs =
|
||||||
|
options.circuitBreakerCooldownMs ?? 30000;
|
||||||
|
|
||||||
|
this.axiosClient = axios.create({
|
||||||
|
baseURL: options.baseUrl,
|
||||||
|
timeout: this.defaultTimeoutMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T>(
|
||||||
|
path: string,
|
||||||
|
config?: AiEngineRequestConfig,
|
||||||
|
): Promise<AxiosResponse<T>> {
|
||||||
|
return this.request<T>({
|
||||||
|
method: "get",
|
||||||
|
url: path,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async post<T>(
|
||||||
|
path: string,
|
||||||
|
data?: unknown,
|
||||||
|
config?: AiEngineRequestConfig,
|
||||||
|
): Promise<AxiosResponse<T>> {
|
||||||
|
return this.request<T>({
|
||||||
|
method: "post",
|
||||||
|
url: path,
|
||||||
|
data,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(): AiEngineClientSnapshot {
|
||||||
|
return {
|
||||||
|
state: this.isCircuitOpen() ? "open" : "closed",
|
||||||
|
consecutiveFailures: this.consecutiveFailures,
|
||||||
|
openedAt: this.circuitOpenedAt
|
||||||
|
? new Date(this.circuitOpenedAt).toISOString()
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(config: AiEngineRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
|
this.ensureCircuitAvailable();
|
||||||
|
|
||||||
|
const retries = this.resolveRetryCount(config);
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const response = await this.axiosClient.request<T>({
|
||||||
|
timeout: this.defaultTimeoutMs,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.resetFailures();
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
const shouldRetry = attempt < retries && this.isRetriableError(error);
|
||||||
|
|
||||||
|
if (!shouldRetry) {
|
||||||
|
this.registerFailure(error);
|
||||||
|
throw this.toRequestError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`[${this.serviceName}] AI request retry ${attempt + 1}/${retries} for ${config.method?.toUpperCase()} ${config.url}`,
|
||||||
|
);
|
||||||
|
await this.delay(this.retryDelayMs * (attempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.registerFailure(lastError);
|
||||||
|
throw this.toRequestError(lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveRetryCount(config: AiEngineRequestConfig): number {
|
||||||
|
if (typeof config.retryCount === "number" && config.retryCount >= 0) {
|
||||||
|
return config.retryCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.maxRetries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCircuitAvailable() {
|
||||||
|
if (!this.isCircuitOpen()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingCooldown =
|
||||||
|
this.circuitBreakerCooldownMs - (Date.now() - (this.circuitOpenedAt ?? 0));
|
||||||
|
|
||||||
|
if (remainingCooldown > 0) {
|
||||||
|
throw new AiEngineRequestError("AI engine circuit breaker is open", {
|
||||||
|
status: 503,
|
||||||
|
detail: {
|
||||||
|
cooldownRemainingMs: remainingCooldown,
|
||||||
|
},
|
||||||
|
isCircuitOpen: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`[${this.serviceName}] AI circuit breaker cooldown elapsed, allowing a recovery attempt`,
|
||||||
|
);
|
||||||
|
this.circuitOpenedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCircuitOpen(): boolean {
|
||||||
|
return this.circuitOpenedAt !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetFailures() {
|
||||||
|
this.consecutiveFailures = 0;
|
||||||
|
this.circuitOpenedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerFailure(error: unknown) {
|
||||||
|
this.consecutiveFailures += 1;
|
||||||
|
|
||||||
|
const normalizedError = this.toRequestError(error);
|
||||||
|
this.logger.warn(
|
||||||
|
`[${this.serviceName}] AI request failed (${this.consecutiveFailures}/${this.circuitBreakerThreshold}): ${normalizedError.message}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.consecutiveFailures >= this.circuitBreakerThreshold) {
|
||||||
|
this.circuitOpenedAt = Date.now();
|
||||||
|
this.logger.error(
|
||||||
|
`[${this.serviceName}] AI circuit breaker opened after ${this.consecutiveFailures} consecutive failures`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRetriableError(error: unknown): boolean {
|
||||||
|
if (!axios.isAxiosError(error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error.response) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = error.response.status;
|
||||||
|
return status >= 500 || status === 429 || error.code === "ECONNABORTED";
|
||||||
|
}
|
||||||
|
|
||||||
|
private toRequestError(error: unknown): AiEngineRequestError {
|
||||||
|
if (error instanceof AiEngineRequestError) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const detail = error.response?.data ?? error.message;
|
||||||
|
const status = error.response?.status;
|
||||||
|
const message = this.buildAxiosErrorMessage(error);
|
||||||
|
|
||||||
|
return new AiEngineRequestError(message, {
|
||||||
|
status,
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return new AiEngineRequestError(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AiEngineRequestError("Unknown AI engine error", {
|
||||||
|
detail: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAxiosErrorMessage(error: AxiosError): string {
|
||||||
|
if (error.code === "ECONNABORTED") {
|
||||||
|
return "AI engine request timed out";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error.response) {
|
||||||
|
return "AI engine is unreachable";
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail =
|
||||||
|
(error.response.data as Record<string, unknown> | undefined)?.detail ??
|
||||||
|
error.message;
|
||||||
|
|
||||||
|
return typeof detail === "string"
|
||||||
|
? detail
|
||||||
|
: `AI engine request failed with status ${error.response.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async delay(ms: number) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
type ScoreLikeValue = number | string | null | undefined;
|
||||||
|
|
||||||
|
type ScoreLike = {
|
||||||
|
home?: ScoreLikeValue;
|
||||||
|
away?: ScoreLikeValue;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export interface MatchStatusLike {
|
||||||
|
state?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
substate?: string | null;
|
||||||
|
statusBoxContent?: string | null;
|
||||||
|
scoreHome?: ScoreLikeValue;
|
||||||
|
scoreAway?: ScoreLikeValue;
|
||||||
|
score?: ScoreLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIVE_STATUS_TOKENS = [
|
||||||
|
"live",
|
||||||
|
"livegame",
|
||||||
|
"playing",
|
||||||
|
"half time",
|
||||||
|
"halftime",
|
||||||
|
"1h",
|
||||||
|
"2h",
|
||||||
|
"ht",
|
||||||
|
"1q",
|
||||||
|
"2q",
|
||||||
|
"3q",
|
||||||
|
"4q",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LIVE_STATE_TOKENS = [
|
||||||
|
"live",
|
||||||
|
"livegame",
|
||||||
|
"firsthalf",
|
||||||
|
"secondhalf",
|
||||||
|
"halftime",
|
||||||
|
"1h",
|
||||||
|
"2h",
|
||||||
|
"ht",
|
||||||
|
"1q",
|
||||||
|
"2q",
|
||||||
|
"3q",
|
||||||
|
"4q",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FINISHED_STATUS_TOKENS = [
|
||||||
|
"finished",
|
||||||
|
"played",
|
||||||
|
"ft",
|
||||||
|
"aet",
|
||||||
|
"pen",
|
||||||
|
"penalties",
|
||||||
|
"afterpenalties",
|
||||||
|
"ended",
|
||||||
|
"post",
|
||||||
|
"postgame",
|
||||||
|
"posted",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FINISHED_STATE_TOKENS = [
|
||||||
|
"finished",
|
||||||
|
"post",
|
||||||
|
"postgame",
|
||||||
|
"posted",
|
||||||
|
"ft",
|
||||||
|
"ended",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LIVE_STATUS_VALUES_FOR_DB = [
|
||||||
|
"LIVE",
|
||||||
|
"live",
|
||||||
|
"1H",
|
||||||
|
"2H",
|
||||||
|
"HT",
|
||||||
|
"1Q",
|
||||||
|
"2Q",
|
||||||
|
"3Q",
|
||||||
|
"4Q",
|
||||||
|
"Playing",
|
||||||
|
"Half Time",
|
||||||
|
"liveGame",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LIVE_STATE_VALUES_FOR_DB = [
|
||||||
|
"live",
|
||||||
|
"liveGame",
|
||||||
|
"firsthalf",
|
||||||
|
"secondhalf",
|
||||||
|
"halfTime",
|
||||||
|
"1H",
|
||||||
|
"2H",
|
||||||
|
"HT",
|
||||||
|
"1Q",
|
||||||
|
"2Q",
|
||||||
|
"3Q",
|
||||||
|
"4Q",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FINISHED_STATUS_VALUES_FOR_DB = [
|
||||||
|
"Finished",
|
||||||
|
"Played",
|
||||||
|
"FT",
|
||||||
|
"AET",
|
||||||
|
"PEN",
|
||||||
|
"Ended",
|
||||||
|
"post",
|
||||||
|
"postGame",
|
||||||
|
"posted",
|
||||||
|
"Posted",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FINISHED_STATE_VALUES_FOR_DB = [
|
||||||
|
"Finished",
|
||||||
|
"post",
|
||||||
|
"postGame",
|
||||||
|
"postgame",
|
||||||
|
"posted",
|
||||||
|
"FT",
|
||||||
|
"Ended",
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeToken(value: unknown): string {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScoreValue(value: ScoreLikeValue): number | null {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasResolvedScore(match: MatchStatusLike): boolean {
|
||||||
|
const homeScore = parseScoreValue(match.score?.home ?? match.scoreHome);
|
||||||
|
const awayScore = parseScoreValue(match.score?.away ?? match.scoreAway);
|
||||||
|
return homeScore !== null && awayScore !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMatchLive(match: MatchStatusLike): boolean {
|
||||||
|
const state = normalizeToken(match.state);
|
||||||
|
const status = normalizeToken(match.status);
|
||||||
|
const substate = normalizeToken(match.substate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
LIVE_STATE_TOKENS.includes(state) ||
|
||||||
|
LIVE_STATUS_TOKENS.includes(status) ||
|
||||||
|
LIVE_STATE_TOKENS.includes(substate)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMatchCompleted(match: MatchStatusLike): boolean {
|
||||||
|
if (normalizeToken(match.statusBoxContent) === "ert") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = normalizeToken(match.state);
|
||||||
|
const status = normalizeToken(match.status);
|
||||||
|
const substate = normalizeToken(match.substate);
|
||||||
|
|
||||||
|
if (
|
||||||
|
FINISHED_STATE_TOKENS.includes(state) ||
|
||||||
|
FINISHED_STATUS_TOKENS.includes(status) ||
|
||||||
|
FINISHED_STATE_TOKENS.includes(substate)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasResolvedScore(match) && !isMatchLive(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveStoredMatchStatus(match: MatchStatusLike): string {
|
||||||
|
if (normalizeToken(match.statusBoxContent) === "ert") {
|
||||||
|
return "POSTPONED";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMatchLive(match)) {
|
||||||
|
return "LIVE";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMatchCompleted(match)) {
|
||||||
|
return "FT";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "NS";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayMatchStatus(match: MatchStatusLike): string {
|
||||||
|
if (isMatchLive(match)) {
|
||||||
|
return "LIVE";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMatchCompleted(match)) {
|
||||||
|
return "Finished";
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(match.status || match.state || "NS");
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
function extractDateParts(date: Date, timeZone: string) {
|
||||||
|
const formatter = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const year = Number(parts.find((part) => part.type === "year")?.value);
|
||||||
|
const month = Number(parts.find((part) => part.type === "month")?.value);
|
||||||
|
const day = Number(parts.find((part) => part.type === "day")?.value);
|
||||||
|
|
||||||
|
return { year, month, day };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDateStringInTimeZone(
|
||||||
|
date: Date,
|
||||||
|
timeZone: string,
|
||||||
|
): string {
|
||||||
|
const { year, month, day } = extractDateParts(date, timeZone);
|
||||||
|
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShiftedDateStringInTimeZone(
|
||||||
|
daysOffset: number,
|
||||||
|
timeZone: string,
|
||||||
|
baseDate: Date = new Date(),
|
||||||
|
): string {
|
||||||
|
const { year, month, day } = extractDateParts(baseDate, timeZone);
|
||||||
|
const shifted = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
shifted.setUTCDate(shifted.getUTCDate() + daysOffset);
|
||||||
|
return shifted.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeZoneOffsetMs(date: Date, timeZone: string): number {
|
||||||
|
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
timeZoneName: "shortOffset",
|
||||||
|
});
|
||||||
|
|
||||||
|
const offsetLabel =
|
||||||
|
formatter.formatToParts(date).find((part) => part.type === "timeZoneName")
|
||||||
|
?.value || "GMT+0";
|
||||||
|
|
||||||
|
const match = offsetLabel.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/);
|
||||||
|
if (!match) return 0;
|
||||||
|
|
||||||
|
const sign = match[1] === "-" ? -1 : 1;
|
||||||
|
const hours = Number(match[2] || "0");
|
||||||
|
const minutes = Number(match[3] || "0");
|
||||||
|
|
||||||
|
return sign * (hours * 60 + minutes) * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDayBoundsForTimeZone(
|
||||||
|
dateString: string,
|
||||||
|
timeZone: string,
|
||||||
|
): { startMs: number; endMs: number } {
|
||||||
|
const [year, month, day] = dateString.split("-").map(Number);
|
||||||
|
const startGuess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
|
||||||
|
const nextDayGuess = new Date(Date.UTC(year, month - 1, day + 1, 0, 0, 0));
|
||||||
|
|
||||||
|
const startOffsetMs = getTimeZoneOffsetMs(startGuess, timeZone);
|
||||||
|
const nextDayOffsetMs = getTimeZoneOffsetMs(nextDayGuess, timeZone);
|
||||||
|
|
||||||
|
const startMs = Date.UTC(year, month - 1, day, 0, 0, 0) - startOffsetMs;
|
||||||
|
const nextDayStartMs =
|
||||||
|
Date.UTC(year, month - 1, day + 1, 0, 0, 0) - nextDayOffsetMs;
|
||||||
|
|
||||||
|
return {
|
||||||
|
startMs,
|
||||||
|
endMs: nextDayStartMs - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDateOnlyValueForTimeZone(
|
||||||
|
timeZone: string,
|
||||||
|
date: Date = new Date(),
|
||||||
|
): Date {
|
||||||
|
return new Date(`${getDateStringInTimeZone(date, timeZone)}T00:00:00.000Z`);
|
||||||
|
}
|
||||||
+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,
|
||||||
@@ -259,15 +259,21 @@ export class AdminController {
|
|||||||
premiumUsers,
|
premiumUsers,
|
||||||
totalMatches,
|
totalMatches,
|
||||||
totalPredictions,
|
totalPredictions,
|
||||||
|
totalCoupons,
|
||||||
] = 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(),
|
||||||
|
this.prisma.userCoupon.count(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
|
totalUsers,
|
||||||
|
activeUsers,
|
||||||
|
totalPredictions,
|
||||||
|
totalCoupons,
|
||||||
users: {
|
users: {
|
||||||
total: totalUsers,
|
total: totalUsers,
|
||||||
active: activeUsers,
|
active: activeUsers,
|
||||||
|
|||||||
@@ -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,20 +4,22 @@ 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";
|
||||||
|
import { normalizeRole } from "../../../common/constants/roles";
|
||||||
|
|
||||||
interface AuthenticatedUser {
|
interface AuthenticatedUser {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
|
role?: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,14 +27,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 +57,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 +75,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +90,30 @@ export class RolesGuard implements CanActivate {
|
|||||||
|
|
||||||
const user = req.user as AuthenticatedUser | undefined;
|
const user = req.user as AuthenticatedUser | undefined;
|
||||||
|
|
||||||
if (!user || !user.roles) {
|
if (!user) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
|
const normalizedUserRoles = (user.roles?.length
|
||||||
|
? user.roles
|
||||||
|
: user.role
|
||||||
|
? [user.role]
|
||||||
|
: []
|
||||||
|
).map((role) => normalizeRole(role));
|
||||||
|
|
||||||
|
const normalizedRequiredRoles = requiredRoles.map((role) =>
|
||||||
|
normalizeRole(role),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (normalizedUserRoles.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRole = normalizedRequiredRoles.some((role) =>
|
||||||
|
normalizedUserRoles.includes(role),
|
||||||
|
);
|
||||||
if (!hasRole) {
|
if (!hasRole) {
|
||||||
throw new ForbiddenException('PERMISSION_DENIED');
|
throw new ForbiddenException("PERMISSION_DENIED");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -110,7 +129,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 +153,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,9 @@
|
|||||||
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";
|
||||||
|
import { normalizeRole } from "../../../common/constants/roles";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
@@ -10,9 +11,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({
|
||||||
@@ -29,9 +30,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedRole = normalizeRole(payload.role);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
role: payload.role,
|
role: normalizedRole,
|
||||||
|
roles: normalizedRole ? [normalizedRole] : [],
|
||||||
|
permissions: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 @@
|
|||||||
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
|
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
|
||||||
import axios from 'axios';
|
import { GeminiService } from "../../gemini/gemini.service";
|
||||||
import { GeminiService } from '../../gemini/gemini.service';
|
import {
|
||||||
|
AiEngineClient,
|
||||||
|
AiEngineRequestError,
|
||||||
|
} from "../../../common/utils/ai-engine-client";
|
||||||
|
|
||||||
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;
|
||||||
@@ -126,28 +129,38 @@ export interface SmartCouponResult {
|
|||||||
export class SmartCouponService {
|
export class SmartCouponService {
|
||||||
private readonly logger = new Logger(SmartCouponService.name);
|
private readonly logger = new Logger(SmartCouponService.name);
|
||||||
private readonly aiEngineUrl: string;
|
private readonly aiEngineUrl: string;
|
||||||
|
private readonly aiEngineClient: AiEngineClient;
|
||||||
|
|
||||||
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";
|
||||||
|
this.aiEngineClient = new AiEngineClient({
|
||||||
|
baseUrl: this.aiEngineUrl,
|
||||||
|
logger: this.logger,
|
||||||
|
serviceName: SmartCouponService.name,
|
||||||
|
timeoutMs: 60000,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryDelayMs: 750,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
|
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
|
||||||
let prediction: SingleMatchPredictionPackage;
|
let prediction: SingleMatchPredictionPackage;
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<SingleMatchPredictionPackage>(
|
const response = await this.aiEngineClient.post<SingleMatchPredictionPackage>(
|
||||||
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
|
`/v20plus/analyze/${matchId}`,
|
||||||
);
|
);
|
||||||
prediction = response.data;
|
prediction = response.data;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (error instanceof AiEngineRequestError) {
|
||||||
const detail = error.response?.data?.detail || error.message;
|
const detail =
|
||||||
|
typeof error.detail === "string" ? error.detail : error.message;
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
`AI analyze failed: ${detail}`,
|
`AI analyze failed: ${detail}`,
|
||||||
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
|
error.status || HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
'AI analyze failed',
|
"AI analyze failed",
|
||||||
HttpStatus.SERVICE_UNAVAILABLE,
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -168,7 +181,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 +189,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 +201,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,16 +210,16 @@ 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 {
|
||||||
const response = await axios.post<SmartCouponResult>(
|
const response = await this.aiEngineClient.post<SmartCouponResult>(
|
||||||
`${this.aiEngineUrl}/v20plus/coupon`,
|
"/v20plus/coupon",
|
||||||
{
|
{
|
||||||
match_ids: matchIds,
|
match_ids: matchIds,
|
||||||
strategy,
|
strategy,
|
||||||
@@ -215,17 +228,18 @@ export class SmartCouponService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
this.logger.error('Failed to generate smart coupon', error);
|
this.logger.error("Failed to generate smart coupon", error);
|
||||||
if (axios.isAxiosError(error)) {
|
if (error instanceof AiEngineRequestError) {
|
||||||
const detail = error.response?.data?.detail || error.message;
|
const detail =
|
||||||
|
typeof error.detail === "string" ? error.detail : error.message;
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
`Coupon generation failed: ${detail}`,
|
`Coupon generation failed: ${detail}`,
|
||||||
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
|
error.status || HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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,9 @@ 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";
|
||||||
|
import { deriveStoredMatchStatus } from "../../common/utils/match-status.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FeederPersistenceService {
|
export class FeederPersistenceService {
|
||||||
@@ -33,7 +34,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 +52,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 +94,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 +108,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 +183,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 +222,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,33 +312,15 @@ export class FeederPersistenceService {
|
|||||||
headerData?.htScoreAway ??
|
headerData?.htScoreAway ??
|
||||||
this.safeInt(matchSummary.score?.ht?.away);
|
this.safeInt(matchSummary.score?.ht?.away);
|
||||||
|
|
||||||
let status = 'NS';
|
const status = deriveStoredMatchStatus({
|
||||||
if (headerData?.matchStatus) {
|
state: headerData?.matchStatus ?? matchSummary.state,
|
||||||
if (
|
status: matchSummary.status,
|
||||||
headerData.matchStatus === 'postGame' ||
|
substate: matchSummary.substate,
|
||||||
headerData.matchStatus === 'post'
|
statusBoxContent: matchSummary.statusBoxContent,
|
||||||
) {
|
scoreHome: finalScoreHome,
|
||||||
status = 'FT';
|
scoreAway: finalScoreAway,
|
||||||
} else if (
|
score: matchSummary.score,
|
||||||
headerData.matchStatus === 'live' ||
|
});
|
||||||
headerData.matchStatus === 'liveGame'
|
|
||||||
) {
|
|
||||||
status = 'LIVE';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Postponed Matches (ERT)
|
|
||||||
if (matchSummary.statusBoxContent === 'ERT') {
|
|
||||||
status = 'POSTPONED';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
status === 'NS' &&
|
|
||||||
finalScoreHome !== null &&
|
|
||||||
finalScoreAway !== null
|
|
||||||
) {
|
|
||||||
status = 'FT';
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.match.upsert({
|
await tx.match.upsert({
|
||||||
where: { id: matchId },
|
where: { id: matchId },
|
||||||
@@ -455,7 +438,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 +482,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 +541,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 +575,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 +781,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 +823,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,
|
||||||
@@ -870,15 +853,11 @@ export class FeederPersistenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getExistingMatchIds(matchIds: string[]): Promise<string[]> {
|
async getExistingMatchIds(matchIds: string[]): Promise<string[]> {
|
||||||
// Only consider matches "existing" if they have ALL key data points
|
|
||||||
// This allows re-fetching matches that exist but have missing data
|
|
||||||
const matches = await this.prisma.match.findMany({
|
const matches = await this.prisma.match.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: matchIds },
|
id: { in: matchIds },
|
||||||
AND: [
|
AND: [
|
||||||
{ oddCategories: { some: {} } },
|
{ oddCategories: { some: {} } },
|
||||||
{ playerEvents: { some: {} } },
|
|
||||||
{ officials: { some: {} } },
|
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
{ footballTeamStats: { some: {} } },
|
{ footballTeamStats: { some: {} } },
|
||||||
@@ -934,7 +913,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,8 @@ import {
|
|||||||
ParsedMarket,
|
ParsedMarket,
|
||||||
DbEventPayload,
|
DbEventPayload,
|
||||||
DbMarketPayload,
|
DbMarketPayload,
|
||||||
} from './feeder.types';
|
} from "./feeder.types";
|
||||||
|
import { isMatchCompleted } from "../../common/utils/match-status.util";
|
||||||
|
|
||||||
interface ProcessDateOptions {
|
interface ProcessDateOptions {
|
||||||
onlyCompletedMatches?: boolean;
|
onlyCompletedMatches?: boolean;
|
||||||
@@ -37,10 +38,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 +57,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 +97,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;
|
||||||
|
|
||||||
@@ -116,47 +114,16 @@ export class FeederService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseScoreValue(value: unknown): number | null {
|
|
||||||
if (value === null || value === undefined || value === '') return null;
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isCompletedMatchSummary(match: MatchSummary): boolean {
|
private isCompletedMatchSummary(match: MatchSummary): boolean {
|
||||||
if (match.statusBoxContent === 'ERT') return false;
|
return isMatchCompleted({
|
||||||
|
state: match.state,
|
||||||
const normalizedState = String(match.state || '')
|
status: match.status,
|
||||||
.trim()
|
substate: match.substate,
|
||||||
.toLowerCase();
|
statusBoxContent: match.statusBoxContent,
|
||||||
const normalizedStatus = String(match.status || '')
|
score: match.score,
|
||||||
.trim()
|
scoreHome: match.homeScore,
|
||||||
.toLowerCase();
|
scoreAway: match.awayScore,
|
||||||
const normalizedSubstate = String(match.substate || '')
|
});
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
if (['postgame', 'post'].includes(normalizedState)) return true;
|
|
||||||
|
|
||||||
if (
|
|
||||||
['played', 'finished', 'ft', 'afterpenalties', 'penalties'].includes(
|
|
||||||
normalizedStatus,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (['postgame', 'post', 'played', 'finished', 'ft'].includes(normalizedSubstate)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const homeScore = this.parseScoreValue(
|
|
||||||
match.score?.home ?? match.homeScore,
|
|
||||||
);
|
|
||||||
const awayScore = this.parseScoreValue(
|
|
||||||
match.score?.away ?? match.awayScore,
|
|
||||||
);
|
|
||||||
|
|
||||||
return homeScore !== null && awayScore !== null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async runPreviousDayCompletedMatchesScan(
|
async runPreviousDayCompletedMatchesScan(
|
||||||
@@ -167,7 +134,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 +158,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 +168,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 +182,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 +198,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 +206,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 +245,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 +275,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 +308,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 +482,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 +502,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 +519,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 +547,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 +559,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 +579,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 +625,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 +676,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 +712,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 +720,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 +745,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 +814,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 +839,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 +847,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 +879,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
|
||||||
@@ -959,15 +923,30 @@ export class FeederService {
|
|||||||
*/
|
*/
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
if (saved && hasCriticalError) {
|
const completedMatch = isMatchCompleted({
|
||||||
// Collect missing components
|
state: headerData?.matchStatus ?? matchSummary.state,
|
||||||
|
status: matchSummary.status,
|
||||||
|
substate: matchSummary.substate,
|
||||||
|
statusBoxContent: matchSummary.statusBoxContent,
|
||||||
|
scoreHome: headerData?.scoreHome ?? matchSummary.score?.home,
|
||||||
|
scoreAway: headerData?.scoreAway ?? matchSummary.score?.away,
|
||||||
|
});
|
||||||
|
|
||||||
const missingParts: string[] = [];
|
const missingParts: string[] = [];
|
||||||
if (!stats) missingParts.push('Stats');
|
if (scope === "all" && completedMatch) {
|
||||||
if (oddsArray.length === 0) missingParts.push('Odds');
|
if (sport === "football" && !stats) missingParts.push("Stats");
|
||||||
if (officialsData.length === 0) missingParts.push('Officials');
|
if (sport === "basketball" && !basketballTeamStats)
|
||||||
|
missingParts.push("BoxScore");
|
||||||
|
if (oddsArray.length === 0) missingParts.push("Odds");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saved && (hasCriticalError || missingParts.length > 0)) {
|
||||||
|
const reason = hasCriticalError
|
||||||
|
? "missing data after upstream errors"
|
||||||
|
: "incomplete completed-match payload";
|
||||||
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`[${matchId}] Saved with MISSING DATA (502). Missing: [${missingParts.join(', ')}]. Scheduled for retry.`,
|
`[${matchId}] Saved with ${reason}. Missing: [${missingParts.join(", ")}]. Scheduled for retry.`,
|
||||||
);
|
);
|
||||||
return { success: false, retryable: true };
|
return { success: false, retryable: true };
|
||||||
}
|
}
|
||||||
@@ -975,12 +954,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,44 +1,90 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get, Res } from "@nestjs/common";
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } from "@nestjs/swagger";
|
||||||
import {
|
import { Response } from "express";
|
||||||
HealthCheck,
|
import { Public } from "../../common/decorators";
|
||||||
HealthCheckService,
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
PrismaHealthIndicator,
|
import { PredictionsService } from "../predictions/predictions.service";
|
||||||
} from '@nestjs/terminus';
|
|
||||||
import { Public } from '../../common/decorators';
|
|
||||||
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 prismaHealth: PrismaHealthIndicator,
|
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
|
private readonly predictionsService: PredictionsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Public()
|
@Public()
|
||||||
@HealthCheck()
|
@ApiOperation({ summary: "Basic health check" })
|
||||||
@ApiOperation({ summary: 'Basic health check' })
|
async check(@Res() response: Response) {
|
||||||
check() {
|
const database = await this.getDatabaseHealth();
|
||||||
return this.health.check([]);
|
const aiEngine = await this.predictionsService.checkHealth();
|
||||||
|
const ok = database.status === "up" && aiEngine.predictionServiceReady;
|
||||||
|
|
||||||
|
return response.status(ok ? 200 : 503).json({
|
||||||
|
status: ok ? "ok" : "degraded",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checks: {
|
||||||
|
database,
|
||||||
|
aiEngine,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('ready')
|
@Get("ready")
|
||||||
@Public()
|
@Public()
|
||||||
@HealthCheck()
|
@ApiOperation({ summary: "Readiness check (includes database)" })
|
||||||
@ApiOperation({ summary: 'Readiness check (includes database)' })
|
async readiness(@Res() response: Response) {
|
||||||
readiness() {
|
const database = await this.getDatabaseHealth();
|
||||||
return this.health.check([
|
const aiEngine = await this.predictionsService.checkHealth();
|
||||||
() => this.prismaHealth.pingCheck('database', this.prisma),
|
const ready = database.status === "up" && aiEngine.predictionServiceReady;
|
||||||
]);
|
|
||||||
|
return response.status(ready ? 200 : 503).json({
|
||||||
|
status: ready ? "ready" : "not_ready",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checks: {
|
||||||
|
database,
|
||||||
|
aiEngine,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('live')
|
@Get("live")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Liveness check' })
|
@ApiOperation({ summary: "Liveness check" })
|
||||||
liveness() {
|
liveness(@Res() response: Response) {
|
||||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
return response
|
||||||
|
.status(200)
|
||||||
|
.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("dependencies")
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: "Dependency-level health details" })
|
||||||
|
async dependencies(@Res() response: Response) {
|
||||||
|
const database = await this.getDatabaseHealth();
|
||||||
|
const aiEngine = await this.predictionsService.checkHealth();
|
||||||
|
|
||||||
|
return response.status(200).json({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checks: {
|
||||||
|
database,
|
||||||
|
aiEngine,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDatabaseHealth() {
|
||||||
|
try {
|
||||||
|
await this.prisma.$queryRaw`SELECT 1`;
|
||||||
|
return {
|
||||||
|
status: "up",
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
status: "down",
|
||||||
|
detail: error instanceof Error ? error.message : "Unknown database error",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { TerminusModule } from '@nestjs/terminus';
|
import { HealthController } from "./health.controller";
|
||||||
import { PrismaHealthIndicator } from '@nestjs/terminus';
|
import { PredictionsModule } from "../predictions/predictions.module";
|
||||||
import { HealthController } from './health.controller';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TerminusModule],
|
imports: [PredictionsModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [PrismaHealthIndicator],
|
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
|||||||
@@ -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,21 @@
|
|||||||
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";
|
||||||
|
import {
|
||||||
|
FINISHED_STATE_VALUES_FOR_DB,
|
||||||
|
FINISHED_STATUS_VALUES_FOR_DB,
|
||||||
|
LIVE_STATE_VALUES_FOR_DB,
|
||||||
|
LIVE_STATUS_VALUES_FOR_DB,
|
||||||
|
getDisplayMatchStatus,
|
||||||
|
} from "../../common/utils/match-status.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MatchesService {
|
export class MatchesService {
|
||||||
@@ -21,9 +28,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.`,
|
||||||
);
|
);
|
||||||
@@ -38,23 +45,12 @@ export class MatchesService {
|
|||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
status: {
|
status: {
|
||||||
in: [
|
in: LIVE_STATUS_VALUES_FOR_DB,
|
||||||
'LIVE',
|
|
||||||
'1H',
|
|
||||||
'2H',
|
|
||||||
'HT',
|
|
||||||
'1Q',
|
|
||||||
'2Q',
|
|
||||||
'3Q',
|
|
||||||
'4Q',
|
|
||||||
'Playing',
|
|
||||||
'Half Time',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
state: {
|
state: {
|
||||||
in: ['live', 'firsthalf', 'secondhalf'],
|
in: LIVE_STATE_VALUES_FOR_DB,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -66,14 +62,23 @@ export class MatchesService {
|
|||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
status: {
|
status: {
|
||||||
in: ['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'],
|
in: FINISHED_STATUS_VALUES_FOR_DB,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
state: {
|
state: {
|
||||||
in: ['Finished', 'post', 'FT', 'postGame'],
|
in: FINISHED_STATE_VALUES_FOR_DB,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{ scoreHome: { not: null } },
|
||||||
|
{ scoreAway: { not: null } },
|
||||||
|
{
|
||||||
|
NOT: this.getLiveFilter(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -134,16 +139,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 +175,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 +202,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 +225,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 +288,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 +311,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,16 +330,13 @@ export class MatchesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map status for frontend
|
// Map status for frontend
|
||||||
let displayStatus = match.status || 'NS';
|
const displayStatus = getDisplayMatchStatus({
|
||||||
if (match.state === 'live') {
|
state: match.state,
|
||||||
displayStatus = 'LIVE';
|
status: match.status,
|
||||||
} else if (
|
substate: match.substate,
|
||||||
match.state === 'post' ||
|
scoreHome: match.scoreHome,
|
||||||
match.state === 'FT' ||
|
scoreAway: match.scoreAway,
|
||||||
match.status === 'Finished'
|
});
|
||||||
) {
|
|
||||||
displayStatus = 'Finished';
|
|
||||||
}
|
|
||||||
|
|
||||||
league.matches.push({
|
league.matches.push({
|
||||||
id: match.id,
|
id: match.id,
|
||||||
@@ -349,11 +351,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 +392,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 +412,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 +441,7 @@ export class MatchesService {
|
|||||||
include: { country: true },
|
include: { country: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { mstUtc: 'desc' },
|
orderBy: { mstUtc: "desc" },
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
}),
|
}),
|
||||||
@@ -482,7 +484,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 +534,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 +542,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,16 +564,13 @@ export class MatchesService {
|
|||||||
|
|
||||||
if (liveMatch) {
|
if (liveMatch) {
|
||||||
// Map liveMatch status
|
// Map liveMatch status
|
||||||
let displayStatus = liveMatch.status || 'NS';
|
const displayStatus = getDisplayMatchStatus({
|
||||||
if (liveMatch.state === 'live') {
|
state: liveMatch.state,
|
||||||
displayStatus = 'LIVE';
|
status: liveMatch.status,
|
||||||
} else if (
|
substate: liveMatch.substate,
|
||||||
liveMatch.state === 'post' ||
|
scoreHome: liveMatch.scoreHome,
|
||||||
liveMatch.state === 'FT' ||
|
scoreAway: liveMatch.scoreAway,
|
||||||
liveMatch.status === 'Finished'
|
});
|
||||||
) {
|
|
||||||
displayStatus = 'Finished';
|
|
||||||
}
|
|
||||||
|
|
||||||
match = {
|
match = {
|
||||||
...liveMatch,
|
...liveMatch,
|
||||||
@@ -607,14 +606,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 +627,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 +636,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 +691,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;
|
||||||
@@ -466,6 +461,21 @@ export class AIHealthDto {
|
|||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
predictionServiceReady: boolean;
|
predictionServiceReady: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: true })
|
||||||
|
aiEngineReachable?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, enum: ["closed", "open"] })
|
||||||
|
circuitState?: "closed" | "open";
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: 0 })
|
||||||
|
consecutiveFailures?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
endpoint?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
detail?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,29 @@ 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 { 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';
|
import {
|
||||||
|
AiEngineClient,
|
||||||
|
AiEngineRequestError,
|
||||||
|
} from "../../common/utils/ai-engine-client";
|
||||||
|
|
||||||
type ConfidenceBand = 'HIGH' | 'MEDIUM' | 'LOW';
|
type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW";
|
||||||
|
|
||||||
interface ConfidenceInterval {
|
interface ConfidenceInterval {
|
||||||
lower: number;
|
lower: number;
|
||||||
@@ -45,75 +48,75 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly logger = new Logger(PredictionsService.name);
|
private readonly logger = new Logger(PredictionsService.name);
|
||||||
private queueEvents: QueueEvents | null = null;
|
private queueEvents: QueueEvents | null = null;
|
||||||
private readonly aiEngineUrl: string;
|
private readonly aiEngineUrl: string;
|
||||||
|
private readonly aiEngineClient: AiEngineClient;
|
||||||
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,9 +126,17 @@ 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.aiEngineClient = new AiEngineClient({
|
||||||
|
baseUrl: this.aiEngineUrl,
|
||||||
|
logger: this.logger,
|
||||||
|
serviceName: PredictionsService.name,
|
||||||
|
timeoutMs: 60000,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryDelayMs: 750,
|
||||||
|
});
|
||||||
this.topLeagueIds = this.loadTopLeagueIds();
|
this.topLeagueIds = this.loadTopLeagueIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,14 +144,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)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,12 +161,50 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkHealth(): Promise<AIHealthDto> {
|
async checkHealth(): Promise<AIHealthDto> {
|
||||||
return Promise.resolve({
|
const circuit = this.aiEngineClient.getSnapshot();
|
||||||
status: 'healthy',
|
|
||||||
modelLoaded: true,
|
try {
|
||||||
predictionServiceReady: true,
|
const response = await this.aiEngineClient.get<{
|
||||||
|
status?: string;
|
||||||
|
model_loaded?: boolean;
|
||||||
|
prediction_service_ready?: boolean;
|
||||||
|
}>("/health", {
|
||||||
|
timeout: 5000,
|
||||||
|
retryCount: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: response.data?.status || "healthy",
|
||||||
|
modelLoaded: response.data?.model_loaded ?? true,
|
||||||
|
predictionServiceReady:
|
||||||
|
response.data?.prediction_service_ready ?? true,
|
||||||
|
aiEngineReachable: true,
|
||||||
|
circuitState: circuit.state,
|
||||||
|
consecutiveFailures: circuit.consecutiveFailures,
|
||||||
|
endpoint: this.aiEngineUrl,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const requestError =
|
||||||
|
error instanceof AiEngineRequestError
|
||||||
|
? error
|
||||||
|
: new AiEngineRequestError("AI health check failed");
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: requestError.isCircuitOpen ? "circuit_open" : "unhealthy",
|
||||||
|
modelLoaded: false,
|
||||||
|
predictionServiceReady: false,
|
||||||
|
aiEngineReachable: false,
|
||||||
|
circuitState: this.aiEngineClient.getSnapshot().state,
|
||||||
|
consecutiveFailures:
|
||||||
|
this.aiEngineClient.getSnapshot().consecutiveFailures,
|
||||||
|
endpoint: this.aiEngineUrl,
|
||||||
|
detail:
|
||||||
|
typeof requestError.detail === "string"
|
||||||
|
? requestError.detail
|
||||||
|
: requestError.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPredictionById(matchId: string): Promise<MatchPredictionDto | null> {
|
async getPredictionById(matchId: string): Promise<MatchPredictionDto | null> {
|
||||||
@@ -183,22 +232,21 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
// Direct HTTP mode (no Redis)
|
// Direct HTTP mode (no Redis)
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(
|
const response = await this.aiEngineClient.post<MatchPredictionDto>(
|
||||||
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
|
`/v20plus/analyze/${matchId}`,
|
||||||
{},
|
{},
|
||||||
{ timeout: 60000 },
|
|
||||||
);
|
);
|
||||||
return this.enrichPredictionResponse(
|
return this.enrichPredictionResponse(
|
||||||
response.data as MatchPredictionDto,
|
response.data as MatchPredictionDto,
|
||||||
matchContext,
|
matchContext,
|
||||||
);
|
);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const error = e as AxiosError<Record<string, unknown>>;
|
const requestError =
|
||||||
const status = error?.response?.status;
|
e instanceof AiEngineRequestError
|
||||||
const detail =
|
? e
|
||||||
error?.response?.data?.detail ||
|
: new AiEngineRequestError("AI Engine request failed");
|
||||||
error?.response?.data ||
|
const status = requestError.status;
|
||||||
error?.message;
|
const detail = requestError.detail || requestError.message;
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Direct AI Engine call failed for ${matchId}: status=${status}, detail=${JSON.stringify(detail)}`,
|
`Direct AI Engine call failed for ${matchId}: status=${status}, detail=${JSON.stringify(detail)}`,
|
||||||
);
|
);
|
||||||
@@ -212,12 +260,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 +280,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 +299,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 +308,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 +324,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 +335,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 +366,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 +377,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 +394,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 +418,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
|
||||||
|
.map((pick) =>
|
||||||
this.enrichPick(pick, response, matchContext, marketBoard),
|
this.enrichPick(pick, response, matchContext, marketBoard),
|
||||||
).filter((pick): pick is NonNullable<typeof pick> => pick !== null)
|
)
|
||||||
|
.filter((pick): pick is NonNullable<typeof pick> => pick !== null)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const betSummary = Array.isArray(response.bet_summary)
|
const betSummary = Array.isArray(response.bet_summary)
|
||||||
@@ -380,8 +431,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 +452,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 +466,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 +478,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 +500,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 +517,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 +526,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 +544,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 +561,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 +592,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 +601,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 +613,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 +627,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 +648,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 +675,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 +712,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 +726,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 +746,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 +806,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 +820,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 +848,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 +917,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 +948,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 +958,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 +977,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 +992,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 +1014,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);
|
||||||
@@ -982,14 +1037,18 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
// Direct HTTP mode
|
// Direct HTTP mode
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(
|
const response = await this.aiEngineClient.post(
|
||||||
`${this.aiEngineUrl}/smart-coupon`,
|
"/smart-coupon",
|
||||||
{ match_ids: matchIds, strategy, ...options },
|
{ match_ids: matchIds, strategy, ...options },
|
||||||
{ timeout: 60000 },
|
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message =
|
||||||
|
error instanceof AiEngineRequestError
|
||||||
|
? error.message
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: String(error);
|
||||||
this.logger.error(`Direct smart coupon call failed: ${message}`);
|
this.logger.error(`Direct smart coupon call failed: ${message}`);
|
||||||
this.throwAiError(message);
|
this.throwAiError(message);
|
||||||
}
|
}
|
||||||
@@ -997,23 +1056,29 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (message.includes("circuit breaker is open")) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
'Failed to get prediction from AI Engine',
|
"AI Engine is temporarily unavailable",
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new HttpException(
|
||||||
|
"Failed to get prediction from AI Engine",
|
||||||
HttpStatus.SERVICE_UNAVAILABLE,
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1066,12 +1131,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 +1147,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 +1187,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 +1211,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("superadmin")
|
||||||
@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("superadmin")
|
||||||
@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("superadmin")
|
||||||
@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("superadmin")
|
||||||
@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("superadmin")
|
||||||
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("superadmin")
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user