cron #1

Merged
fahricansecer merged 3 commits from cron into main 2026-04-16 17:22:37 +03:00
116 changed files with 3720 additions and 4197 deletions
Showing only changes of commit c8e7e4e927 - Show all commits
-26
View File
@@ -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
+6 -6
View File
@@ -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!");
}); });
}); });
}); });
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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!";
} }
} }
+29 -29
View File
@@ -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(
+4 -4
View File
@@ -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
+2 -2
View File
@@ -1,2 +1,2 @@
export * from './base.service'; export * from "./base.service";
export * from './base.controller'; export * from "./base.controller";
+5 -5
View File
@@ -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";
}, },
); );
+15 -15
View File
@@ -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" };
} }
} }
+15 -15
View File
@@ -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,
+26 -26
View File
@@ -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, "");
} }
} }
+7 -7
View File
@@ -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,
+2 -2
View File
@@ -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);
+9 -9
View File
@@ -1,10 +1,10 @@
import { existsSync, createWriteStream, mkdirSync } from 'fs'; import { existsSync, createWriteStream, mkdirSync } from "fs";
import { dirname } from 'path'; import { dirname } from "path";
import axios from 'axios'; import axios from "axios";
import { Logger } from '@nestjs/common'; import { Logger } from "@nestjs/common";
export class ImageUtils { export class ImageUtils {
private static readonly logger = new Logger('ImageUtils'); private static readonly logger = new Logger("ImageUtils");
/** /**
* Downloads an image from a URL and saves it to a local path. * Downloads an image from a URL and saves it to a local path.
@@ -26,8 +26,8 @@ export class ImageUtils {
// Download // Download
const response = await axios({ const response = await axios({
url, url,
method: 'GET', method: "GET",
responseType: 'stream', responseType: "stream",
timeout: 5000, timeout: 5000,
validateStatus: (status) => status === 200, // Only save if 200 OK validateStatus: (status) => status === 200, // Only save if 200 OK
}); });
@@ -37,8 +37,8 @@ export class ImageUtils {
response.data.pipe(writer); response.data.pipe(writer);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
writer.on('finish', () => resolve(true)); writer.on("finish", () => resolve(true));
writer.on('error', (err) => { writer.on("error", (err) => {
this.logger.warn( this.logger.warn(
`Failed to write image to ${localPath}: ${err.message}`, `Failed to write image to ${localPath}: ${err.message}`,
); );
+29 -29
View File
@@ -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),
})); }));
+20 -20
View File
@@ -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;
+2 -2
View File
@@ -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({
+9 -9
View File
@@ -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
View File
@@ -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");
} }
} }
+57 -57
View File
@@ -10,32 +10,32 @@ import {
UseInterceptors, UseInterceptors,
Inject, Inject,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from "@nestjs/common";
import { import {
CacheInterceptor, CacheInterceptor,
CacheKey, CacheKey,
CacheTTL, CacheTTL,
CACHE_MANAGER, CACHE_MANAGER,
} from '@nestjs/cache-manager'; } from "@nestjs/cache-manager";
import * as cacheManager from 'cache-manager'; import * as cacheManager from "cache-manager";
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation } from "@nestjs/swagger";
import { Roles } from '../../common/decorators'; import { Roles } from "../../common/decorators";
import { PrismaService } from '../../database/prisma.service'; import { PrismaService } from "../../database/prisma.service";
import { PaginationDto } from '../../common/dto/pagination.dto'; import { PaginationDto } from "../../common/dto/pagination.dto";
import { import {
ApiResponse, ApiResponse,
createSuccessResponse, createSuccessResponse,
createPaginatedResponse, createPaginatedResponse,
PaginatedData, PaginatedData,
} from '../../common/types/api-response.type'; } from "../../common/types/api-response.type";
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from "class-transformer";
import { UserResponseDto } from '../users/dto/user.dto'; import { UserResponseDto } from "../users/dto/user.dto";
import { UserRole } from '@prisma/client'; import { UserRole } from "@prisma/client";
@ApiTags('Admin') @ApiTags("Admin")
@ApiBearerAuth() @ApiBearerAuth()
@Controller('admin') @Controller("admin")
@Roles('superadmin') @Roles("superadmin")
export class AdminController { export class AdminController {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@@ -44,8 +44,8 @@ export class AdminController {
// ================== Users Management ================== // ================== Users Management ==================
@Get('users') @Get("users")
@ApiOperation({ summary: 'Get all users (admin)' }) @ApiOperation({ summary: "Get all users (admin)" })
async getAllUsers( async getAllUsers(
@Query() pagination: PaginationDto, @Query() pagination: PaginationDto,
): Promise<ApiResponse<PaginatedData<UserResponseDto>>> { ): Promise<ApiResponse<PaginatedData<UserResponseDto>>> {
@@ -73,10 +73,10 @@ export class AdminController {
); );
} }
@Get('users/:id') @Get("users/:id")
@ApiOperation({ summary: 'Get user by ID' }) @ApiOperation({ summary: "Get user by ID" })
async getUserById( async getUserById(
@Param('id') id: string, @Param("id") id: string,
): Promise<ApiResponse<UserResponseDto>> { ): Promise<ApiResponse<UserResponseDto>> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { id }, where: { id },
@@ -84,27 +84,27 @@ export class AdminController {
usageLimit: true, usageLimit: true,
analyses: { analyses: {
take: 5, take: 5,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
}, },
}, },
}); });
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new NotFoundException("User not found");
} }
return createSuccessResponse(plainToInstance(UserResponseDto, user)); return createSuccessResponse(plainToInstance(UserResponseDto, user));
} }
@Put('users/:id/toggle-active') @Put("users/:id/toggle-active")
@ApiOperation({ summary: 'Toggle user active status' }) @ApiOperation({ summary: "Toggle user active status" })
async toggleUserActive( async toggleUserActive(
@Param('id') id: string, @Param("id") id: string,
): Promise<ApiResponse<UserResponseDto>> { ): Promise<ApiResponse<UserResponseDto>> {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new NotFoundException("User not found");
} }
const updated = await this.prisma.user.update({ const updated = await this.prisma.user.update({
@@ -114,14 +114,14 @@ export class AdminController {
return createSuccessResponse( return createSuccessResponse(
plainToInstance(UserResponseDto, updated), plainToInstance(UserResponseDto, updated),
'User status updated', "User status updated",
); );
} }
@Put('users/:id/role') @Put("users/:id/role")
@ApiOperation({ summary: 'Update user role' }) @ApiOperation({ summary: "Update user role" })
async updateUserRole( async updateUserRole(
@Param('id') id: string, @Param("id") id: string,
@Body() data: { role: UserRole }, @Body() data: { role: UserRole },
): Promise<ApiResponse<UserResponseDto>> { ): Promise<ApiResponse<UserResponseDto>> {
const user = await this.prisma.user.update({ const user = await this.prisma.user.update({
@@ -131,14 +131,14 @@ export class AdminController {
return createSuccessResponse( return createSuccessResponse(
plainToInstance(UserResponseDto, user), plainToInstance(UserResponseDto, user),
'User role updated', "User role updated",
); );
} }
@Put('users/:id/subscription') @Put("users/:id/subscription")
@ApiOperation({ summary: 'Update user subscription' }) @ApiOperation({ summary: "Update user subscription" })
async updateUserSubscription( async updateUserSubscription(
@Param('id') id: string, @Param("id") id: string,
@Body() @Body()
data: { subscriptionStatus: string; subscriptionExpiresAt?: string }, data: { subscriptionStatus: string; subscriptionExpiresAt?: string },
): Promise<ApiResponse<UserResponseDto>> { ): Promise<ApiResponse<UserResponseDto>> {
@@ -154,40 +154,40 @@ export class AdminController {
return createSuccessResponse( return createSuccessResponse(
plainToInstance(UserResponseDto, user), plainToInstance(UserResponseDto, user),
'User subscription updated', "User subscription updated",
); );
} }
@Delete('users/:id') @Delete("users/:id")
@ApiOperation({ summary: 'Soft delete a user' }) @ApiOperation({ summary: "Soft delete a user" })
async deleteUser(@Param('id') id: string): Promise<ApiResponse<null>> { async deleteUser(@Param("id") id: string): Promise<ApiResponse<null>> {
await this.prisma.user.update({ await this.prisma.user.update({
where: { id }, where: { id },
data: { deletedAt: new Date() }, data: { deletedAt: new Date() },
}); });
return createSuccessResponse(null, 'User deleted'); return createSuccessResponse(null, "User deleted");
} }
// ================== App Settings ================== // ================== App Settings ==================
@Get('settings') @Get("settings")
@UseInterceptors(CacheInterceptor) @UseInterceptors(CacheInterceptor)
@CacheKey('app_settings') @CacheKey("app_settings")
@CacheTTL(60 * 1000) @CacheTTL(60 * 1000)
@ApiOperation({ summary: 'Get all app settings' }) @ApiOperation({ summary: "Get all app settings" })
async getAllSettings(): Promise<ApiResponse<Record<string, string>>> { async getAllSettings(): Promise<ApiResponse<Record<string, string>>> {
const settings = await this.prisma.appSetting.findMany(); const settings = await this.prisma.appSetting.findMany();
const settingsMap: Record<string, string> = {}; const settingsMap: Record<string, string> = {};
for (const s of settings) { for (const s of settings) {
settingsMap[s.key] = s.value || ''; settingsMap[s.key] = s.value || "";
} }
return createSuccessResponse(settingsMap); return createSuccessResponse(settingsMap);
} }
@Put('settings/:key') @Put("settings/:key")
@ApiOperation({ summary: 'Update an app setting' }) @ApiOperation({ summary: "Update an app setting" })
async updateSetting( async updateSetting(
@Param('key') key: string, @Param("key") key: string,
@Body() data: { value: string }, @Body() data: { value: string },
): Promise<ApiResponse<{ key: string; value: string }>> { ): Promise<ApiResponse<{ key: string; value: string }>> {
const setting = await this.prisma.appSetting.upsert({ const setting = await this.prisma.appSetting.upsert({
@@ -195,17 +195,17 @@ export class AdminController {
update: { value: data.value }, update: { value: data.value },
create: { key, value: data.value }, create: { key, value: data.value },
}); });
await this.cacheManager.del('app_settings'); await this.cacheManager.del("app_settings");
return createSuccessResponse( return createSuccessResponse(
{ key: setting.key, value: setting.value || '' }, { key: setting.key, value: setting.value || "" },
'Setting updated', "Setting updated",
); );
} }
// ================== Usage Limits ================== // ================== Usage Limits ==================
@Get('usage-limits') @Get("usage-limits")
@ApiOperation({ summary: 'Get all usage limits' }) @ApiOperation({ summary: "Get all usage limits" })
async getAllUsageLimits(@Query() pagination: PaginationDto) { async getAllUsageLimits(@Query() pagination: PaginationDto) {
const { skip, take } = pagination; const { skip, take } = pagination;
@@ -218,7 +218,7 @@ export class AdminController {
select: { id: true, email: true, firstName: true, lastName: true }, select: { id: true, email: true, firstName: true, lastName: true },
}, },
}, },
orderBy: { lastResetDate: 'desc' }, orderBy: { lastResetDate: "desc" },
}), }),
this.prisma.usageLimit.count(), this.prisma.usageLimit.count(),
]); ]);
@@ -231,8 +231,8 @@ export class AdminController {
); );
} }
@Post('usage-limits/reset-all') @Post("usage-limits/reset-all")
@ApiOperation({ summary: 'Reset all usage limits' }) @ApiOperation({ summary: "Reset all usage limits" })
async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> { async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> {
const result = await this.prisma.usageLimit.updateMany({ const result = await this.prisma.usageLimit.updateMany({
data: { data: {
@@ -244,14 +244,14 @@ export class AdminController {
return createSuccessResponse( return createSuccessResponse(
{ count: result.count }, { count: result.count },
'All usage limits reset', "All usage limits reset",
); );
} }
// ================== Analytics ================== // ================== Analytics ==================
@Get('analytics/overview') @Get("analytics/overview")
@ApiOperation({ summary: 'Get system analytics overview' }) @ApiOperation({ summary: "Get system analytics overview" })
async getAnalyticsOverview() { async getAnalyticsOverview() {
const [ const [
totalUsers, totalUsers,
@@ -262,7 +262,7 @@ export class AdminController {
] = await Promise.all([ ] = await Promise.all([
this.prisma.user.count(), this.prisma.user.count(),
this.prisma.user.count({ where: { isActive: true } }), this.prisma.user.count({ where: { isActive: true } }),
this.prisma.user.count({ where: { subscriptionStatus: 'active' } }), this.prisma.user.count({ where: { subscriptionStatus: "active" } }),
this.prisma.match.count(), this.prisma.match.count(),
this.prisma.prediction.count(), this.prisma.prediction.count(),
]); ]);
+2 -2
View File
@@ -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 -1
View File
@@ -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 {
+19 -19
View File
@@ -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 };
+5 -5
View File
@@ -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],
+7 -7
View File
@@ -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,
}) })
+25 -25
View File
@@ -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"));
} }
} }
+11 -11
View File
@@ -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,
}, },
+21 -21
View File
@@ -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;
+8 -8
View File
@@ -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;
} }
+14 -14
View File
@@ -4,15 +4,15 @@ import {
ExecutionContext, ExecutionContext,
UnauthorizedException, UnauthorizedException,
ForbiddenException, ForbiddenException,
} from '@nestjs/common'; } from "@nestjs/common";
import { Reflector } from '@nestjs/core'; import { Reflector } from "@nestjs/core";
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from "@nestjs/passport";
import { Request } from 'express'; import { Request } from "express";
import { import {
IS_PUBLIC_KEY, IS_PUBLIC_KEY,
ROLES_KEY, ROLES_KEY,
PERMISSIONS_KEY, PERMISSIONS_KEY,
} from '../../../common/decorators'; } from "../../../common/decorators";
interface AuthenticatedUser { interface AuthenticatedUser {
id: string; id: string;
@@ -25,14 +25,14 @@ interface AuthenticatedUser {
* JWT Auth Guard - Validates JWT token * JWT Auth Guard - Validates JWT token
*/ */
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private reflector: Reflector) { constructor(private reflector: Reflector) {
super(); super();
} }
canActivate(context: ExecutionContext) { canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<Request>(); const request = context.switchToHttp().getRequest<Request>();
if (request?.method === 'OPTIONS') { if (request?.method === "OPTIONS") {
return true; return true;
} }
@@ -55,10 +55,10 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
info: any, info: any,
): TUser { ): TUser {
if (err || !user) { if (err || !user) {
if (info?.name === 'TokenExpiredError') { if (info?.name === "TokenExpiredError") {
throw new UnauthorizedException('TOKEN_EXPIRED'); throw new UnauthorizedException("TOKEN_EXPIRED");
} }
throw err || new UnauthorizedException('AUTH_REQUIRED'); throw err || new UnauthorizedException("AUTH_REQUIRED");
} }
return user; return user;
} }
@@ -73,7 +73,7 @@ export class RolesGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>(); const req = context.switchToHttp().getRequest<Request>();
if (req?.method === 'OPTIONS') { if (req?.method === "OPTIONS") {
return true; return true;
} }
@@ -94,7 +94,7 @@ export class RolesGuard implements CanActivate {
const hasRole = requiredRoles.some((role) => user.roles.includes(role)); const hasRole = requiredRoles.some((role) => user.roles.includes(role));
if (!hasRole) { if (!hasRole) {
throw new ForbiddenException('PERMISSION_DENIED'); throw new ForbiddenException("PERMISSION_DENIED");
} }
return true; return true;
@@ -110,7 +110,7 @@ export class PermissionsGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>(); const req = context.switchToHttp().getRequest<Request>();
if (req?.method === 'OPTIONS') { if (req?.method === "OPTIONS") {
return true; return true;
} }
@@ -134,7 +134,7 @@ export class PermissionsGuard implements CanActivate {
); );
if (!hasPermission) { if (!hasPermission) {
throw new ForbiddenException('PERMISSION_DENIED'); throw new ForbiddenException("PERMISSION_DENIED");
} }
return true; return true;
+1 -1
View File
@@ -1 +1 @@
export * from './auth.guards'; export * from "./auth.guards";
+7 -7
View File
@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from "@nestjs/common";
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from '@nestjs/config'; import { ConfigService } from "@nestjs/config";
import { AuthService, JwtPayload } from '../auth.service'; import { AuthService, JwtPayload } from "../auth.service";
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -10,9 +10,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly authService: AuthService, private readonly authService: AuthService,
) { ) {
const secret = configService.get<string>('JWT_SECRET'); const secret = configService.get<string>("JWT_SECRET");
if (!secret) { if (!secret) {
throw new Error('JWT_SECRET is not defined'); throw new Error("JWT_SECRET is not defined");
} }
super({ super({
+36 -36
View File
@@ -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,
+8 -8
View File
@@ -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],
+4 -4
View File
@@ -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;
+14 -14
View File
@@ -8,19 +8,19 @@ import {
ArrayMaxSize, ArrayMaxSize,
Min, Min,
Max, Max,
} from 'class-validator'; } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export enum CouponStrategyEnum { export enum CouponStrategyEnum {
SAFE = 'SAFE', SAFE = "SAFE",
BALANCED = 'BALANCED', BALANCED = "BALANCED",
AGGRESSIVE = 'AGGRESSIVE', AGGRESSIVE = "AGGRESSIVE",
VALUE = 'VALUE', VALUE = "VALUE",
MIRACLE = 'MIRACLE', MIRACLE = "MIRACLE",
} }
export class AnalyzeMatchDto { export class AnalyzeMatchDto {
@ApiProperty({ description: 'Match ID to analyze' }) @ApiProperty({ description: "Match ID to analyze" })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
matchId: string; matchId: string;
@@ -28,8 +28,8 @@ export class AnalyzeMatchDto {
export class DailyBankoDto { export class DailyBankoDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Optional match IDs — system fetches if empty', description: "Optional match IDs — system fetches if empty",
example: ['match-1', 'match-2'], example: ["match-1", "match-2"],
}) })
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@@ -40,8 +40,8 @@ export class DailyBankoDto {
export class SuggestCouponDto { export class SuggestCouponDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Match IDs — system fetches if empty', description: "Match IDs — system fetches if empty",
example: ['match-1', 'match-2'], example: ["match-1", "match-2"],
}) })
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@@ -57,7 +57,7 @@ export class SuggestCouponDto {
@IsEnum(CouponStrategyEnum) @IsEnum(CouponStrategyEnum)
strategy?: CouponStrategyEnum; strategy?: CouponStrategyEnum;
@ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 }) @ApiPropertyOptional({ description: "Maximum matches in coupon", example: 5 })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@@ -65,7 +65,7 @@ export class SuggestCouponDto {
maxMatches?: number; maxMatches?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Minimum confidence threshold (0-100)', description: "Minimum confidence threshold (0-100)",
example: 60, example: 60,
}) })
@IsOptional() @IsOptional()
@@ -1,10 +1,10 @@
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
import axios from 'axios'; import axios from "axios";
import { GeminiService } from '../../gemini/gemini.service'; import { GeminiService } from "../../gemini/gemini.service";
export type PredictionRiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME'; export type PredictionRiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
export type PredictionDataQuality = 'HIGH' | 'MEDIUM' | 'LOW'; export type PredictionDataQuality = "HIGH" | "MEDIUM" | "LOW";
export type BetGrade = 'A' | 'B' | 'C' | 'PASS'; export type BetGrade = "A" | "B" | "C" | "PASS";
export interface PredictionPickRow { export interface PredictionPickRow {
market: string; market: string;
@@ -128,7 +128,7 @@ export class SmartCouponService {
private readonly aiEngineUrl: string; private readonly aiEngineUrl: string;
constructor(private readonly geminiService: GeminiService) { constructor(private readonly geminiService: GeminiService) {
this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000'; this.aiEngineUrl = process.env.AI_ENGINE_URL || "http://ai-engine:8000";
} }
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> { async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
@@ -147,7 +147,7 @@ export class SmartCouponService {
); );
} }
throw new HttpException( throw new HttpException(
'AI analyze failed', "AI analyze failed",
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
); );
} }
@@ -168,7 +168,7 @@ export class SmartCouponService {
const result = await this.geminiService.generateText( const result = await this.geminiService.generateText(
JSON.stringify(prediction, null, 2), JSON.stringify(prediction, null, 2),
{ {
model: 'gemini-2.0-flash', model: "gemini-2.0-flash",
temperature: 0.7, temperature: 0.7,
maxTokens: 600, maxTokens: 600,
systemPrompt: MATCH_COMMENTARY_SYSTEM_PROMPT, systemPrompt: MATCH_COMMENTARY_SYSTEM_PROMPT,
@@ -176,7 +176,7 @@ export class SmartCouponService {
); );
return result.text || null; return result.text || null;
} catch (error) { } catch (error) {
this.logger.warn('AI commentary generation failed, skipping', error); this.logger.warn("AI commentary generation failed, skipping", error);
return null; return null;
} }
} }
@@ -188,7 +188,7 @@ export class SmartCouponService {
return null; return null;
} }
return this.getSmartCoupon(matchIds, 'SAFE', { return this.getSmartCoupon(matchIds, "SAFE", {
maxMatches: 2, maxMatches: 2,
minConfidence: 78, minConfidence: 78,
}); });
@@ -197,11 +197,11 @@ export class SmartCouponService {
async getSmartCoupon( async getSmartCoupon(
matchIds: string[], matchIds: string[],
strategy: strategy:
| 'SAFE' | "SAFE"
| 'BALANCED' | "BALANCED"
| 'AGGRESSIVE' | "AGGRESSIVE"
| 'VALUE' | "VALUE"
| 'MIRACLE' = 'BALANCED', | "MIRACLE" = "BALANCED",
options: { maxMatches?: number; minConfidence?: number } = {}, options: { maxMatches?: number; minConfidence?: number } = {},
): Promise<SmartCouponResult> { ): Promise<SmartCouponResult> {
try { try {
@@ -216,7 +216,7 @@ export class SmartCouponService {
); );
return response.data; return response.data;
} catch (error) { } catch (error) {
this.logger.error('Failed to generate smart coupon', error); this.logger.error("Failed to generate smart coupon", error);
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const detail = error.response?.data?.detail || error.message; const detail = error.response?.data?.detail || error.message;
throw new HttpException( throw new HttpException(
@@ -225,7 +225,7 @@ export class SmartCouponService {
); );
} }
throw new HttpException( throw new HttpException(
'Coupon generation failed', "Coupon generation failed",
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
); );
} }
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from '../../../database/prisma.service'; import { PrismaService } from "../../../database/prisma.service";
import { User, UserCoupon, Match } from '@prisma/client'; import { User, UserCoupon, Match } from "@prisma/client";
export class CreateCouponDto { export class CreateCouponDto {
strategy: string; // 'SAFE', 'VALUE', 'CUSTOM' strategy: string; // 'SAFE', 'VALUE', 'CUSTOM'
@@ -39,7 +39,7 @@ export class UserCouponService {
strategy: dto.strategy, strategy: dto.strategy,
totalOdds: parseFloat(totalOdds.toFixed(2)), totalOdds: parseFloat(totalOdds.toFixed(2)),
isPublic: dto.isPublic || false, isPublic: dto.isPublic || false,
status: 'PENDING', status: "PENDING",
couponItems: { couponItems: {
create: dto.items.map((item) => ({ create: dto.items.map((item) => ({
matchId: item.matchId, matchId: item.matchId,
@@ -66,7 +66,7 @@ export class UserCouponService {
async updatePendingCoupons(): Promise<void> { async updatePendingCoupons(): Promise<void> {
// Sadece bitmiş (FT) maçları içeren PENDING kuponları çek // Sadece bitmiş (FT) maçları içeren PENDING kuponları çek
const pendingCoupons = await this.prisma.userCoupon.findMany({ const pendingCoupons = await this.prisma.userCoupon.findMany({
where: { status: 'PENDING' }, where: { status: "PENDING" },
include: { include: {
couponItems: { couponItems: {
include: { match: true }, include: { match: true },
@@ -80,7 +80,7 @@ export class UserCouponService {
let allMatchesFinished = true; let allMatchesFinished = true;
for (const item of coupon.couponItems) { for (const item of coupon.couponItems) {
if (item.match.status !== 'FT') { if (item.match.status !== "FT") {
allMatchesFinished = false; allMatchesFinished = false;
break; // Henüz bitmemiş maç var, kuponu güncelleme break; // Henüz bitmemiş maç var, kuponu güncelleme
} }
@@ -104,12 +104,12 @@ export class UserCouponService {
if (isCouponLost) { if (isCouponLost) {
await this.prisma.userCoupon.update({ await this.prisma.userCoupon.update({
where: { id: coupon.id }, where: { id: coupon.id },
data: { status: 'LOST' }, data: { status: "LOST" },
}); });
} else if (allMatchesFinished && isCouponWon) { } else if (allMatchesFinished && isCouponWon) {
await this.prisma.userCoupon.update({ await this.prisma.userCoupon.update({
where: { id: coupon.id }, where: { id: coupon.id },
data: { status: 'WON' }, data: { status: "WON" },
}); });
} }
} }
@@ -125,23 +125,23 @@ export class UserCouponService {
const total = home + away; const total = home + away;
switch (selection) { switch (selection) {
case 'MS 1': case "MS 1":
return home > away; return home > away;
case 'MS X': case "MS X":
return home === away; return home === away;
case 'MS 2': case "MS 2":
return away > home; return away > home;
case '1.5 UST': case "1.5 UST":
return total > 1.5; return total > 1.5;
case '2.5 UST': case "2.5 UST":
return total > 2.5; return total > 2.5;
case '3.5 UST': case "3.5 UST":
return total > 3.5; return total > 3.5;
case '2.5 ALT': case "2.5 ALT":
return total < 2.5; return total < 2.5;
case 'KG VAR': case "KG VAR":
return home > 0 && away > 0; return home > 0 && away > 0;
case 'KG YOK': case "KG YOK":
return home === 0 || away === 0; return home === 0 || away === 0;
default: default:
return false; // Bilinmeyen market return false; // Bilinmeyen market
@@ -155,7 +155,7 @@ export class UserCouponService {
const coupons = await this.prisma.userCoupon.findMany({ const coupons = await this.prisma.userCoupon.findMany({
where: { where: {
userId, userId,
status: { in: ['WON', 'LOST'] }, status: { in: ["WON", "LOST"] },
}, },
}); });
@@ -171,7 +171,7 @@ export class UserCouponService {
}; };
} }
const wonCoupons = coupons.filter((c) => c.status === 'WON'); const wonCoupons = coupons.filter((c) => c.status === "WON");
const totalInvested = totalCoupons; // Her kupona 1 birim yatırıldığını varsayıyoruz const totalInvested = totalCoupons; // Her kupona 1 birim yatırıldığını varsayıyoruz
const totalReturn = wonCoupons.reduce((acc, c) => acc + c.totalOdds, 0); const totalReturn = wonCoupons.reduce((acc, c) => acc + c.totalOdds, 0);
const winRate = (wonCoupons.length / totalCoupons) * 100; const winRate = (wonCoupons.length / totalCoupons) * 100;
@@ -5,8 +5,8 @@
* Database operations using Prisma * Database operations using Prisma
*/ */
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from '../../database/prisma.service'; import { PrismaService } from "../../database/prisma.service";
import { import {
Sport, Sport,
MatchSummary, MatchSummary,
@@ -20,8 +20,8 @@ import {
DbEventPayload, DbEventPayload,
DbMarketPayload, DbMarketPayload,
BasketballTeamStats, BasketballTeamStats,
} from './feeder.types'; } from "./feeder.types";
import { ImageUtils } from '../../common/utils/image.util'; import { ImageUtils } from "../../common/utils/image.util";
@Injectable() @Injectable()
export class FeederPersistenceService { export class FeederPersistenceService {
@@ -33,7 +33,7 @@ export class FeederPersistenceService {
// HELPER FUNCTIONS // HELPER FUNCTIONS
// ============================================ // ============================================
private safeString(value: any): string | null { private safeString(value: any): string | null {
return value === null || value === undefined || value === '' return value === null || value === undefined || value === ""
? null ? null
: String(value); : String(value);
} }
@@ -51,12 +51,12 @@ export class FeederPersistenceService {
private mapPositionToEnum(position: string | null): any { private mapPositionToEnum(position: string | null): any {
if (!position) return null; if (!position) return null;
const pos = position.toLowerCase(); const pos = position.toLowerCase();
if (pos.includes('kaleci') || pos.includes('goalkeeper')) if (pos.includes("kaleci") || pos.includes("goalkeeper"))
return 'goalkeeper'; return "goalkeeper";
if (pos.includes('defans') || pos.includes('defender')) return 'defender'; if (pos.includes("defans") || pos.includes("defender")) return "defender";
if (pos.includes('orta saha') || pos.includes('midfielder')) if (pos.includes("orta saha") || pos.includes("midfielder"))
return 'midfielder'; return "midfielder";
if (pos.includes('forvet') || pos.includes('striker')) return 'striker'; if (pos.includes("forvet") || pos.includes("striker")) return "striker";
return null; return null;
} }
@@ -93,7 +93,7 @@ export class FeederPersistenceService {
} }
for (const s of market.selectionCollection) { for (const s of market.selectionCollection) {
if (!s || s.odd === '-' || s.odd === '') continue; if (!s || s.odd === "-" || s.odd === "") continue;
const sName = this.safeString(s.name); const sName = this.safeString(s.name);
const sValue = this.safeString(s.odd); const sValue = this.safeString(s.odd);
@@ -107,7 +107,7 @@ export class FeederPersistenceService {
if (existingSel) { if (existingSel) {
if (existingSel.oddValue !== sValue) { if (existingSel.oddValue !== sValue) {
const oldVal = parseFloat(existingSel.oddValue || '0'); const oldVal = parseFloat(existingSel.oddValue || "0");
const newVal = parseFloat(sValue); const newVal = parseFloat(sValue);
if (!isNaN(oldVal) && !isNaN(newVal)) { if (!isNaN(oldVal) && !isNaN(newVal)) {
@@ -182,13 +182,13 @@ export class FeederPersistenceService {
const teamsToUpsert = [ const teamsToUpsert = [
{ {
id: homeTeamId, id: homeTeamId,
name: matchSummary.homeTeam?.name || 'Unknown', name: matchSummary.homeTeam?.name || "Unknown",
slug: matchSummary.homeTeam?.slug || homeTeamId, slug: matchSummary.homeTeam?.slug || homeTeamId,
sport: sport, sport: sport,
}, },
{ {
id: awayTeamId, id: awayTeamId,
name: matchSummary.awayTeam?.name || 'Unknown', name: matchSummary.awayTeam?.name || "Unknown",
slug: matchSummary.awayTeam?.slug || awayTeamId, slug: matchSummary.awayTeam?.slug || awayTeamId,
sport: sport, sport: sport,
}, },
@@ -221,18 +221,18 @@ export class FeederPersistenceService {
update: {}, update: {},
create: { create: {
id: countryId, id: countryId,
name: league.country.name || 'Unknown', name: league.country.name || "Unknown",
}, },
}); });
} catch (error: any) { } catch (error: any) {
if (error.code !== 'P2002') throw error; if (error.code !== "P2002") throw error;
} }
} }
// 2. Save League (Handle ID changes by checking unique constraint) // 2. Save League (Handle ID changes by checking unique constraint)
let finalLeagueId = this.safeString(league.id); let finalLeagueId = this.safeString(league.id);
if (finalLeagueId && countryId) { if (finalLeagueId && countryId) {
const leagueName = league.name || 'Unknown'; const leagueName = league.name || "Unknown";
// Check if league exists by unique constraint (name + country + sport) // Check if league exists by unique constraint (name + country + sport)
const existingLeague = await tx.league.findUnique({ const existingLeague = await tx.league.findUnique({
@@ -311,32 +311,32 @@ export class FeederPersistenceService {
headerData?.htScoreAway ?? headerData?.htScoreAway ??
this.safeInt(matchSummary.score?.ht?.away); this.safeInt(matchSummary.score?.ht?.away);
let status = 'NS'; let status = "NS";
if (headerData?.matchStatus) { if (headerData?.matchStatus) {
if ( if (
headerData.matchStatus === 'postGame' || headerData.matchStatus === "postGame" ||
headerData.matchStatus === 'post' headerData.matchStatus === "post"
) { ) {
status = 'FT'; status = "FT";
} else if ( } else if (
headerData.matchStatus === 'live' || headerData.matchStatus === "live" ||
headerData.matchStatus === 'liveGame' headerData.matchStatus === "liveGame"
) { ) {
status = 'LIVE'; status = "LIVE";
} }
} }
// Handle Postponed Matches (ERT) // Handle Postponed Matches (ERT)
if (matchSummary.statusBoxContent === 'ERT') { if (matchSummary.statusBoxContent === "ERT") {
status = 'POSTPONED'; status = "POSTPONED";
} }
if ( if (
status === 'NS' && status === "NS" &&
finalScoreHome !== null && finalScoreHome !== null &&
finalScoreAway !== null finalScoreAway !== null
) { ) {
status = 'FT'; status = "FT";
} }
await tx.match.upsert({ await tx.match.upsert({
@@ -455,7 +455,7 @@ export class FeederPersistenceService {
} }
// 8. Save Team Stats (Football) // 8. Save Team Stats (Football)
if (stats && sport === 'football') { if (stats && sport === "football") {
const statsRows = [ const statsRows = [
{ {
matchId, matchId,
@@ -499,7 +499,7 @@ export class FeederPersistenceService {
} }
// 8b. Save Team Stats (Basketball) // 8b. Save Team Stats (Basketball)
if (basketballTeamStats && sport === 'basketball') { if (basketballTeamStats && sport === "basketball") {
const teams = [ const teams = [
{ id: homeTeamId, data: basketballTeamStats.home }, { id: homeTeamId, data: basketballTeamStats.home },
{ id: awayTeamId, data: basketballTeamStats.away }, { id: awayTeamId, data: basketballTeamStats.away },
@@ -558,7 +558,7 @@ export class FeederPersistenceService {
} }
// 8c. Save Player Stats (Basketball) // 8c. Save Player Stats (Basketball)
if (basketballPlayerStats.length > 0 && sport === 'basketball') { if (basketballPlayerStats.length > 0 && sport === "basketball") {
await tx.basketballPlayerStats.deleteMany({ where: { matchId } }); await tx.basketballPlayerStats.deleteMany({ where: { matchId } });
for (const p of basketballPlayerStats) { for (const p of basketballPlayerStats) {
@@ -592,12 +592,12 @@ export class FeederPersistenceService {
await this.saveOddsInTransaction(tx, matchId, oddsArray); await this.saveOddsInTransaction(tx, matchId, oddsArray);
// 10. Save Officials // 10. Save Officials
if (sport === 'football' && officialsData.length > 0) { if (sport === "football" && officialsData.length > 0) {
await tx.matchOfficial.deleteMany({ where: { matchId } }); await tx.matchOfficial.deleteMany({ where: { matchId } });
const processedOfficials = new Set<string>(); const processedOfficials = new Set<string>();
for (const o of officialsData) { for (const o of officialsData) {
const roleName = o.role || 'Referee'; const roleName = o.role || "Referee";
const uniqueKey = `${o.name}_${roleName}`; const uniqueKey = `${o.name}_${roleName}`;
if (processedOfficials.has(uniqueKey)) continue; if (processedOfficials.has(uniqueKey)) continue;
@@ -798,10 +798,10 @@ export class FeederPersistenceService {
const history = await this.prisma.match.findMany({ const history = await this.prisma.match.findMany({
where: { where: {
OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }], OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }],
status: 'FT', status: "FT",
mstUtc: { lt: match.mstUtc }, mstUtc: { lt: match.mstUtc },
}, },
orderBy: { mstUtc: 'desc' }, orderBy: { mstUtc: "desc" },
take: 5, take: 5,
}); });
@@ -840,8 +840,8 @@ export class FeederPersistenceService {
return { return {
match_id: match.id, match_id: match.id,
home_team: match.homeTeam?.name || 'Unknown', home_team: match.homeTeam?.name || "Unknown",
away_team: match.awayTeam?.name || 'Unknown', away_team: match.awayTeam?.name || "Unknown",
home_team_id: match.homeTeamId, home_team_id: match.homeTeamId,
away_team_id: match.awayTeamId, away_team_id: match.awayTeamId,
league_id: match.leagueId, league_id: match.leagueId,
@@ -934,7 +934,7 @@ export class FeederPersistenceService {
scoreHome: liveMatch.scoreHome, scoreHome: liveMatch.scoreHome,
scoreAway: liveMatch.scoreAway, scoreAway: liveMatch.scoreAway,
mstUtc: liveMatch.mstUtc, mstUtc: liveMatch.mstUtc,
sport: liveMatch.sport || 'football', sport: liveMatch.sport || "football",
}; };
} }
+105 -105
View File
@@ -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()}`;
} }
} }
+6 -6
View File
@@ -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],
+100 -102
View File
@@ -3,10 +3,10 @@
* Main orchestration service for historical data scanning * Main orchestration service for historical data scanning
*/ */
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from "@nestjs/common";
import { FeederScraperService } from './feeder-scraper.service'; import { FeederScraperService } from "./feeder-scraper.service";
import { FeederTransformerService } from './feeder-transformer.service'; import { FeederTransformerService } from "./feeder-transformer.service";
import { FeederPersistenceService } from './feeder-persistence.service'; import { FeederPersistenceService } from "./feeder-persistence.service";
import { import {
Sport, Sport,
MatchSummary, MatchSummary,
@@ -23,7 +23,7 @@ import {
ParsedMarket, ParsedMarket,
DbEventPayload, DbEventPayload,
DbMarketPayload, DbMarketPayload,
} from './feeder.types'; } from "./feeder.types";
interface ProcessDateOptions { interface ProcessDateOptions {
onlyCompletedMatches?: boolean; onlyCompletedMatches?: boolean;
@@ -37,10 +37,10 @@ export class FeederService {
// Configuration - Adjust these based on rate limiting behavior // Configuration - Adjust these based on rate limiting behavior
private readonly CONCURRENCY_LIMIT = 20; // Increased for maximum speed on EC2 private readonly CONCURRENCY_LIMIT = 20; // Increased for maximum speed on EC2
private readonly REQUEST_DELAY_MS = 50; // Minimal delay to respect basics private readonly REQUEST_DELAY_MS = 50; // Minimal delay to respect basics
private readonly HISTORICAL_START_DATE = '2023-06-01'; // 2 years of data private readonly HISTORICAL_START_DATE = "2023-06-01"; // 2 years of data
private readonly SPORTS: Sport[] = ['football', 'basketball']; private readonly SPORTS: Sport[] = ["football", "basketball"];
private readonly MAX_RETRIES = 50; private readonly MAX_RETRIES = 50;
private readonly DAILY_SYNC_TIME_ZONE = 'Europe/Istanbul'; private readonly DAILY_SYNC_TIME_ZONE = "Europe/Istanbul";
constructor( constructor(
private readonly scraperService: FeederScraperService, private readonly scraperService: FeederScraperService,
@@ -56,38 +56,38 @@ export class FeederService {
} }
private getYesterdayDateString(timeZone: string): string { private getYesterdayDateString(timeZone: string): string {
const formatter = new Intl.DateTimeFormat('en-CA', { const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone, timeZone,
year: 'numeric', year: "numeric",
month: '2-digit', month: "2-digit",
day: '2-digit', day: "2-digit",
}); });
const parts = formatter.formatToParts(new Date()); const parts = formatter.formatToParts(new Date());
const year = Number(parts.find((part) => part.type === 'year')?.value); const year = Number(parts.find((part) => part.type === "year")?.value);
const month = Number(parts.find((part) => part.type === 'month')?.value); const month = Number(parts.find((part) => part.type === "month")?.value);
const day = Number(parts.find((part) => part.type === 'day')?.value); const day = Number(parts.find((part) => part.type === "day")?.value);
const tzMidnightUtc = new Date(Date.UTC(year, month - 1, day)); const tzMidnightUtc = new Date(Date.UTC(year, month - 1, day));
tzMidnightUtc.setUTCDate(tzMidnightUtc.getUTCDate() - 1); tzMidnightUtc.setUTCDate(tzMidnightUtc.getUTCDate() - 1);
return tzMidnightUtc.toISOString().split('T')[0]; return tzMidnightUtc.toISOString().split("T")[0];
} }
private getTimeZoneOffsetMs(date: Date, timeZone: string): number { private getTimeZoneOffsetMs(date: Date, timeZone: string): number {
const formatter = new Intl.DateTimeFormat('en-US', { const formatter = new Intl.DateTimeFormat("en-US", {
timeZone, timeZone,
timeZoneName: 'shortOffset', timeZoneName: "shortOffset",
}); });
const offsetLabel = const offsetLabel =
formatter.formatToParts(date).find((part) => part.type === 'timeZoneName') formatter.formatToParts(date).find((part) => part.type === "timeZoneName")
?.value || 'GMT+0'; ?.value || "GMT+0";
const match = offsetLabel.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/); const match = offsetLabel.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/);
if (!match) return 0; if (!match) return 0;
const sign = match[1] === '-' ? -1 : 1; const sign = match[1] === "-" ? -1 : 1;
const hours = Number(match[2] || '0'); const hours = Number(match[2] || "0");
const minutes = Number(match[3] || '0'); const minutes = Number(match[3] || "0");
return sign * (hours * 60 + minutes) * 60 * 1000; return sign * (hours * 60 + minutes) * 60 * 1000;
} }
@@ -96,17 +96,14 @@ export class FeederService {
dateString: string, dateString: string,
timeZone: string, timeZone: string,
): { startTs: number; endTs: number } { ): { startTs: number; endTs: number } {
const [year, month, day] = dateString.split('-').map(Number); const [year, month, day] = dateString.split("-").map(Number);
const startGuess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0)); const startGuess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
const nextDayGuess = new Date( const nextDayGuess = new Date(Date.UTC(year, month - 1, day + 1, 0, 0, 0));
Date.UTC(year, month - 1, day + 1, 0, 0, 0),
);
const startOffsetMs = this.getTimeZoneOffsetMs(startGuess, timeZone); const startOffsetMs = this.getTimeZoneOffsetMs(startGuess, timeZone);
const nextDayOffsetMs = this.getTimeZoneOffsetMs(nextDayGuess, timeZone); const nextDayOffsetMs = this.getTimeZoneOffsetMs(nextDayGuess, timeZone);
const startMs = const startMs = Date.UTC(year, month - 1, day, 0, 0, 0) - startOffsetMs;
Date.UTC(year, month - 1, day, 0, 0, 0) - startOffsetMs;
const nextDayStartMs = const nextDayStartMs =
Date.UTC(year, month - 1, day + 1, 0, 0, 0) - nextDayOffsetMs; Date.UTC(year, month - 1, day + 1, 0, 0, 0) - nextDayOffsetMs;
@@ -117,35 +114,39 @@ export class FeederService {
} }
private parseScoreValue(value: unknown): number | null { private parseScoreValue(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null; if (value === null || value === undefined || value === "") return null;
const parsed = Number(value); const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null; return Number.isFinite(parsed) ? parsed : null;
} }
private isCompletedMatchSummary(match: MatchSummary): boolean { private isCompletedMatchSummary(match: MatchSummary): boolean {
if (match.statusBoxContent === 'ERT') return false; if (match.statusBoxContent === "ERT") return false;
const normalizedState = String(match.state || '') const normalizedState = String(match.state || "")
.trim() .trim()
.toLowerCase(); .toLowerCase();
const normalizedStatus = String(match.status || '') const normalizedStatus = String(match.status || "")
.trim() .trim()
.toLowerCase(); .toLowerCase();
const normalizedSubstate = String(match.substate || '') const normalizedSubstate = String(match.substate || "")
.trim() .trim()
.toLowerCase(); .toLowerCase();
if (['postgame', 'post'].includes(normalizedState)) return true; if (["postgame", "post"].includes(normalizedState)) return true;
if ( if (
['played', 'finished', 'ft', 'afterpenalties', 'penalties'].includes( ["played", "finished", "ft", "afterpenalties", "penalties"].includes(
normalizedStatus, normalizedStatus,
) )
) { ) {
return true; return true;
} }
if (['postgame', 'post', 'played', 'finished', 'ft'].includes(normalizedSubstate)) { if (
["postgame", "post", "played", "finished", "ft"].includes(
normalizedSubstate,
)
) {
return true; return true;
} }
@@ -167,7 +168,7 @@ export class FeederService {
targetLeagueIds: string[] = [], targetLeagueIds: string[] = [],
): Promise<void> { ): Promise<void> {
this.logger.log( this.logger.log(
`🗓️ STARTING DAILY COMPLETED MATCH SYNC [Date: ${targetDateStr}] [Sports: ${sports.join(', ')}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ''}`, `🗓️ STARTING DAILY COMPLETED MATCH SYNC [Date: ${targetDateStr}] [Sports: ${sports.join(", ")}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ""}`,
); );
for (const sport of sports) { for (const sport of sports) {
@@ -191,7 +192,7 @@ export class FeederService {
targetLeagueIds: string[] = [], // NEW: Optional league filter targetLeagueIds: string[] = [], // NEW: Optional league filter
): Promise<void> { ): Promise<void> {
this.logger.log( this.logger.log(
`🚀 STARTING HISTORICAL SCAN [Target: ${sports.join(', ')}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ''}`, `🚀 STARTING HISTORICAL SCAN [Target: ${sports.join(", ")}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ""}`,
); );
const startDate = new Date(startDateStr); const startDate = new Date(startDateStr);
@@ -201,7 +202,7 @@ export class FeederService {
// writing to live_matches. Historical scan should only fill matches table. // writing to live_matches. Historical scan should only fill matches table.
endDate.setDate(endDate.getDate() - 2); endDate.setDate(endDate.getDate() - 2);
const stateKey = `historical_scan_state_${sports.join('_')}${targetLeagueIds.length > 0 ? '_filtered' : ''}_desc`; const stateKey = `historical_scan_state_${sports.join("_")}${targetLeagueIds.length > 0 ? "_filtered" : ""}_desc`;
let currentDate: Date | null = null; let currentDate: Date | null = null;
// Resume from saved state // Resume from saved state
@@ -215,12 +216,12 @@ export class FeederService {
// For reverse scan, we resume from the *next* day backwards, i.e., resumeDate - 1 day // For reverse scan, we resume from the *next* day backwards, i.e., resumeDate - 1 day
currentDate.setDate(currentDate.getDate() - 1); currentDate.setDate(currentDate.getDate() - 1);
this.logger.log( this.logger.log(
`📍 Resuming from: ${currentDate.toISOString().split('T')[0]}`, `📍 Resuming from: ${currentDate.toISOString().split("T")[0]}`,
); );
} }
} }
} catch { } catch {
this.logger.warn('Could not read state, starting from beginning'); this.logger.warn("Could not read state, starting from beginning");
} }
// Initialize currentDate to endDate if not resuming (or if resume failed) // Initialize currentDate to endDate if not resuming (or if resume failed)
@@ -231,7 +232,7 @@ export class FeederService {
} }
this.logger.log( this.logger.log(
`📊 Scanning (Reverse): ${currentDate.toISOString().split('T')[0]}${startDate.toISOString().split('T')[0]}`, `📊 Scanning (Reverse): ${currentDate.toISOString().split("T")[0]}${startDate.toISOString().split("T")[0]}`,
); );
let processedDays = 0; let processedDays = 0;
@@ -239,7 +240,7 @@ export class FeederService {
// REVERSE LOOP: Iterate while currentDate is greater than or equal to startDate // REVERSE LOOP: Iterate while currentDate is greater than or equal to startDate
while (currentDate >= startDate) { while (currentDate >= startDate) {
const dateString = currentDate.toISOString().split('T')[0]; const dateString = currentDate.toISOString().split("T")[0];
for (const sport of sports) { for (const sport of sports) {
await this.processDate(dateString, sport, targetLeagueIds); await this.processDate(dateString, sport, targetLeagueIds);
@@ -278,7 +279,7 @@ export class FeederService {
currentDate.setDate(currentDate.getDate() - 1); currentDate.setDate(currentDate.getDate() - 1);
} }
this.logger.log('🎉 HISTORICAL SCAN COMPLETED'); this.logger.log("🎉 HISTORICAL SCAN COMPLETED");
} }
// ============================================ // ============================================
@@ -308,9 +309,9 @@ export class FeederService {
break; // Success, exit loop break; // Success, exit loop
} catch (e: any) { } catch (e: any) {
const is502 = const is502 =
e.message?.includes('502') || e.message?.includes("502") ||
e.response?.status === 502 || e.response?.status === 502 ||
e.message?.includes('Bad Gateway'); e.message?.includes("Bad Gateway");
if (is502 && i < 2) { if (is502 && i < 2) {
this.logger.warn( this.logger.warn(
@@ -341,10 +342,7 @@ export class FeederService {
// regardless of the matchDate query parameter. We must filter by mstUtc // regardless of the matchDate query parameter. We must filter by mstUtc
// to ensure we only process matches that actually belong to the target date. // to ensure we only process matches that actually belong to the target date.
const { startTs: targetDateStartTs, endTs: targetDateEndTs } = const { startTs: targetDateStartTs, endTs: targetDateEndTs } =
this.getDayBoundsForTimeZone( this.getDayBoundsForTimeZone(dateString, this.DAILY_SYNC_TIME_ZONE);
dateString,
this.DAILY_SYNC_TIME_ZONE,
);
const dateFilteredMatches = allMatches.filter((m) => { const dateFilteredMatches = allMatches.filter((m) => {
const matchTs = m.mstUtc; const matchTs = m.mstUtc;
@@ -518,14 +516,14 @@ export class FeederService {
// ============================================ // ============================================
async refreshMatch( async refreshMatch(
matchId: string, matchId: string,
scope: 'all' | 'lineups' | 'odds' = 'all', scope: "all" | "lineups" | "odds" = "all",
): Promise<ProcessResult> { ): Promise<ProcessResult> {
this.logger.log(`🔄 Refreshing match (${scope}) for ${matchId}`); this.logger.log(`🔄 Refreshing match (${scope}) for ${matchId}`);
const matchRecord = await this.persistenceService.getMatch(matchId); const matchRecord = await this.persistenceService.getMatch(matchId);
if (!matchRecord) { if (!matchRecord) {
this.logger.warn(`[${matchId}] Refresh failed: Match not in DB`); this.logger.warn(`[${matchId}] Refresh failed: Match not in DB`);
return { success: false, retryable: false, error: 'Match not found' }; return { success: false, retryable: false, error: "Match not found" };
} }
// Construct MatchSummary from DB record // Construct MatchSummary from DB record
@@ -538,13 +536,13 @@ export class FeederService {
iddaaCode: matchRecord.iddaaCode, iddaaCode: matchRecord.iddaaCode,
homeTeam: { homeTeam: {
id: matchRecord.homeTeamId, id: matchRecord.homeTeamId,
name: matchRecord.homeTeam?.name || '', name: matchRecord.homeTeam?.name || "",
slug: matchRecord.homeTeam?.slug || '', slug: matchRecord.homeTeam?.slug || "",
}, },
awayTeam: { awayTeam: {
id: matchRecord.awayTeamId, id: matchRecord.awayTeamId,
name: matchRecord.awayTeam?.name || '', name: matchRecord.awayTeam?.name || "",
slug: matchRecord.awayTeam?.slug || '', slug: matchRecord.awayTeam?.slug || "",
}, },
score: { score: {
home: matchRecord.scoreHome, home: matchRecord.scoreHome,
@@ -555,9 +553,9 @@ export class FeederService {
const dummyCompetitions: Record<string, Competition> = { const dummyCompetitions: Record<string, Competition> = {
[summary.competitionId]: { [summary.competitionId]: {
id: summary.competitionId, id: summary.competitionId,
name: 'Unknown', name: "Unknown",
competitionSlug: '', competitionSlug: "",
country: { id: '', name: '' }, country: { id: "", name: "" },
}, },
}; };
@@ -583,7 +581,7 @@ export class FeederService {
competitions: Record<string, Competition>, competitions: Record<string, Competition>,
sport: Sport, sport: Sport,
force: boolean = false, force: boolean = false,
scope: 'all' | 'lineups' | 'odds' = 'all', // Add scope flag scope: "all" | "lineups" | "odds" = "all", // Add scope flag
): Promise<ProcessResult> { ): Promise<ProcessResult> {
const matchId = matchSummary.id; const matchId = matchSummary.id;
const homeTeamId = matchSummary.homeTeam?.id; const homeTeamId = matchSummary.homeTeam?.id;
@@ -595,7 +593,7 @@ export class FeederService {
} }
// Skip postponed matches (ERT = Erteledendi) // Skip postponed matches (ERT = Erteledendi)
if (matchSummary.statusBoxContent === 'ERT') { if (matchSummary.statusBoxContent === "ERT") {
this.logger.debug(`[${matchId}] Skipped: Postponed match (ERT)`); this.logger.debug(`[${matchId}] Skipped: Postponed match (ERT)`);
return { success: false, retryable: false }; return { success: false, retryable: false };
} }
@@ -615,9 +613,9 @@ export class FeederService {
return await fn(); return await fn();
} catch (e: any) { } catch (e: any) {
const is502 = const is502 =
e.message?.includes('502') || e.message?.includes("502") ||
e.response?.status === 502 || e.response?.status === 502 ||
e.message?.includes('Bad Gateway'); e.message?.includes("Bad Gateway");
if (i === retries - 1) throw e; // Last attempt failed if (i === retries - 1) throw e; // Last attempt failed
@@ -661,44 +659,44 @@ export class FeederService {
// 1. Fetch Match Header (score, status) // 1. Fetch Match Header (score, status)
let headerData: ParsedMatchHeader | null = null; let headerData: ParsedMatchHeader | null = null;
if (scope === 'all') { if (scope === "all") {
try { try {
headerData = await fetchResilient('Header', () => headerData = await fetchResilient("Header", () =>
this.scraperService.fetchMatchHeader(matchId), this.scraperService.fetchMatchHeader(matchId),
); );
} catch (e: any) { } catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true; if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(`[${matchId}] Header fetch failed: ${e.message}`); this.logger.warn(`[${matchId}] Header fetch failed: ${e.message}`);
} }
} }
// 2. Sport-specific data fetching // 2. Sport-specific data fetching
if (sport === 'basketball') { if (sport === "basketball") {
// Basketball: Box Score (Always if all or lineups) // Basketball: Box Score (Always if all or lineups)
if (scope === 'all' || scope === 'lineups') { if (scope === "all" || scope === "lineups") {
try { try {
const boxData = await fetchResilient('BoxScore', () => const boxData = await fetchResilient("BoxScore", () =>
this.scraperService.fetchBasketballBoxScore(matchId), this.scraperService.fetchBasketballBoxScore(matchId),
); );
if (boxData) { if (boxData) {
const homeParsed = this.scraperService.parseBasketballBoxScore( const homeParsed = this.scraperService.parseBasketballBoxScore(
boxData.views?.home?.html || '', boxData.views?.home?.html || "",
); );
const awayParsed = this.scraperService.parseBasketballBoxScore( const awayParsed = this.scraperService.parseBasketballBoxScore(
boxData.views?.away?.html || '', boxData.views?.away?.html || "",
); );
basketballTeamStats = basketballTeamStats =
scope === 'all' scope === "all"
? { ? {
home: homeParsed.teamTotals, home: homeParsed.teamTotals,
away: awayParsed.teamTotals, away: awayParsed.teamTotals,
} }
: null; : null;
if (scope === 'all') { if (scope === "all") {
try { try {
const details = await fetchResilient('QuarterScores', () => const details = await fetchResilient("QuarterScores", () =>
this.scraperService.fetchBasketballDetailsHeader(matchId), this.scraperService.fetchBasketballDetailsHeader(matchId),
); );
if (details && basketballTeamStats) { if (details && basketballTeamStats) {
@@ -712,7 +710,7 @@ export class FeederService {
}; };
} }
} catch (e: any) { } catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true; if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn( this.logger.warn(
`[${matchId}] Quarter scores fetch failed: ${e.message}`, `[${matchId}] Quarter scores fetch failed: ${e.message}`,
); );
@@ -748,7 +746,7 @@ export class FeederService {
processPlayers(awayParsed, awayTeamId); processPlayers(awayParsed, awayTeamId);
} }
} catch (e: any) { } catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true; if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(`[${matchId}] Box score failed: ${e.message}`); this.logger.warn(`[${matchId}] Box score failed: ${e.message}`);
} }
} }
@@ -756,9 +754,9 @@ export class FeederService {
// Football: Events, Lineups, Stats, Officials // Football: Events, Lineups, Stats, Officials
// Key Events // Key Events
if (scope === 'all') { if (scope === "all") {
try { try {
const eventsData = await fetchResilient('Events', () => const eventsData = await fetchResilient("Events", () =>
this.scraperService.fetchKeyEvents(matchId), this.scraperService.fetchKeyEvents(matchId),
); );
if (eventsData?.keyEvents) { if (eventsData?.keyEvents) {
@@ -781,7 +779,7 @@ export class FeederService {
); );
} }
} catch (e: any) { } catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true; if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(`[${matchId}] Events failed: ${e.message}`); this.logger.warn(`[${matchId}] Events failed: ${e.message}`);
} }
@@ -850,20 +848,20 @@ export class FeederService {
*/ */
// Game Stats & Officials // Game Stats & Officials
if (scope === 'all') { if (scope === "all") {
try { try {
const gameStats = await fetchResilient('Stats', () => const gameStats = await fetchResilient("Stats", () =>
this.scraperService.fetchGameStats(matchId), this.scraperService.fetchGameStats(matchId),
); );
stats = this.transformerService.transformGameStats(gameStats); stats = this.transformerService.transformGameStats(gameStats);
} catch (e: any) { } catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true; if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(`[${matchId}] Stats failed: ${e.message}`); this.logger.warn(`[${matchId}] Stats failed: ${e.message}`);
} }
// Officials (from match page) // Officials (from match page)
try { try {
const matchPageHtml = await fetchResilient('Officials', () => const matchPageHtml = await fetchResilient("Officials", () =>
this.scraperService.fetchMatchPage( this.scraperService.fetchMatchPage(
matchId, matchId,
matchSummary.matchSlug, matchSummary.matchSlug,
@@ -875,7 +873,7 @@ export class FeederService {
this.transformerService.parseOfficials(matchPageHtml); this.transformerService.parseOfficials(matchPageHtml);
} }
} catch (e: any) { } catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true; if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(`[${matchId}] Officials failed: ${e.message}`); this.logger.warn(`[${matchId}] Officials failed: ${e.message}`);
} }
} }
@@ -883,31 +881,31 @@ export class FeederService {
// 3. Fetch Iddaa Odds (Always if all or odds) // 3. Fetch Iddaa Odds (Always if all or odds)
let oddsArray: DbMarketPayload[] = []; let oddsArray: DbMarketPayload[] = [];
if (scope === 'all' || scope === 'odds') { if (scope === "all" || scope === "odds") {
try { try {
let markets: ParsedMarket[] = []; let markets: ParsedMarket[] = [];
if (sport === 'basketball') { if (sport === "basketball") {
markets = markets =
((await fetchResilient('BucketOdds', () => ((await fetchResilient("BucketOdds", () =>
this.scraperService.fetchBasketballMarkets(matchId), this.scraperService.fetchBasketballMarkets(matchId),
)) as ParsedMarket[]) || []; )) as ParsedMarket[]) || [];
} else { } else {
markets = markets =
((await fetchResilient('IddaaOdds', () => ((await fetchResilient("IddaaOdds", () =>
this.scraperService.fetchIddaaMarkets(matchId), this.scraperService.fetchIddaaMarkets(matchId),
)) as ParsedMarket[]) || []; )) as ParsedMarket[]) || [];
} }
// Logic is same since structure is ParsedMarket[] // Logic is same since structure is ParsedMarket[]
oddsArray = this.transformerService.transformIddaaMarkets(markets); oddsArray = this.transformerService.transformIddaaMarkets(markets);
} catch (e: any) { } catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true; if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(`[${matchId}] Odds failed: ${e.message}`); this.logger.warn(`[${matchId}] Odds failed: ${e.message}`);
} }
} }
// 4. Persist to Database // 4. Persist to Database
let saved = false; let saved = false;
if (scope === 'lineups') { if (scope === "lineups") {
saved = await this.persistenceService.saveLineups( saved = await this.persistenceService.saveLineups(
matchId, matchId,
playersMap, playersMap,
@@ -915,7 +913,7 @@ export class FeederService {
homeTeamId, homeTeamId,
awayTeamId, awayTeamId,
); );
} else if (scope === 'odds') { } else if (scope === "odds") {
saved = await this.persistenceService.saveOdds(matchId, oddsArray); saved = await this.persistenceService.saveOdds(matchId, oddsArray);
} else { } else {
// Full Update // Full Update
@@ -962,12 +960,12 @@ export class FeederService {
if (saved && hasCriticalError) { if (saved && hasCriticalError) {
// Collect missing components // Collect missing components
const missingParts: string[] = []; const missingParts: string[] = [];
if (!stats) missingParts.push('Stats'); if (!stats) missingParts.push("Stats");
if (oddsArray.length === 0) missingParts.push('Odds'); if (oddsArray.length === 0) missingParts.push("Odds");
if (officialsData.length === 0) missingParts.push('Officials'); if (officialsData.length === 0) missingParts.push("Officials");
this.logger.warn( this.logger.warn(
`[${matchId}] Saved with MISSING DATA (502). Missing: [${missingParts.join(', ')}]. Scheduled for retry.`, `[${matchId}] Saved with MISSING DATA (502). Missing: [${missingParts.join(", ")}]. Scheduled for retry.`,
); );
return { success: false, retryable: true }; return { success: false, retryable: true };
} }
@@ -975,12 +973,12 @@ export class FeederService {
return { success: saved, retryable: !saved }; return { success: saved, retryable: !saved };
} catch (error: any) { } catch (error: any) {
const isRetryable = const isRetryable =
error.message.includes('502') || error.message.includes("502") ||
error.message.includes('504') || error.message.includes("504") ||
error.message.includes('ECONNABORTED') || error.message.includes("ECONNABORTED") ||
error.message.includes('timeout') || error.message.includes("timeout") ||
error.message.includes('ETIMEDOUT') || error.message.includes("ETIMEDOUT") ||
error.message.includes('Unique constraint'); // Concurrency retry error.message.includes("Unique constraint"); // Concurrency retry
if (isRetryable) { if (isRetryable) {
this.logger.warn(`[${matchId}] ${error.message} - Will retry`); this.logger.warn(`[${matchId}] ${error.message} - Will retry`);
+88 -88
View File
@@ -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;
+4 -4
View File
@@ -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",
})); }));
+4 -4
View File
@@ -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
+27 -27
View File
@@ -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");
} }
} }
} }
+3 -3
View File
@@ -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";
+14 -14
View File
@@ -1,15 +1,15 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from "@nestjs/common";
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from "@nestjs/swagger";
import { import {
HealthCheck, HealthCheck,
HealthCheckService, HealthCheckService,
PrismaHealthIndicator, PrismaHealthIndicator,
} from '@nestjs/terminus'; } from "@nestjs/terminus";
import { Public } from '../../common/decorators'; import { Public } from "../../common/decorators";
import { PrismaService } from '../../database/prisma.service'; import { PrismaService } from "../../database/prisma.service";
@ApiTags('Health') @ApiTags("Health")
@Controller('health') @Controller("health")
export class HealthController { export class HealthController {
constructor( constructor(
private health: HealthCheckService, private health: HealthCheckService,
@@ -20,25 +20,25 @@ export class HealthController {
@Get() @Get()
@Public() @Public()
@HealthCheck() @HealthCheck()
@ApiOperation({ summary: 'Basic health check' }) @ApiOperation({ summary: "Basic health check" })
check() { check() {
return this.health.check([]); return this.health.check([]);
} }
@Get('ready') @Get("ready")
@Public() @Public()
@HealthCheck() @HealthCheck()
@ApiOperation({ summary: 'Readiness check (includes database)' }) @ApiOperation({ summary: "Readiness check (includes database)" })
readiness() { readiness() {
return this.health.check([ return this.health.check([
() => this.prismaHealth.pingCheck('database', this.prisma), () => this.prismaHealth.pingCheck("database", this.prisma),
]); ]);
} }
@Get('live') @Get("live")
@Public() @Public()
@ApiOperation({ summary: 'Liveness check' }) @ApiOperation({ summary: "Liveness check" })
liveness() { liveness() {
return { status: 'ok', timestamp: new Date().toISOString() }; return { status: "ok", timestamp: new Date().toISOString() };
} }
} }
+4 -4
View File
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TerminusModule } from '@nestjs/terminus'; import { TerminusModule } from "@nestjs/terminus";
import { PrismaHealthIndicator } from '@nestjs/terminus'; import { PrismaHealthIndicator } from "@nestjs/terminus";
import { HealthController } from './health.controller'; import { HealthController } from "./health.controller";
@Module({ @Module({
imports: [TerminusModule], imports: [TerminusModule],
+50 -50
View File
@@ -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;
} }
} }
+4 -4
View File
@@ -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],
+12 -12
View File
@@ -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,
}); });
+12 -12
View File
@@ -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[];
} }
+32 -32
View File
@@ -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;
+4 -4
View File
@@ -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],
+70 -70
View File
@@ -1,14 +1,14 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from "@nestjs/common";
import * as fs from 'fs'; import * as fs from "fs";
import * as path from 'path'; import * as path from "path";
import { PrismaService } from '../../database/prisma.service'; import { PrismaService } from "../../database/prisma.service";
import { import {
Sport, Sport,
MatchQueryDto, MatchQueryDto,
LeagueWithMatchesDto, LeagueWithMatchesDto,
ActiveLeagueDto, ActiveLeagueDto,
} from './dto'; } from "./dto";
import { Prisma } from '@prisma/client'; import { Prisma } from "@prisma/client";
@Injectable() @Injectable()
export class MatchesService { export class MatchesService {
@@ -21,9 +21,9 @@ export class MatchesService {
private loadTopLeagues() { private loadTopLeagues() {
try { try {
const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json'); const topLeaguesPath = path.join(process.cwd(), "top_leagues.json");
if (fs.existsSync(topLeaguesPath)) { if (fs.existsSync(topLeaguesPath)) {
this.topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8')); this.topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
this.logger.log( this.logger.log(
`Loaded ${this.topLeagueIds.length} top leagues for filtering.`, `Loaded ${this.topLeagueIds.length} top leagues for filtering.`,
); );
@@ -39,22 +39,22 @@ export class MatchesService {
{ {
status: { status: {
in: [ in: [
'LIVE', "LIVE",
'1H', "1H",
'2H', "2H",
'HT', "HT",
'1Q', "1Q",
'2Q', "2Q",
'3Q', "3Q",
'4Q', "4Q",
'Playing', "Playing",
'Half Time', "Half Time",
], ],
}, },
}, },
{ {
state: { state: {
in: ['live', 'firsthalf', 'secondhalf'], in: ["live", "firsthalf", "secondhalf"],
}, },
}, },
], ],
@@ -66,12 +66,12 @@ export class MatchesService {
OR: [ OR: [
{ {
status: { status: {
in: ['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'], in: ["Finished", "Played", "FT", "AET", "PEN", "Ended"],
}, },
}, },
{ {
state: { state: {
in: ['Finished', 'post', 'FT', 'postGame'], in: ["Finished", "post", "FT", "postGame"],
}, },
}, },
], ],
@@ -134,16 +134,16 @@ export class MatchesService {
if (leagueId) { if (leagueId) {
where.leagueId = leagueId; where.leagueId = leagueId;
} else if (status === 'LIVE' && this.topLeagueIds.length > 0) { } else if (status === "LIVE" && this.topLeagueIds.length > 0) {
// Filter live matches by top leagues by default if no leagueId is provided // Filter live matches by top leagues by default if no leagueId is provided
where.leagueId = { in: this.topLeagueIds }; where.leagueId = { in: this.topLeagueIds };
} }
if (status === 'LIVE') { if (status === "LIVE") {
andConditions.push(this.getLiveFilter()); andConditions.push(this.getLiveFilter());
} else if (status === 'UPCOMING' || status === 'NOT_STARTED') { } else if (status === "UPCOMING" || status === "NOT_STARTED") {
andConditions.push(this.getUpcomingFilter(Date.now())); andConditions.push(this.getUpcomingFilter(Date.now()));
} else if (status === 'FINISHED') { } else if (status === "FINISHED") {
andConditions.push(this.getFinishedFilter()); andConditions.push(this.getFinishedFilter());
} else if (status) { } else if (status) {
where.status = status; where.status = status;
@@ -170,9 +170,9 @@ export class MatchesService {
// Team filter // Team filter
if (team) { if (team) {
if (team.role === 'home') { if (team.role === "home") {
where.homeTeamId = team.id; where.homeTeamId = team.id;
} else if (team.role === 'away') { } else if (team.role === "away") {
where.awayTeamId = team.id; where.awayTeamId = team.id;
} else { } else {
andConditions.push({ andConditions.push({
@@ -197,7 +197,7 @@ export class MatchesService {
const matches = await this.prisma.liveMatch.findMany({ const matches = await this.prisma.liveMatch.findMany({
where, where,
select: { id: true }, select: { id: true },
orderBy: { mstUtc: 'asc' }, // Sort by nearest match first orderBy: { mstUtc: "asc" }, // Sort by nearest match first
take: limit, take: limit,
}); });
@@ -220,7 +220,7 @@ export class MatchesService {
AND: [this.getUpcomingFilter(Date.now())], AND: [this.getUpcomingFilter(Date.now())],
}, },
select: { id: true }, select: { id: true },
orderBy: { mstUtc: 'asc' }, orderBy: { mstUtc: "asc" },
take: limit, take: limit,
}); });
console.log( console.log(
@@ -283,16 +283,16 @@ export class MatchesService {
const leaguesMap = new Map<string, LeagueWithMatchesDto>(); const leaguesMap = new Map<string, LeagueWithMatchesDto>();
for (const match of matches) { for (const match of matches) {
const leagueId = match.leagueId || 'unknown'; const leagueId = match.leagueId || "unknown";
if (!leaguesMap.has(leagueId)) { if (!leaguesMap.has(leagueId)) {
leaguesMap.set(leagueId, { leaguesMap.set(leagueId, {
id: leagueId, id: leagueId,
name: match.league?.name || 'Unknown League', name: match.league?.name || "Unknown League",
code: match.league?.code || undefined, code: match.league?.code || undefined,
country: { country: {
id: match.league?.country?.id || '', id: match.league?.country?.id || "",
name: match.league?.country?.name || '', name: match.league?.country?.name || "",
flagUrl: match.league?.country?.flagUrl || undefined, flagUrl: match.league?.country?.flagUrl || undefined,
}, },
sport: sport, sport: sport,
@@ -306,13 +306,13 @@ export class MatchesService {
const structuredOdds: any[] = []; const structuredOdds: any[] = [];
if ( if (
match.odds && match.odds &&
typeof match.odds === 'object' && typeof match.odds === "object" &&
!Array.isArray(match.odds) !Array.isArray(match.odds)
) { ) {
const oddsObj = match.odds as Record<string, Record<string, number>>; const oddsObj = match.odds as Record<string, Record<string, number>>;
for (const [marketName, selections] of Object.entries(oddsObj)) { for (const [marketName, selections] of Object.entries(oddsObj)) {
const structuredSelections: Record<string, { odd: string }> = {}; const structuredSelections: Record<string, { odd: string }> = {};
if (selections && typeof selections === 'object') { if (selections && typeof selections === "object") {
for (const [selName, selOdd] of Object.entries(selections)) { for (const [selName, selOdd] of Object.entries(selections)) {
structuredSelections[selName] = { odd: String(selOdd) }; structuredSelections[selName] = { odd: String(selOdd) };
} }
@@ -325,15 +325,15 @@ export class MatchesService {
} }
// Map status for frontend // Map status for frontend
let displayStatus = match.status || 'NS'; let displayStatus = match.status || "NS";
if (match.state === 'live') { if (match.state === "live") {
displayStatus = 'LIVE'; displayStatus = "LIVE";
} else if ( } else if (
match.state === 'post' || match.state === "post" ||
match.state === 'FT' || match.state === "FT" ||
match.status === 'Finished' match.status === "Finished"
) { ) {
displayStatus = 'Finished'; displayStatus = "Finished";
} }
league.matches.push({ league.matches.push({
@@ -349,11 +349,11 @@ export class MatchesService {
scoreAway: match.scoreAway ?? undefined, scoreAway: match.scoreAway ?? undefined,
htScoreHome: undefined, // LiveMatch table doesn't have HT scores separately usually htScoreHome: undefined, // LiveMatch table doesn't have HT scores separately usually
htScoreAway: undefined, htScoreAway: undefined,
homeTeamName: match.homeTeam?.name || 'Unknown', homeTeamName: match.homeTeam?.name || "Unknown",
homeTeamLogo: match.homeTeamId homeTeamLogo: match.homeTeamId
? `https://file.mackolikfeeds.com/teams/${match.homeTeamId}` ? `https://file.mackolikfeeds.com/teams/${match.homeTeamId}`
: undefined, : undefined,
awayTeamName: match.awayTeam?.name || 'Unknown', awayTeamName: match.awayTeam?.name || "Unknown",
awayTeamLogo: match.awayTeamId awayTeamLogo: match.awayTeamId
? `https://file.mackolikfeeds.com/teams/${match.awayTeamId}` ? `https://file.mackolikfeeds.com/teams/${match.awayTeamId}`
: undefined, : undefined,
@@ -390,15 +390,15 @@ export class MatchesService {
// Priority sorting (Mackolik style) // Priority sorting (Mackolik style)
const PRIORITY = [ const PRIORITY = [
'Trendyol Süper Lig', "Trendyol Süper Lig",
'Süper Lig', "Süper Lig",
'Trendyol 1. Lig', "Trendyol 1. Lig",
'1. Lig', "1. Lig",
'Premier Lig', "Premier Lig",
'LaLiga', "LaLiga",
'Serie A', "Serie A",
'Bundesliga', "Bundesliga",
'Ligue 1', "Ligue 1",
]; ];
return leagues return leagues
@@ -410,7 +410,7 @@ export class MatchesService {
const bPriority = bIdx === -1 ? 999 : bIdx; const bPriority = bIdx === -1 ? 999 : bIdx;
if (aPriority !== bPriority) return aPriority - bPriority; if (aPriority !== bPriority) return aPriority - bPriority;
return (a.name || '').localeCompare(b.name || ''); return (a.name || "").localeCompare(b.name || "");
}) })
.map((l) => ({ .map((l) => ({
id: l.id, id: l.id,
@@ -439,7 +439,7 @@ export class MatchesService {
include: { country: true }, include: { country: true },
}, },
}, },
orderBy: { mstUtc: 'desc' }, orderBy: { mstUtc: "desc" },
skip, skip,
take: limit, take: limit,
}), }),
@@ -482,7 +482,7 @@ export class MatchesService {
createdAt: stat.createdAt, createdAt: stat.createdAt,
}; };
if ((sport || '').toLowerCase() === 'basketball') { if ((sport || "").toLowerCase() === "basketball") {
return { return {
...base, ...base,
points: stat.points, points: stat.points,
@@ -532,7 +532,7 @@ export class MatchesService {
basketballTeamStats: true, basketballTeamStats: true,
playerParticipations: { playerParticipations: {
include: { player: true }, include: { player: true },
orderBy: [{ isStarting: 'desc' }, { position: 'asc' }], orderBy: [{ isStarting: "desc" }, { position: "asc" }],
}, },
playerEvents: { playerEvents: {
include: { include: {
@@ -540,7 +540,7 @@ export class MatchesService {
assistPlayer: true, assistPlayer: true,
substitutedOut: true, substitutedOut: true,
}, },
orderBy: [{ periodId: 'asc' }, { timeMinute: 'asc' }], orderBy: [{ periodId: "asc" }, { timeMinute: "asc" }],
}, },
oddCategories: { oddCategories: {
include: { selections: true }, include: { selections: true },
@@ -562,15 +562,15 @@ export class MatchesService {
if (liveMatch) { if (liveMatch) {
// Map liveMatch status // Map liveMatch status
let displayStatus = liveMatch.status || 'NS'; let displayStatus = liveMatch.status || "NS";
if (liveMatch.state === 'live') { if (liveMatch.state === "live") {
displayStatus = 'LIVE'; displayStatus = "LIVE";
} else if ( } else if (
liveMatch.state === 'post' || liveMatch.state === "post" ||
liveMatch.state === 'FT' || liveMatch.state === "FT" ||
liveMatch.status === 'Finished' liveMatch.status === "Finished"
) { ) {
displayStatus = 'Finished'; displayStatus = "Finished";
} }
match = { match = {
@@ -607,14 +607,14 @@ export class MatchesService {
if ( if (
match.isLiveSource && match.isLiveSource &&
match.odds && match.odds &&
typeof match.odds === 'object' && typeof match.odds === "object" &&
!Array.isArray(match.odds) !Array.isArray(match.odds)
) { ) {
// Parse JSON odds from LiveMatch // Parse JSON odds from LiveMatch
const oddsObj = match.odds as Record<string, Record<string, number>>; const oddsObj = match.odds as Record<string, Record<string, number>>;
for (const [marketName, selections] of Object.entries(oddsObj)) { for (const [marketName, selections] of Object.entries(oddsObj)) {
odds[marketName] = {}; odds[marketName] = {};
if (selections && typeof selections === 'object') { if (selections && typeof selections === "object") {
for (const [selName, selOdd] of Object.entries(selections)) { for (const [selName, selOdd] of Object.entries(selections)) {
odds[marketName][selName] = { odd: String(selOdd) }; odds[marketName][selName] = { odd: String(selOdd) };
} }
@@ -628,7 +628,7 @@ export class MatchesService {
for (const sel of cat.selections) { for (const sel of cat.selections) {
if (sel.name) { if (sel.name) {
odds[cat.name][sel.name] = { odds[cat.name][sel.name] = {
odd: sel.oddValue || '', odd: sel.oddValue || "",
sov: sel.sov ?? undefined, sov: sel.sov ?? undefined,
}; };
} }
@@ -637,7 +637,7 @@ export class MatchesService {
} }
const sportStats = const sportStats =
match.sport === 'basketball' match.sport === "basketball"
? match.basketballTeamStats || [] ? match.basketballTeamStats || []
: match.footballTeamStats || []; : match.footballTeamStats || [];
const normalizedTeamStats = sportStats.map((s: any) => const normalizedTeamStats = sportStats.map((s: any) =>
@@ -692,7 +692,7 @@ export class MatchesService {
// Fuzzy search // Fuzzy search
team = await this.prisma.team.findFirst({ team = await this.prisma.team.findFirst({
where: { where: {
name: { contains: trimmedName, mode: 'insensitive' }, name: { contains: trimmedName, mode: "insensitive" },
sport: sport as any, sport: sport as any,
}, },
select: { id: true }, select: { id: true },
+32 -37
View File
@@ -1,11 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from "@nestjs/swagger";
export type SignalTier = export type SignalTier = "CORE" | "VALUE" | "LEAN" | "LONGSHOT" | "PASS";
| 'CORE'
| 'VALUE'
| 'LEAN'
| 'LONGSHOT'
| 'PASS';
export class MatchInfoDto { export class MatchInfoDto {
@ApiProperty() @ApiProperty()
@@ -34,14 +29,14 @@ export class MatchInfoDto {
@ApiProperty({ @ApiProperty({
required: false, required: false,
enum: ['football', 'basketball'], enum: ["football", "basketball"],
}) })
sport?: 'football' | 'basketball'; sport?: "football" | "basketball";
} }
export class DataQualityDto { export class DataQualityDto {
@ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] }) @ApiProperty({ enum: ["HIGH", "MEDIUM", "LOW"] })
label: 'HIGH' | 'MEDIUM' | 'LOW'; label: "HIGH" | "MEDIUM" | "LOW";
@ApiProperty() @ApiProperty()
score: number; score: number;
@@ -52,7 +47,7 @@ export class DataQualityDto {
@ApiProperty() @ApiProperty()
away_lineup_count: number; away_lineup_count: number;
@ApiProperty({ required: false, default: 'none' }) @ApiProperty({ required: false, default: "none" })
lineup_source?: string; lineup_source?: string;
@ApiProperty({ type: [String] }) @ApiProperty({ type: [String] })
@@ -69,16 +64,16 @@ export class ConfidenceIntervalDto {
@ApiProperty() @ApiProperty()
width: number; width: number;
@ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] }) @ApiProperty({ enum: ["HIGH", "MEDIUM", "LOW"] })
band: 'HIGH' | 'MEDIUM' | 'LOW'; band: "HIGH" | "MEDIUM" | "LOW";
@ApiProperty() @ApiProperty()
threshold_met: boolean; threshold_met: boolean;
} }
export class RiskDto { export class RiskDto {
@ApiProperty({ enum: ['LOW', 'MEDIUM', 'HIGH', 'EXTREME'] }) @ApiProperty({ enum: ["LOW", "MEDIUM", "HIGH", "EXTREME"] })
level: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME'; level: "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
@ApiProperty() @ApiProperty()
score: number; score: number;
@@ -156,8 +151,8 @@ export class MatchPickDto {
@ApiProperty() @ApiProperty()
playable: boolean; playable: boolean;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] }) @ApiProperty({ enum: ["A", "B", "C", "PASS"] })
bet_grade: 'A' | 'B' | 'C' | 'PASS'; bet_grade: "A" | "B" | "C" | "PASS";
@ApiProperty() @ApiProperty()
stake_units: number; stake_units: number;
@@ -170,7 +165,7 @@ export class MatchPickDto {
@ApiProperty({ @ApiProperty({
required: false, required: false,
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'], enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"],
}) })
signal_tier?: SignalTier; signal_tier?: SignalTier;
} }
@@ -185,15 +180,15 @@ export class MatchBetAdviceDto {
@ApiProperty() @ApiProperty()
reason: string; reason: string;
@ApiProperty({ required: false, enum: ['HIGH', 'MEDIUM', 'LOW'] }) @ApiProperty({ required: false, enum: ["HIGH", "MEDIUM", "LOW"] })
confidence_band?: 'HIGH' | 'MEDIUM' | 'LOW'; confidence_band?: "HIGH" | "MEDIUM" | "LOW";
@ApiProperty({ required: false }) @ApiProperty({ required: false })
min_confidence_for_play?: number; min_confidence_for_play?: number;
@ApiProperty({ @ApiProperty({
required: false, required: false,
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'], enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"],
}) })
signal_tier?: SignalTier; signal_tier?: SignalTier;
} }
@@ -211,8 +206,8 @@ export class MatchBetSummaryItemDto {
@ApiProperty() @ApiProperty()
calibrated_confidence: number; calibrated_confidence: number;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] }) @ApiProperty({ enum: ["A", "B", "C", "PASS"] })
bet_grade: 'A' | 'B' | 'C' | 'PASS'; bet_grade: "A" | "B" | "C" | "PASS";
@ApiProperty() @ApiProperty()
playable: boolean; playable: boolean;
@@ -240,30 +235,30 @@ export class MatchBetSummaryItemDto {
@ApiProperty({ @ApiProperty({
required: false, required: false,
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'], enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"],
}) })
signal_tier?: SignalTier; signal_tier?: SignalTier;
} }
export class HtFtPredictionDto { export class HtFtPredictionDto {
@ApiProperty() @ApiProperty()
'1/1': number; "1/1": number;
@ApiProperty() @ApiProperty()
'1/X': number; "1/X": number;
@ApiProperty() @ApiProperty()
'1/2': number; "1/2": number;
@ApiProperty() @ApiProperty()
'X/1': number; "X/1": number;
@ApiProperty() @ApiProperty()
'X/X': number; "X/X": number;
@ApiProperty() @ApiProperty()
'X/2': number; "X/2": number;
@ApiProperty() @ApiProperty()
'2/1': number; "2/1": number;
@ApiProperty() @ApiProperty()
'2/X': number; "2/X": number;
@ApiProperty() @ApiProperty()
'2/2': number; "2/2": number;
@ApiProperty() @ApiProperty()
pick: string; pick: string;
@ApiProperty() @ApiProperty()
@@ -310,8 +305,8 @@ export class AggressivePickDto {
@ApiProperty() @ApiProperty()
playable: boolean; playable: boolean;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] }) @ApiProperty({ enum: ["A", "B", "C", "PASS"] })
bet_grade: 'A' | 'B' | 'C' | 'PASS'; bet_grade: "A" | "B" | "C" | "PASS";
@ApiProperty() @ApiProperty()
stake_units: number; stake_units: number;
@@ -468,4 +463,4 @@ export class AIHealthDto {
predictionServiceReady: boolean; predictionServiceReady: boolean;
} }
export * from './smart-coupon.dto'; export * from "./smart-coupon.dto";
@@ -8,28 +8,28 @@ import {
ArrayMaxSize, ArrayMaxSize,
Min, Min,
Max, Max,
} from 'class-validator'; } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export class GeneratePredictionDto { export class GeneratePredictionDto {
@ApiProperty({ description: 'Match ID to generate prediction for' }) @ApiProperty({ description: "Match ID to generate prediction for" })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
matchId: string; matchId: string;
} }
export enum CouponStrategy { export enum CouponStrategy {
SAFE = 'SAFE', SAFE = "SAFE",
BALANCED = 'BALANCED', BALANCED = "BALANCED",
AGGRESSIVE = 'AGGRESSIVE', AGGRESSIVE = "AGGRESSIVE",
VALUE = 'VALUE', VALUE = "VALUE",
MIRACLE = 'MIRACLE', MIRACLE = "MIRACLE",
} }
export class SmartCouponRequestDto { export class SmartCouponRequestDto {
@ApiProperty({ @ApiProperty({
description: 'List of match IDs for coupon', description: "List of match IDs for coupon",
example: ['match-1', 'match-2'], example: ["match-1", "match-2"],
}) })
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
@@ -44,7 +44,7 @@ export class SmartCouponRequestDto {
@IsEnum(CouponStrategy) @IsEnum(CouponStrategy)
strategy?: CouponStrategy; strategy?: CouponStrategy;
@ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 }) @ApiPropertyOptional({ description: "Maximum matches in coupon", example: 5 })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@@ -52,7 +52,7 @@ export class SmartCouponRequestDto {
maxMatches?: number; maxMatches?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Minimum confidence threshold (0-100)', description: "Minimum confidence threshold (0-100)",
example: 60, example: 60,
}) })
@IsOptional() @IsOptional()
@@ -3,14 +3,14 @@
*/ */
export type CouponStrategy = export type CouponStrategy =
| 'SAFE' | "SAFE"
| 'BALANCED' | "BALANCED"
| 'AGGRESSIVE' | "AGGRESSIVE"
| 'VALUE' | "VALUE"
| 'MIRACLE'; | "MIRACLE";
export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME'; export type RiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
export type DataQualityLabel = 'HIGH' | 'MEDIUM' | 'LOW'; export type DataQualityLabel = "HIGH" | "MEDIUM" | "LOW";
export interface SmartCouponRequestDto { export interface SmartCouponRequestDto {
match_ids: string[]; match_ids: string[];
@@ -7,24 +7,24 @@ import {
HttpCode, HttpCode,
HttpStatus, HttpStatus,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiParam } from "@nestjs/swagger";
import { PredictionsService } from './predictions.service'; import { PredictionsService } from "./predictions.service";
import { import {
MatchPredictionDto, MatchPredictionDto,
PredictionHistoryResponseDto, PredictionHistoryResponseDto,
UpcomingPredictionsDto, UpcomingPredictionsDto,
ValueBetDto, ValueBetDto,
AIHealthDto, AIHealthDto,
} from './dto'; } from "./dto";
import { import {
GeneratePredictionDto, GeneratePredictionDto,
SmartCouponRequestDto, SmartCouponRequestDto,
} from './dto/predictions-request.dto'; } from "./dto/predictions-request.dto";
import { Public } from 'src/common/decorators'; import { Public } from "src/common/decorators";
@ApiTags('Predictions') @ApiTags("Predictions")
@Controller('predictions') @Controller("predictions")
export class PredictionsController { export class PredictionsController {
constructor(private readonly predictionsService: PredictionsService) {} constructor(private readonly predictionsService: PredictionsService) {}
@@ -32,8 +32,8 @@ export class PredictionsController {
* GET /predictions/health * GET /predictions/health
* Check AI Engine health status * Check AI Engine health status
*/ */
@Get('health') @Get("health")
@ApiOperation({ summary: 'Check AI Engine health status' }) @ApiOperation({ summary: "Check AI Engine health status" })
@ApiResponse({ status: 200, type: AIHealthDto }) @ApiResponse({ status: 200, type: AIHealthDto })
async checkHealth(): Promise<AIHealthDto> { async checkHealth(): Promise<AIHealthDto> {
return this.predictionsService.checkHealth(); return this.predictionsService.checkHealth();
@@ -43,8 +43,8 @@ export class PredictionsController {
* GET /predictions/upcoming * GET /predictions/upcoming
* Get predictions for upcoming matches * Get predictions for upcoming matches
*/ */
@Get('upcoming') @Get("upcoming")
@ApiOperation({ summary: 'Get predictions for upcoming matches' }) @ApiOperation({ summary: "Get predictions for upcoming matches" })
@ApiResponse({ status: 200, type: UpcomingPredictionsDto }) @ApiResponse({ status: 200, type: UpcomingPredictionsDto })
async getUpcoming(): Promise<UpcomingPredictionsDto> { async getUpcoming(): Promise<UpcomingPredictionsDto> {
return this.predictionsService.getUpcomingPredictions(); return this.predictionsService.getUpcomingPredictions();
@@ -54,10 +54,10 @@ export class PredictionsController {
* GET /predictions/test/:id * GET /predictions/test/:id
* Refetch match data and get prediction * Refetch match data and get prediction
*/ */
@Get('test/:id') @Get("test/:id")
@ApiOperation({ summary: 'Refetch match data and get prediction' }) @ApiOperation({ summary: "Refetch match data and get prediction" })
@ApiParam({ name: 'id', description: 'Match ID' }) @ApiParam({ name: "id", description: "Match ID" })
async getTestPrediction(@Param('id') id: string) { async getTestPrediction(@Param("id") id: string) {
return this.predictionsService.testPrediction(id); return this.predictionsService.testPrediction(id);
} }
@@ -65,8 +65,8 @@ export class PredictionsController {
* GET /predictions/value-bets * GET /predictions/value-bets
* Get EV+ betting opportunities * Get EV+ betting opportunities
*/ */
@Get('value-bets') @Get("value-bets")
@ApiOperation({ summary: 'Get value betting opportunities (EV+)' }) @ApiOperation({ summary: "Get value betting opportunities (EV+)" })
@ApiResponse({ status: 200, type: [ValueBetDto] }) @ApiResponse({ status: 200, type: [ValueBetDto] })
async getValueBets(): Promise<ValueBetDto[]> { async getValueBets(): Promise<ValueBetDto[]> {
return this.predictionsService.getValueBets(); return this.predictionsService.getValueBets();
@@ -76,8 +76,8 @@ export class PredictionsController {
* GET /predictions/history * GET /predictions/history
* Get prediction history and accuracy stats * Get prediction history and accuracy stats
*/ */
@Get('history') @Get("history")
@ApiOperation({ summary: 'Get prediction history and accuracy statistics' }) @ApiOperation({ summary: "Get prediction history and accuracy statistics" })
@ApiResponse({ status: 200, type: PredictionHistoryResponseDto }) @ApiResponse({ status: 200, type: PredictionHistoryResponseDto })
async getHistory(): Promise<PredictionHistoryResponseDto> { async getHistory(): Promise<PredictionHistoryResponseDto> {
return this.predictionsService.getPredictionHistory(); return this.predictionsService.getPredictionHistory();
@@ -87,14 +87,14 @@ export class PredictionsController {
* GET /predictions/:matchId * GET /predictions/:matchId
* Get prediction for a specific match * Get prediction for a specific match
*/ */
@Get(':matchId') @Get(":matchId")
@Public() @Public()
@ApiOperation({ summary: 'Get prediction for a specific match' }) @ApiOperation({ summary: "Get prediction for a specific match" })
@ApiParam({ name: 'matchId', description: 'Match ID' }) @ApiParam({ name: "matchId", description: "Match ID" })
@ApiResponse({ status: 200, type: MatchPredictionDto }) @ApiResponse({ status: 200, type: MatchPredictionDto })
@ApiResponse({ status: 404, description: 'Match not found' }) @ApiResponse({ status: 404, description: "Match not found" })
async getPrediction( async getPrediction(
@Param('matchId') matchId: string, @Param("matchId") matchId: string,
): Promise<MatchPredictionDto> { ): Promise<MatchPredictionDto> {
// Check cache first // Check cache first
const cached = await this.predictionsService.getCachedPrediction(matchId); const cached = await this.predictionsService.getCachedPrediction(matchId);
@@ -119,9 +119,9 @@ export class PredictionsController {
* POST /predictions/generate * POST /predictions/generate
* Generate prediction with provided match data * Generate prediction with provided match data
*/ */
@Post('generate') @Post("generate")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Generate prediction with provided match data' }) @ApiOperation({ summary: "Generate prediction with provided match data" })
@ApiResponse({ status: 200, type: MatchPredictionDto }) @ApiResponse({ status: 200, type: MatchPredictionDto })
async generatePrediction( async generatePrediction(
@Body() dto: GeneratePredictionDto, @Body() dto: GeneratePredictionDto,
@@ -131,7 +131,7 @@ export class PredictionsController {
}); });
if (!prediction) { if (!prediction) {
throw new NotFoundException('Failed to generate prediction'); throw new NotFoundException("Failed to generate prediction");
} }
return prediction; return prediction;
@@ -141,19 +141,19 @@ export class PredictionsController {
* POST /predictions/smart-coupon * POST /predictions/smart-coupon
* Generate Smart Coupon using AI Engine V20 * Generate Smart Coupon using AI Engine V20
*/ */
@Post('smart-coupon') @Post("smart-coupon")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
summary: 'Generate Smart Coupon with V20 AI recommendations', summary: "Generate Smart Coupon with V20 AI recommendations",
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Smart coupon generated successfully', description: "Smart coupon generated successfully",
}) })
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> { async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> {
const coupon = await this.predictionsService.getSmartCoupon( const coupon = await this.predictionsService.getSmartCoupon(
dto.matchIds, dto.matchIds,
dto.strategy || 'BALANCED', dto.strategy || "BALANCED",
{ {
maxMatches: dto.maxMatches, maxMatches: dto.maxMatches,
minConfidence: dto.minConfidence, minConfidence: dto.minConfidence,
@@ -161,7 +161,7 @@ export class PredictionsController {
); );
if (!coupon) { if (!coupon) {
throw new NotFoundException('Failed to generate Smart Coupon'); throw new NotFoundException("Failed to generate Smart Coupon");
} }
return coupon; return coupon;
+13 -13
View File
@@ -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: [
+188 -182
View File
@@ -6,26 +6,26 @@ import {
OnModuleDestroy, OnModuleDestroy,
OnModuleInit, OnModuleInit,
Optional, Optional,
} from '@nestjs/common'; } from "@nestjs/common";
import { PrismaService } from '../../database/prisma.service'; import { PrismaService } from "../../database/prisma.service";
import { ConfigService } from '@nestjs/config'; import { ConfigService } from "@nestjs/config";
import { QueueEvents } from 'bullmq'; import { QueueEvents } from "bullmq";
import { PredictionsQueue } from './queues/predictions.queue'; import { PredictionsQueue } from "./queues/predictions.queue";
import { PREDICTIONS_QUEUE } from './queues/predictions.types'; import { PREDICTIONS_QUEUE } from "./queues/predictions.types";
import { import {
MatchPredictionDto, MatchPredictionDto,
PredictionHistoryResponseDto, PredictionHistoryResponseDto,
UpcomingPredictionsDto, UpcomingPredictionsDto,
ValueBetDto, ValueBetDto,
AIHealthDto, AIHealthDto,
} from './dto'; } from "./dto";
import axios, { AxiosError } from 'axios'; import axios, { AxiosError } from "axios";
import { Prisma } from '@prisma/client'; import { Prisma } from "@prisma/client";
import { FeederService } from '../feeder/feeder.service'; import { FeederService } from "../feeder/feeder.service";
import * as fs from 'node:fs'; import * as fs from "node:fs";
import * as path from 'node:path'; import * as path from "node:path";
type ConfidenceBand = 'HIGH' | 'MEDIUM' | 'LOW'; type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW";
interface ConfidenceInterval { interface ConfidenceInterval {
lower: number; lower: number;
@@ -47,73 +47,72 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private readonly aiEngineUrl: string; private readonly aiEngineUrl: string;
private readonly topLeagueIds = new Set<string>(); private readonly topLeagueIds = new Set<string>();
private readonly reasonTranslations: Record<string, string> = { private readonly reasonTranslations: Record<string, string> = {
confidence_below_threshold: 'Güven eşiğin altında', confidence_below_threshold: "Güven eşiğin altında",
confidence_interval_too_wide: 'Güven aralığı çok geniş', confidence_interval_too_wide: "Güven aralığı çok geniş",
confidence_interval_too_wide_for_main_pick: confidence_interval_too_wide_for_main_pick:
'Ana seçim için güven aralığı çok geniş', "Ana seçim için güven aralığı çok geniş",
confidence_band_low: 'Güven bandı düşük', confidence_band_low: "Güven bandı düşük",
playable_edge_found: 'Oynanabilir avantaj bulundu', playable_edge_found: "Oynanabilir avantaj bulundu",
market_signal_dominant: 'Piyasa sinyali baskın', market_signal_dominant: "Piyasa sinyali baskın",
team_form_signal_dominant: 'Takım formuna dayalı sinyaller çok baskın', team_form_signal_dominant: "Takım formuna dayalı sinyaller çok baskın",
lineup_signal_strong: 'İlk on bir sinyali güçlü', lineup_signal_strong: "İlk on bir sinyali güçlü",
lineup_signal_weak: 'İlk on bir sinyali zayıf', lineup_signal_weak: "İlk on bir sinyali zayıf",
lineup_probable_xi_used: 'Muhtemel ilk on bir kullanıldı', lineup_probable_xi_used: "Muhtemel ilk on bir kullanıldı",
lineup_probable_not_confirmed: 'Muhtemel ilk on bir henüz doğrulanmadı', lineup_probable_not_confirmed: "Muhtemel ilk on bir henüz doğrulanmadı",
lineup_unavailable: 'İlk on bir bilgisi mevcut değil', lineup_unavailable: "İlk on bir bilgisi mevcut değil",
lineup_incomplete: 'İlk on bir bilgisi eksik', lineup_incomplete: "İlk on bir bilgisi eksik",
missing_referee: 'Hakem verisi eksik', missing_referee: "Hakem verisi eksik",
draw_probability_elevated: 'Beraberlik olasılığı yükselmiş görünüyor', draw_probability_elevated: "Beraberlik olasılığı yükselmiş görünüyor",
balanced_match_risk: 'Maç dengeli, sürpriz riski yükseliyor', balanced_match_risk: "Maç dengeli, sürpriz riski yükseliyor",
draw_pressure: 'Beraberlik baskısı yüksek', draw_pressure: "Beraberlik baskısı yüksek",
upset_risk_detected: 'Sürpriz riski tespit edildi', upset_risk_detected: "Sürpriz riski tespit edildi",
limited_data_confidence: 'Veri kısıtlı olduğu için güven sınırlı', limited_data_confidence: "Veri kısıtlı olduğu için güven sınırlı",
data_quality_issue: 'Veri kalitesi sorunu var', data_quality_issue: "Veri kalitesi sorunu var",
high_risk_low_data_quality: 'Risk yüksek, veri kalitesi düşük', high_risk_low_data_quality: "Risk yüksek, veri kalitesi düşük",
insufficient_play_score: 'Oynanabilirlik puanı yetersiz', insufficient_play_score: "Oynanabilirlik puanı yetersiz",
no_bet_conditions_met: 'Bahis koşulları oluşmadı', no_bet_conditions_met: "Bahis koşulları oluşmadı",
market_passed_all_gates: 'Market tüm güvenlik kontrollerini geçti', market_passed_all_gates: "Market tüm güvenlik kontrollerini geçti",
no_ev_edge_minimum_stake: no_ev_edge_minimum_stake:
'Beklenen avantaj oluşmadı, minimum bahis önerildi', "Beklenen avantaj oluşmadı, minimum bahis önerildi",
player_form_signal_strong: 'Oyuncu formu sinyali güçlü', player_form_signal_strong: "Oyuncu formu sinyali güçlü",
player_form_signal_limited: 'Oyuncu formu sinyali sınırlı', player_form_signal_limited: "Oyuncu formu sinyali sınırlı",
live_state_impossible_market: 'Canlı maç durumu bu marketi geçersiz kılıyor', live_state_impossible_market:
live_score_exceeds_under_line: "Canlı maç durumu bu marketi geçersiz kılıyor",
'Mevcut skor bu alt seçeneğiyle çelişiyor', live_score_exceeds_under_line: "Mevcut skor bu alt seçeneğiyle çelişiyor",
score_model_conflicts_with_under_pick: score_model_conflicts_with_under_pick:
'Skor modeli alt seçeneğiyle çelişiyor', "Skor modeli alt seçeneğiyle çelişiyor",
score_model_conflicts_with_over_pick: score_model_conflicts_with_over_pick:
'Skor modeli üst seçeneğiyle çelişiyor', "Skor modeli üst seçeneğiyle çelişiyor",
market_stack_conflict_over25: market_stack_conflict_over25:
'Üst 2.5 sinyaliyle çeliştiği için zayıflatıldı', "Üst 2.5 sinyaliyle çeliştiği için zayıflatıldı",
market_stack_conflict_btts: market_stack_conflict_btts:
'Karşılıklı gol sinyaliyle çeliştiği için zayıflatıldı', "Karşılıklı gol sinyaliyle çeliştiği için zayıflatıldı",
first_half_result_conflicts_with_goalless_half: first_half_result_conflicts_with_goalless_half:
'İlk yarı sonucu beklentisi golsüz ilk yarıyla çelişiyor', "İlk yarı sonucu beklentisi golsüz ilk yarıyla çelişiyor",
first_half_htft_conflicts_with_goalless_half: first_half_htft_conflicts_with_goalless_half:
'İlk yarı/maç sonu beklentisi golsüz ilk yarıyla çelişiyor', "İlk yarı/maç sonu beklentisi golsüz ilk yarıyla çelişiyor",
first_half_draw_conflicts_with_goal_pick: first_half_draw_conflicts_with_goal_pick:
'İlk yarı beraberlik baskısı erken gol beklentisiyle çelişiyor', "İlk yarı beraberlik baskısı erken gol beklentisiyle çelişiyor",
first_half_goalless_conflicts_with_result_pick: first_half_goalless_conflicts_with_result_pick:
'Golsüz ilk yarı beklentisi ilk yarı sonuç seçimiyle çelişiyor', "Golsüz ilk yarı beklentisi ilk yarı sonuç seçimiyle çelişiyor",
first_half_goalless_conflicts_with_htft_pick: first_half_goalless_conflicts_with_htft_pick:
'Golsüz ilk yarı beklentisi ilk yarı/maç sonu seçimiyle çelişiyor', "Golsüz ilk yarı beklentisi ilk yarı/maç sonu seçimiyle çelişiyor",
first_half_goal_pressure_conflicts_with_htft_draw: first_half_goal_pressure_conflicts_with_htft_draw:
'İlk yarı gol baskısı ilk yarı beraberlik kurgusuyla çelişiyor', "İlk yarı gol baskısı ilk yarı beraberlik kurgusuyla çelişiyor",
live_total_goals_close_to_line: live_total_goals_close_to_line: "Canlı toplam gol çizgisine çok yakın",
'Canlı toplam gol çizgisine çok yakın',
score_model_conflicts_with_btts_no: score_model_conflicts_with_btts_no:
'Skor modeli KG Yok seçeneğiyle çelişiyor', "Skor modeli KG Yok seçeneğiyle çelişiyor",
score_model_conflicts_with_draw_pick: score_model_conflicts_with_draw_pick:
'Skor modeli beraberlik seçeneğiyle çelişiyor', "Skor modeli beraberlik seçeneğiyle çelişiyor",
score_model_conflicts_with_home_pick: score_model_conflicts_with_home_pick:
'Skor modeli ev sahibi seçeneğiyle çelişiyor', "Skor modeli ev sahibi seçeneğiyle çelişiyor",
score_model_conflicts_with_away_pick: score_model_conflicts_with_away_pick:
'Skor modeli deplasman seçeneğiyle çelişiyor', "Skor modeli deplasman seçeneğiyle çelişiyor",
high_total_goal_volatility: 'Toplam gol volatilitesi yüksek', high_total_goal_volatility: "Toplam gol volatilitesi yüksek",
mutual_goal_pressure: 'İki takımın da gol baskısı yüksek', mutual_goal_pressure: "İki takımın da gol baskısı yüksek",
late_goal_swing_risk: 'Geç gol kaynaklı kırılma riski var', late_goal_swing_risk: "Geç gol kaynaklı kırılma riski var",
live_match_open_state: 'Canlı maç açık oyuna dönmüş durumda', live_match_open_state: "Canlı maç açık oyuna dönmüş durumda",
live_match_active_state: 'Canlı maç aktif ve dalgalı ilerliyor', live_match_active_state: "Canlı maç aktif ve dalgalı ilerliyor",
}; };
constructor( constructor(
@@ -123,8 +122,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
@Optional() private readonly predictionsQueue?: PredictionsQueue, @Optional() private readonly predictionsQueue?: PredictionsQueue,
) { ) {
this.aiEngineUrl = this.configService.get( this.aiEngineUrl = this.configService.get(
'AI_ENGINE_URL', "AI_ENGINE_URL",
'http://localhost:8000', "http://localhost:8000",
); );
this.topLeagueIds = this.loadTopLeagueIds(); this.topLeagueIds = this.loadTopLeagueIds();
} }
@@ -133,14 +132,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (this.predictionsQueue) { if (this.predictionsQueue) {
this.queueEvents = new QueueEvents(PREDICTIONS_QUEUE, { this.queueEvents = new QueueEvents(PREDICTIONS_QUEUE, {
connection: { connection: {
host: this.configService.get('redis.host', 'localhost'), host: this.configService.get("redis.host", "localhost"),
port: this.configService.get('redis.port', 6379), port: this.configService.get("redis.port", 6379),
password: this.configService.get('redis.password'), password: this.configService.get("redis.password"),
}, },
}); });
this.logger.log('Queue mode enabled for predictions'); this.logger.log("Queue mode enabled for predictions");
} else { } else {
this.logger.log('Direct HTTP mode enabled for predictions (no Redis)'); this.logger.log("Direct HTTP mode enabled for predictions (no Redis)");
} }
} }
@@ -152,7 +151,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
checkHealth(): Promise<AIHealthDto> { checkHealth(): Promise<AIHealthDto> {
return Promise.resolve({ return Promise.resolve({
status: 'healthy', status: "healthy",
modelLoaded: true, modelLoaded: true,
predictionServiceReady: true, predictionServiceReady: true,
}); });
@@ -212,12 +211,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
} }
if (status === 422) { if (status === 422) {
throw new HttpException( throw new HttpException(
`AI Engine: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`, `AI Engine: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`,
HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.UNPROCESSABLE_ENTITY,
); );
} }
throw new HttpException( throw new HttpException(
`AI Engine error: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`, `AI Engine error: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`,
status || HttpStatus.SERVICE_UNAVAILABLE, status || HttpStatus.SERVICE_UNAVAILABLE,
); );
} }
@@ -232,7 +231,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async testPrediction(matchId: string): Promise<MatchPredictionDto | null> { async testPrediction(matchId: string): Promise<MatchPredictionDto | null> {
this.logger.log(`[TEST PREDICTION] Syncing match data for ${matchId}...`); this.logger.log(`[TEST PREDICTION] Syncing match data for ${matchId}...`);
// refreshMatch triggers the feeder scraper to get all match info, odds, and lineups and write to DB // refreshMatch triggers the feeder scraper to get all match info, odds, and lineups and write to DB
const refreshResult = await this.feederService.refreshMatch(matchId, 'all'); const refreshResult = await this.feederService.refreshMatch(matchId, "all");
if (!refreshResult.success) { if (!refreshResult.success) {
this.logger.warn( this.logger.warn(
@@ -251,7 +250,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const upcoming = await this.prisma.prediction.findMany({ const upcoming = await this.prisma.prediction.findMany({
where: { where: {
match: { match: {
status: 'NS', status: "NS",
mstUtc: { gte: Math.floor(Date.now() / 1000) }, mstUtc: { gte: Math.floor(Date.now() / 1000) },
}, },
}, },
@@ -260,13 +259,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
include: { homeTeam: true, awayTeam: true, league: true }, include: { homeTeam: true, awayTeam: true, league: true },
}, },
}, },
orderBy: { match: { mstUtc: 'asc' } }, orderBy: { match: { mstUtc: "asc" } },
take: 50, take: 50,
}); });
return { return {
count: upcoming.length, count: upcoming.length,
modelVersion: 'v25-v30-ensemble', modelVersion: "v25-v30-ensemble",
matches: upcoming.map((p) => { matches: upcoming.map((p) => {
const out = p.predictionJson as Record<string, unknown>; const out = p.predictionJson as Record<string, unknown>;
const matchInfo = (out?.match_info || {}) as Record<string, unknown>; const matchInfo = (out?.match_info || {}) as Record<string, unknown>;
@@ -276,9 +275,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
...matchInfo, ...matchInfo,
match_name: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`, match_name: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`,
match_date_ms: Number(p.match.mstUtc) * 1000, match_date_ms: Number(p.match.mstUtc) * 1000,
league: p.match.league?.name || '', league: p.match.league?.name || "",
league_id: p.match.leagueId, league_id: p.match.leagueId,
is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ''), is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ""),
}, },
} as unknown as MatchPredictionDto; } as unknown as MatchPredictionDto;
}), }),
@@ -287,12 +286,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private loadTopLeagueIds(): Set<string> { private loadTopLeagueIds(): Set<string> {
try { try {
const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json'); const topLeaguesPath = path.join(process.cwd(), "top_leagues.json");
if (!fs.existsSync(topLeaguesPath)) { if (!fs.existsSync(topLeaguesPath)) {
return new Set<string>(); return new Set<string>();
} }
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8')); const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
if (!Array.isArray(raw)) { if (!Array.isArray(raw)) {
return new Set<string>(); return new Set<string>();
} }
@@ -318,7 +317,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (match) { if (match) {
return { return {
leagueId: match.leagueId ?? null, leagueId: match.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(match.leagueId ?? ''), isTopLeague: this.topLeagueIds.has(match.leagueId ?? ""),
}; };
} }
@@ -329,7 +328,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return { return {
leagueId: liveMatch?.leagueId ?? null, leagueId: liveMatch?.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ''), isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ""),
}; };
} }
@@ -346,7 +345,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
league_id: league_id:
this.asRecord(response.match_info).league_id ?? matchContext.leagueId, this.asRecord(response.match_info).league_id ?? matchContext.leagueId,
is_top_league: is_top_league:
this.asRecord(response.match_info).is_top_league ?? matchContext.isTopLeague, this.asRecord(response.match_info).is_top_league ??
matchContext.isTopLeague,
}; };
const mainPick = this.enrichPick( const mainPick = this.enrichPick(
@@ -369,9 +369,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
); );
const supportingPicks = Array.isArray(response.supporting_picks) const supportingPicks = Array.isArray(response.supporting_picks)
? response.supporting_picks.map((pick) => ? response.supporting_picks
this.enrichPick(pick, response, matchContext, marketBoard), .map((pick) =>
).filter((pick): pick is NonNullable<typeof pick> => pick !== null) this.enrichPick(pick, response, matchContext, marketBoard),
)
.filter((pick): pick is NonNullable<typeof pick> => pick !== null)
: []; : [];
const betSummary = Array.isArray(response.bet_summary) const betSummary = Array.isArray(response.bet_summary)
@@ -380,8 +382,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
) )
: []; : [];
const mainBand = const mainBand = this.asRecord(mainPick?.confidence_interval).band ?? "LOW";
this.asRecord(mainPick?.confidence_interval).band ?? 'LOW';
const minConfidenceForPlay = this.getMinConfidenceForPlay( const minConfidenceForPlay = this.getMinConfidenceForPlay(
this.asRecord(mainPick).market, this.asRecord(mainPick).market,
matchContext.isTopLeague, matchContext.isTopLeague,
@@ -402,7 +403,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (mainPick && !isMainPlayable) { if (mainPick && !isMainPlayable) {
reasoningFactors.unshift( reasoningFactors.unshift(
this.translateReason('confidence_interval_too_wide_for_main_pick'), this.translateReason("confidence_interval_too_wide_for_main_pick"),
); );
} }
@@ -416,9 +417,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
isMainPlayable isMainPlayable
? String( ? String(
this.asRecord(response.bet_advice).reason || this.asRecord(response.bet_advice).reason ||
'playable_edge_found', "playable_edge_found",
) )
: 'confidence_below_threshold', : "confidence_below_threshold",
), ),
suggested_stake_units: isMainPlayable suggested_stake_units: isMainPlayable
? Number(this.asRecord(response.bet_advice).suggested_stake_units ?? 0) ? Number(this.asRecord(response.bet_advice).suggested_stake_units ?? 0)
@@ -428,15 +429,18 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const enrichedMarketBoard = Object.fromEntries( const enrichedMarketBoard = Object.fromEntries(
Object.entries(marketBoard).map(([market, entry]) => { Object.entries(marketBoard).map(([market, entry]) => {
const record = this.asRecord(entry); const record = this.asRecord(entry);
const pickName = String(record.pick ?? ''); const pickName = String(record.pick ?? "");
if (!pickName || !record.probs || typeof record.probs !== 'object') { if (!pickName || !record.probs || typeof record.probs !== "object") {
return [market, record]; return [market, record];
} }
const syntheticPick = { const syntheticPick = {
market, market,
pick: pickName, pick: pickName,
probability: this.lookupProbability(record.probs as Record<string, unknown>, pickName), probability: this.lookupProbability(
record.probs as Record<string, unknown>,
pickName,
),
confidence: Number(record.confidence ?? 0), confidence: Number(record.confidence ?? 0),
calibrated_confidence: Number(record.confidence ?? 0), calibrated_confidence: Number(record.confidence ?? 0),
raw_confidence: Number(record.confidence ?? 0), raw_confidence: Number(record.confidence ?? 0),
@@ -447,7 +451,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
implied_prob: 0, implied_prob: 0,
play_score: 0, play_score: 0,
playable: false, playable: false,
bet_grade: 'PASS', bet_grade: "PASS",
stake_units: 0, stake_units: 0,
decision_reasons: [], decision_reasons: [],
}; };
@@ -464,7 +468,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
{ {
...record, ...record,
confidence_interval: this.asRecord(enriched?.confidence_interval), confidence_interval: this.asRecord(enriched?.confidence_interval),
confidence_band: this.asRecord(enriched?.confidence_interval).band ?? 'LOW', confidence_band:
this.asRecord(enriched?.confidence_interval).band ?? "LOW",
}, },
]; ];
}), }),
@@ -472,14 +477,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return { return {
...response, ...response,
match_info: matchInfo as MatchPredictionDto['match_info'], match_info: matchInfo as MatchPredictionDto["match_info"],
data_quality: { data_quality: {
...dataQuality, ...dataQuality,
lineup_source: String(dataQuality.lineup_source ?? 'none'), lineup_source: String(dataQuality.lineup_source ?? "none"),
} as MatchPredictionDto['data_quality'], } as MatchPredictionDto["data_quality"],
risk: { risk: {
...risk, ...risk,
surprise_type: this.translateReason(String(risk.surprise_type ?? '')), surprise_type: this.translateReason(String(risk.surprise_type ?? "")),
surprise_reasons: Array.isArray(risk.surprise_reasons) surprise_reasons: Array.isArray(risk.surprise_reasons)
? risk.surprise_reasons.map((reason) => ? risk.surprise_reasons.map((reason) =>
this.translateReason(String(reason)), this.translateReason(String(reason)),
@@ -490,13 +495,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
this.translateReason(String(warning)), this.translateReason(String(warning)),
) )
: [], : [],
} as MatchPredictionDto['risk'], } as MatchPredictionDto["risk"],
main_pick: mainPick, main_pick: mainPick,
value_pick: valuePick, value_pick: valuePick,
aggressive_pick: aggressivePick, aggressive_pick: aggressivePick,
supporting_picks: supportingPicks, supporting_picks: supportingPicks,
bet_summary: betSummary, bet_summary: betSummary,
bet_advice: betAdvice as MatchPredictionDto['bet_advice'], bet_advice: betAdvice as MatchPredictionDto["bet_advice"],
market_board: enrichedMarketBoard, market_board: enrichedMarketBoard,
reasoning_factors: reasoningFactors, reasoning_factors: reasoningFactors,
}; };
@@ -507,14 +512,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
prediction: Record<string, unknown>, prediction: Record<string, unknown>,
matchContext: MatchContext, matchContext: MatchContext,
marketBoard: Record<string, unknown>, marketBoard: Record<string, unknown>,
): MatchPredictionDto['main_pick'] { ): MatchPredictionDto["main_pick"] {
if (!pick || typeof pick !== 'object') { if (!pick || typeof pick !== "object") {
return null; return null;
} }
const record = this.asRecord(pick); const record = this.asRecord(pick);
const market = String(record.market ?? ''); const market = String(record.market ?? "");
const pickName = String(record.pick ?? ''); const pickName = String(record.pick ?? "");
const probs = this.resolveMarketProbabilities(marketBoard, market); const probs = this.resolveMarketProbabilities(marketBoard, market);
const probability = const probability =
this.asNumber(record.probability) || this.asNumber(record.probability) ||
@@ -538,7 +543,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
), ),
riskScore: this.normalizeScore(this.asRecord(prediction.risk).score), riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
lineupSource: String( lineupSource: String(
this.asRecord(prediction.data_quality).lineup_source ?? 'none', this.asRecord(prediction.data_quality).lineup_source ?? "none",
), ),
isTopLeague: matchContext.isTopLeague, isTopLeague: matchContext.isTopLeague,
}); });
@@ -547,10 +552,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
? [...record.decision_reasons] ? [...record.decision_reasons]
: []; : [];
if (!interval.threshold_met) { if (!interval.threshold_met) {
nextReasons.push('confidence_interval_too_wide'); nextReasons.push("confidence_interval_too_wide");
} }
if (interval.band === 'LOW') { if (interval.band === "LOW") {
nextReasons.push('confidence_band_low'); nextReasons.push("confidence_band_low");
} }
const displayOdds = this.normalizeDisplayOdds( const displayOdds = this.normalizeDisplayOdds(
@@ -559,7 +564,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
); );
return { return {
...(record as MatchPredictionDto['main_pick']), ...(record as MatchPredictionDto["main_pick"]),
market, market,
pick: pickName, pick: pickName,
probability, probability,
@@ -573,7 +578,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
odds: displayOdds, odds: displayOdds,
edge: this.asNumber(record.edge), edge: this.asNumber(record.edge),
play_score: this.asNumber(record.play_score), play_score: this.asNumber(record.play_score),
bet_grade: String(record.bet_grade || 'PASS') as 'A' | 'B' | 'C' | 'PASS', bet_grade: String(record.bet_grade || "PASS") as "A" | "B" | "C" | "PASS",
implied_prob: impliedProb, implied_prob: impliedProb,
ev_edge: evEdge, ev_edge: evEdge,
playable: Boolean(record.playable) && interval.threshold_met, playable: Boolean(record.playable) && interval.threshold_met,
@@ -594,10 +599,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
prediction: Record<string, unknown>, prediction: Record<string, unknown>,
matchContext: MatchContext, matchContext: MatchContext,
marketBoard: Record<string, unknown>, marketBoard: Record<string, unknown>,
): MatchPredictionDto['bet_summary'][number] { ): MatchPredictionDto["bet_summary"][number] {
const record = this.asRecord(item); const record = this.asRecord(item);
const market = String(record.market ?? ''); const market = String(record.market ?? "");
const pickName = String(record.pick ?? ''); const pickName = String(record.pick ?? "");
const probs = this.resolveMarketProbabilities(marketBoard, market); const probs = this.resolveMarketProbabilities(marketBoard, market);
const probability = this.lookupProbability(probs, pickName); const probability = this.lookupProbability(probs, pickName);
const calibratedConfidence = const calibratedConfidence =
@@ -621,13 +626,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
), ),
riskScore: this.normalizeScore(this.asRecord(prediction.risk).score), riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
lineupSource: String( lineupSource: String(
this.asRecord(prediction.data_quality).lineup_source ?? 'none', this.asRecord(prediction.data_quality).lineup_source ?? "none",
), ),
isTopLeague: matchContext.isTopLeague, isTopLeague: matchContext.isTopLeague,
}); });
return { return {
...(record as MatchPredictionDto['bet_summary'][number]), ...(record as MatchPredictionDto["bet_summary"][number]),
odds: this.normalizeDisplayOdds(odds, impliedProb), odds: this.normalizeDisplayOdds(odds, impliedProb),
implied_prob: impliedProb, implied_prob: impliedProb,
ev_edge: evEdge, ev_edge: evEdge,
@@ -658,12 +663,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private translateReason(reason: string): string { private translateReason(reason: string): string {
if (!reason) { if (!reason) {
return ''; return "";
} }
const normalized = reason.startsWith('risk:') const normalized = reason.startsWith("risk:") ? reason.slice(5) : reason;
? reason.slice(5)
: reason;
if (this.reasonTranslations[normalized]) { if (this.reasonTranslations[normalized]) {
return this.reasonTranslations[normalized]; return this.reasonTranslations[normalized];
@@ -674,7 +677,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`; return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`;
} }
const negativeEdgeMatch = normalized.match(/^negative_model_edge_([+\-]?[\d.]+)$/); const negativeEdgeMatch = normalized.match(
/^negative_model_edge_([+\-]?[\d.]+)$/,
);
if (negativeEdgeMatch) { if (negativeEdgeMatch) {
return `Model avantajı negatif (${negativeEdgeMatch[1]})`; return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
} }
@@ -692,47 +697,44 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private classifySignalTier( private classifySignalTier(
record: Record<string, unknown>, record: Record<string, unknown>,
interval: { interval: {
band?: 'HIGH' | 'MEDIUM' | 'LOW'; band?: "HIGH" | "MEDIUM" | "LOW";
threshold_met?: boolean; threshold_met?: boolean;
}, },
): 'CORE' | 'VALUE' | 'LEAN' | 'LONGSHOT' | 'PASS' { ): "CORE" | "VALUE" | "LEAN" | "LONGSHOT" | "PASS" {
const playable = Boolean(record.playable) && Boolean(interval.threshold_met); const playable =
Boolean(record.playable) && Boolean(interval.threshold_met);
const calibratedConfidence = this.asNumber(record.calibrated_confidence); const calibratedConfidence = this.asNumber(record.calibrated_confidence);
const odds = this.asNumber(record.odds); const odds = this.asNumber(record.odds);
const evEdge = this.asNumber(record.ev_edge) || this.asNumber(record.edge); const evEdge = this.asNumber(record.ev_edge) || this.asNumber(record.edge);
const playScore = this.asNumber(record.play_score); const playScore = this.asNumber(record.play_score);
const band = String(interval.band ?? 'LOW').toUpperCase(); const band = String(interval.band ?? "LOW").toUpperCase();
if ( if (
playable && playable &&
band === 'HIGH' && band === "HIGH" &&
calibratedConfidence >= 72 && calibratedConfidence >= 72 &&
evEdge >= 0.02 && evEdge >= 0.02 &&
playScore >= 68 playScore >= 68
) { ) {
return 'CORE'; return "CORE";
} }
if ( if (calibratedConfidence >= 52 && odds >= 1.75 && evEdge >= 0.04) {
calibratedConfidence >= 52 && return playable ? "VALUE" : "LONGSHOT";
odds >= 1.75 &&
evEdge >= 0.04
) {
return playable ? 'VALUE' : 'LONGSHOT';
} }
if ( if (
calibratedConfidence >= 46 && calibratedConfidence >= 46 &&
(band === 'HIGH' || band === 'MEDIUM' || evEdge > 0) (band === "HIGH" || band === "MEDIUM" || evEdge > 0)
) { ) {
return 'LEAN'; return "LEAN";
} }
if (odds >= 2.2 && calibratedConfidence >= 38) { if (odds >= 2.2 && calibratedConfidence >= 38) {
return 'LONGSHOT'; return "LONGSHOT";
} }
return 'PASS'; return "PASS";
} }
private estimateConfidenceInterval(input: { private estimateConfidenceInterval(input: {
@@ -755,7 +757,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const secondProb = sortedProbs[1] ?? 0; const secondProb = sortedProbs[1] ?? 0;
const topProb = sortedProbs[0] ?? probability; const topProb = sortedProbs[0] ?? probability;
const margin = Math.max(0, topProb - secondProb); const margin = Math.max(0, topProb - secondProb);
const normalizedConfidence = this.normalizePercent(input.calibratedConfidence); const normalizedConfidence = this.normalizePercent(
input.calibratedConfidence,
);
const baseWidthByMarket: Record<string, number> = { const baseWidthByMarket: Record<string, number> = {
MS: 0.18, MS: 0.18,
@@ -767,19 +771,19 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}; };
const baseWidth = baseWidthByMarket[input.market] ?? 0.19; const baseWidth = baseWidthByMarket[input.market] ?? 0.19;
const lineupPenalty = const lineupPenalty =
input.lineupSource === 'confirmed_live' input.lineupSource === "confirmed_live"
? -0.015 ? -0.015
: input.lineupSource === 'probable_xi' : input.lineupSource === "probable_xi"
? 0 ? 0
: 0.02; : 0.02;
const width = this.clamp( const width = this.clamp(
baseWidth baseWidth -
- margin * 0.22 margin * 0.22 -
- normalizedConfidence * 0.05 normalizedConfidence * 0.05 +
+ (1 - input.dataQualityScore) * 0.09 (1 - input.dataQualityScore) * 0.09 +
+ input.riskScore * 0.08 input.riskScore * 0.08 -
- (input.isTopLeague ? 0.012 : 0) (input.isTopLeague ? 0.012 : 0) +
+ lineupPenalty, lineupPenalty,
0.08, 0.08,
0.34, 0.34,
); );
@@ -795,17 +799,17 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
width <= this.getMaxAllowedWidth(input.market) && width <= this.getMaxAllowedWidth(input.market) &&
input.dataQualityScore >= 0.58 && input.dataQualityScore >= 0.58 &&
input.evEdge >= this.getMinEdge(input.market) && input.evEdge >= this.getMinEdge(input.market) &&
(upper - input.impliedProb) >= 0.03; upper - input.impliedProb >= 0.03;
let band: ConfidenceBand = 'LOW'; let band: ConfidenceBand = "LOW";
if (input.calibratedConfidence >= 69 && width <= 0.12 && margin >= 0.07) { if (input.calibratedConfidence >= 69 && width <= 0.12 && margin >= 0.07) {
band = 'HIGH'; band = "HIGH";
} else if ( } else if (
input.calibratedConfidence >= 58 && input.calibratedConfidence >= 58 &&
width <= 0.18 && width <= 0.18 &&
margin >= 0.035 margin >= 0.035
) { ) {
band = 'MEDIUM'; band = "MEDIUM";
} }
return { return {
@@ -864,7 +868,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
): Record<string, unknown> { ): Record<string, unknown> {
const entry = this.asRecord(marketBoard[market]); const entry = this.asRecord(marketBoard[market]);
const probs = entry.probs; const probs = entry.probs;
return probs && typeof probs === 'object' return probs && typeof probs === "object"
? (probs as Record<string, unknown>) ? (probs as Record<string, unknown>)
: {}; : {};
} }
@@ -895,7 +899,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private normalizeScore(value: unknown): number { private normalizeScore(value: unknown): number {
const numeric = this.asNumber(value); const numeric = this.asNumber(value);
return numeric > 1 ? this.clamp(numeric / 100, 0, 1) : this.clamp(numeric, 0, 1); return numeric > 1
? this.clamp(numeric / 100, 0, 1)
: this.clamp(numeric, 0, 1);
} }
private normalizePercent(value: number): number { private normalizePercent(value: number): number {
@@ -903,15 +909,15 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
} }
private asRecord(value: unknown): Record<string, any> { private asRecord(value: unknown): Record<string, any> {
return value && typeof value === 'object' return value && typeof value === "object"
? (value as Record<string, any>) ? (value as Record<string, any>)
: {}; : {};
} }
private asNumber(value: unknown): number { private asNumber(value: unknown): number {
return typeof value === 'number' return typeof value === "number"
? value ? value
: typeof value === 'string' : typeof value === "string"
? Number(value) || 0 ? Number(value) || 0
: 0; : 0;
} }
@@ -922,7 +928,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async getValueBets(): Promise<ValueBetDto[]> { async getValueBets(): Promise<ValueBetDto[]> {
const predictions = await this.prisma.prediction.findMany({ const predictions = await this.prisma.prediction.findMany({
where: { match: { status: 'NS' } }, where: { match: { status: "NS" } },
include: { match: { include: { homeTeam: true, awayTeam: true } } }, include: { match: { include: { homeTeam: true, awayTeam: true } } },
}); });
@@ -937,14 +943,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
valueBets.push({ valueBets.push({
matchId: p.matchId, matchId: p.matchId,
matchName: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`, matchName: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`,
betType: (vb.market || vb.betType || '') as string, betType: (vb.market || vb.betType || "") as string,
prediction: (vb.pick || vb.prediction || '') as string, prediction: (vb.pick || vb.prediction || "") as string,
confidence: typeof vb.confidence === 'number' ? vb.confidence : 0, confidence: typeof vb.confidence === "number" ? vb.confidence : 0,
odd: typeof vb.odd === 'number' ? vb.odd : 0, odd: typeof vb.odd === "number" ? vb.odd : 0,
expectedValue: expectedValue:
typeof vb.edge === 'number' typeof vb.edge === "number"
? vb.edge ? vb.edge
: typeof vb.expectedValue === 'number' : typeof vb.expectedValue === "number"
? vb.expectedValue ? vb.expectedValue
: 0, : 0,
}); });
@@ -959,7 +965,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async getSmartCoupon( async getSmartCoupon(
matchIds: string[], matchIds: string[],
strategy: string = 'BALANCED', strategy: string = "BALANCED",
options: { maxMatches?: number; minConfidence?: number } = {}, options: { maxMatches?: number; minConfidence?: number } = {},
): Promise<any> { ): Promise<any> {
await this.ensureSmartCouponDataReady(matchIds); await this.ensureSmartCouponDataReady(matchIds);
@@ -997,23 +1003,23 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private throwAiError(message: string): never { private throwAiError(message: string): never {
if ( if (
message.includes('timed out') || message.includes("timed out") ||
message.includes('AI_ENGINE_TIMEOUT') || message.includes("AI_ENGINE_TIMEOUT") ||
message.includes('AI_ENGINE_504') message.includes("AI_ENGINE_504")
) { ) {
throw new HttpException( throw new HttpException(
'Prediction request timed out', "Prediction request timed out",
HttpStatus.GATEWAY_TIMEOUT, HttpStatus.GATEWAY_TIMEOUT,
); );
} }
if (message.includes('AI_ENGINE_502')) { if (message.includes("AI_ENGINE_502")) {
throw new HttpException( throw new HttpException(
'AI Engine upstream returned 502', "AI Engine upstream returned 502",
HttpStatus.BAD_GATEWAY, HttpStatus.BAD_GATEWAY,
); );
} }
throw new HttpException( throw new HttpException(
'Failed to get prediction from AI Engine', "Failed to get prediction from AI Engine",
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
); );
} }
@@ -1066,12 +1072,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
} }
const cached = prediction.predictionJson as Record<string, unknown>; const cached = prediction.predictionJson as Record<string, unknown>;
const modelVersion = cached['model_version']; const modelVersion = cached["model_version"];
if (typeof modelVersion !== 'string') { if (typeof modelVersion !== "string") {
return null; return null;
} }
if (!modelVersion.startsWith('v25')) { if (!modelVersion.startsWith("v25")) {
return null; return null;
} }
@@ -1082,7 +1088,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const uniqueMatchIds = [...new Set(matchIds.filter((id) => !!id))]; const uniqueMatchIds = [...new Set(matchIds.filter((id) => !!id))];
if (uniqueMatchIds.length === 0) { if (uniqueMatchIds.length === 0) {
throw new HttpException( throw new HttpException(
'No matchIds provided for smart coupon generation', "No matchIds provided for smart coupon generation",
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
@@ -1122,7 +1128,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const hasLiveOdds = const hasLiveOdds =
!!liveMatch?.odds && !!liveMatch?.odds &&
typeof liveMatch.odds === 'object' && typeof liveMatch.odds === "object" &&
!Array.isArray(liveMatch.odds) && !Array.isArray(liveMatch.odds) &&
Object.keys(liveMatch.odds as Record<string, unknown>).length > 0; Object.keys(liveMatch.odds as Record<string, unknown>).length > 0;
const matchExists = !!liveMatch?.id || !!persistedMatch?.id; const matchExists = !!liveMatch?.id || !!persistedMatch?.id;
@@ -1146,9 +1152,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const isFinished = const isFinished =
hasScores || hasScores ||
state === 'MS' || state === "MS" ||
state === 'postGame' || state === "postGame" ||
['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'].includes( ["Finished", "Played", "FT", "AET", "PEN", "Ended"].includes(
status as string, status as string,
); );
@@ -1,18 +1,18 @@
/* eslint-disable @typescript-eslint/unbound-method */ /* eslint-disable @typescript-eslint/unbound-method */
import axios from 'axios'; import axios from "axios";
import { PredictionJobType } from './predictions.types'; import { PredictionJobType } from "./predictions.types";
import { PredictionsProcessor } from './predictions.processor'; import { PredictionsProcessor } from "./predictions.processor";
jest.mock('axios'); jest.mock("axios");
const mockedAxios = axios as jest.Mocked<typeof axios>; const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('PredictionsProcessor', () => { describe("PredictionsProcessor", () => {
let processor: PredictionsProcessor; let processor: PredictionsProcessor;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
process.env.AI_ENGINE_URL = 'http://unit-ai:8000'; process.env.AI_ENGINE_URL = "http://unit-ai:8000";
processor = new PredictionsProcessor(); processor = new PredictionsProcessor();
}); });
@@ -20,34 +20,34 @@ describe('PredictionsProcessor', () => {
delete process.env.AI_ENGINE_URL; delete process.env.AI_ENGINE_URL;
}); });
it('posts to analyze endpoint for predict-match jobs', async () => { it("posts to analyze endpoint for predict-match jobs", async () => {
mockedAxios.post.mockResolvedValueOnce({ data: { ok: true } } as any); mockedAxios.post.mockResolvedValueOnce({ data: { ok: true } } as any);
const job = { const job = {
id: 'j1', id: "j1",
name: PredictionJobType.PREDICT_MATCH, name: PredictionJobType.PREDICT_MATCH,
data: { matchId: 'match-123' }, data: { matchId: "match-123" },
} as any; } as any;
const result = await processor.process(job); const result = await processor.process(job);
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
expect(mockedAxios.post).toHaveBeenCalledWith( expect(mockedAxios.post).toHaveBeenCalledWith(
'http://unit-ai:8000/v20plus/analyze/match-123', "http://unit-ai:8000/v20plus/analyze/match-123",
{}, {},
{ timeout: 30000 }, { timeout: 30000 },
); );
}); });
it('posts mapped payload to coupon endpoint for smart-coupon jobs', async () => { it("posts mapped payload to coupon endpoint for smart-coupon jobs", async () => {
mockedAxios.post.mockResolvedValueOnce({ data: { bets: [] } } as any); mockedAxios.post.mockResolvedValueOnce({ data: { bets: [] } } as any);
const job = { const job = {
id: 'j2', id: "j2",
name: PredictionJobType.SMART_COUPON, name: PredictionJobType.SMART_COUPON,
data: { data: {
matchIds: ['m1', 'm2'], matchIds: ["m1", "m2"],
strategy: 'BALANCED', strategy: "BALANCED",
options: { maxMatches: 4, minConfidence: 65 }, options: { maxMatches: 4, minConfidence: 65 },
}, },
} as any; } as any;
@@ -56,10 +56,10 @@ describe('PredictionsProcessor', () => {
expect(result).toEqual({ bets: [] }); expect(result).toEqual({ bets: [] });
expect(mockedAxios.post).toHaveBeenCalledWith( expect(mockedAxios.post).toHaveBeenCalledWith(
'http://unit-ai:8000/v20plus/coupon', "http://unit-ai:8000/v20plus/coupon",
{ {
match_ids: ['m1', 'm2'], match_ids: ["m1", "m2"],
strategy: 'BALANCED', strategy: "BALANCED",
max_matches: 4, max_matches: 4,
min_confidence: 65, min_confidence: 65,
}, },
@@ -67,15 +67,15 @@ describe('PredictionsProcessor', () => {
); );
}); });
it('throws for unknown job type', async () => { it("throws for unknown job type", async () => {
const job = { const job = {
id: 'j3', id: "j3",
name: 'unknown-job', name: "unknown-job",
data: {}, data: {},
} as any; } as any;
await expect(processor.process(job)).rejects.toThrow( await expect(processor.process(job)).rejects.toThrow(
'Unknown job type: unknown-job', "Unknown job type: unknown-job",
); );
}); });
}); });
@@ -1,14 +1,14 @@
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ /* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Processor, WorkerHost } from "@nestjs/bullmq";
import { Logger } from '@nestjs/common'; import { Logger } from "@nestjs/common";
import { Job } from 'bullmq'; import { Job } from "bullmq";
import { import {
PREDICTIONS_QUEUE, PREDICTIONS_QUEUE,
PredictionJobType, PredictionJobType,
PredictMatchJobData, PredictMatchJobData,
SmartCouponJobData, SmartCouponJobData,
} from './predictions.types'; } from "./predictions.types";
import axios from 'axios'; import axios from "axios";
/** /**
* Predictions Processor * Predictions Processor
@@ -22,7 +22,7 @@ export class PredictionsProcessor extends WorkerHost {
constructor() { constructor() {
super(); super();
// Default to container service URL // Default to container service URL
this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000'; this.aiEngineUrl = process.env.AI_ENGINE_URL || "http://ai-engine:8000";
} }
async process(job: Job<any, any, string>): Promise<any> { async process(job: Job<any, any, string>): Promise<any> {
@@ -56,7 +56,7 @@ export class PredictionsProcessor extends WorkerHost {
); );
return response.data; return response.data;
} catch (error) { } catch (error) {
throw this.mapAxiosError(error, matchId, 'predict'); throw this.mapAxiosError(error, matchId, "predict");
} }
} }
@@ -81,14 +81,14 @@ export class PredictionsProcessor extends WorkerHost {
); );
return response.data; return response.data;
} catch (error) { } catch (error) {
throw this.mapAxiosError(error, matchIds.join(','), 'smart-coupon'); throw this.mapAxiosError(error, matchIds.join(","), "smart-coupon");
} }
} }
private mapAxiosError( private mapAxiosError(
error: unknown, error: unknown,
identifier: string, identifier: string,
flow: 'predict' | 'smart-coupon', flow: "predict" | "smart-coupon",
): Error { ): Error {
if (!axios.isAxiosError(error)) { if (!axios.isAxiosError(error)) {
return error instanceof Error return error instanceof Error
@@ -98,7 +98,7 @@ export class PredictionsProcessor extends WorkerHost {
const status = error.response?.status; const status = error.response?.status;
const detail = error.response?.data?.detail || error.message; const detail = error.response?.data?.detail || error.message;
const code = error.code || ''; const code = error.code || "";
if (status === 502) { if (status === 502) {
this.logger.error(`AI Engine 502 (${flow}:${identifier}): ${detail}`); this.logger.error(`AI Engine 502 (${flow}:${identifier}): ${detail}`);
@@ -110,13 +110,13 @@ export class PredictionsProcessor extends WorkerHost {
return new Error(`AI_ENGINE_504|${flow}|${detail}`); return new Error(`AI_ENGINE_504|${flow}|${detail}`);
} }
if (code === 'ECONNABORTED' || code === 'ETIMEDOUT') { if (code === "ECONNABORTED" || code === "ETIMEDOUT") {
this.logger.error(`AI Engine timeout (${flow}:${identifier}): ${detail}`); this.logger.error(`AI Engine timeout (${flow}:${identifier}): ${detail}`);
return new Error(`AI_ENGINE_TIMEOUT|${flow}|${detail}`); return new Error(`AI_ENGINE_TIMEOUT|${flow}|${detail}`);
} }
this.logger.error( this.logger.error(
`AI Engine error (${flow}:${identifier}) [${status ?? 'N/A'}]: ${detail}`, `AI Engine error (${flow}:${identifier}) [${status ?? "N/A"}]: ${detail}`,
); );
return new Error(`AI_ENGINE_ERROR|${flow}|${detail}`); return new Error(`AI_ENGINE_ERROR|${flow}|${detail}`);
} }
@@ -1,12 +1,12 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from "@nestjs/common";
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from "@nestjs/bullmq";
import { Queue } from 'bullmq'; import { Queue } from "bullmq";
import { import {
PREDICTIONS_QUEUE, PREDICTIONS_QUEUE,
PredictionJobType, PredictionJobType,
PredictMatchJobData, PredictMatchJobData,
SmartCouponJobData, SmartCouponJobData,
} from './predictions.types'; } from "./predictions.types";
@Injectable() @Injectable()
export class PredictionsQueue { export class PredictionsQueue {
@@ -3,11 +3,11 @@
* Senior Level Strict Typing * Senior Level Strict Typing
*/ */
export const PREDICTIONS_QUEUE = 'predictions-queue'; export const PREDICTIONS_QUEUE = "predictions-queue";
export enum PredictionJobType { export enum PredictionJobType {
PREDICT_MATCH = 'predict-match', PREDICT_MATCH = "predict-match",
SMART_COUPON = 'smart-coupon', SMART_COUPON = "smart-coupon",
} }
export interface PredictMatchJobData { export interface PredictMatchJobData {
@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from '../../../database/prisma.service'; import { PrismaService } from "../../../database/prisma.service";
@Injectable() @Injectable()
export class AiFeatureStoreService { export class AiFeatureStoreService {
@@ -16,10 +16,10 @@ export class AiFeatureStoreService {
where: { id: matchId }, where: { id: matchId },
include: { include: {
homeTeam: { homeTeam: {
include: { homeMatches: { take: 5, orderBy: { mstUtc: 'desc' } } }, include: { homeMatches: { take: 5, orderBy: { mstUtc: "desc" } } },
}, },
awayTeam: { awayTeam: {
include: { awayMatches: { take: 5, orderBy: { mstUtc: 'desc' } } }, include: { awayMatches: { take: 5, orderBy: { mstUtc: "desc" } } },
}, },
}, },
}); });
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from "@nestjs/common";
import { GeminiService } from '../gemini/gemini.service'; import { GeminiService } from "../gemini/gemini.service";
import { PredictionCardDto } from './dto/prediction-card.dto'; import { PredictionCardDto } from "./dto/prediction-card.dto";
const SYSTEM_PROMPT = `Sen profesyonel bir spor analisti ve sosyal medya içerik üreticisisin. const SYSTEM_PROMPT = `Sen profesyonel bir spor analisti ve sosyal medya içerik üreticisisin.
Verilen maç tahmin verisini kullanarak kısa, etkili ve ilgi çekici sosyal medya postları yazıyorsun. Verilen maç tahmin verisini kullanarak kısa, etkili ve ilgi çekici sosyal medya postları yazıyorsun.
@@ -28,7 +28,7 @@ export class CaptionGeneratorService {
*/ */
async generateCaption(card: PredictionCardDto): Promise<string> { async generateCaption(card: PredictionCardDto): Promise<string> {
if (!this.geminiService.isAvailable()) { if (!this.geminiService.isAvailable()) {
this.logger.warn('Gemini not available, using template caption'); this.logger.warn("Gemini not available, using template caption");
return this.generateFallbackCaption(card); return this.generateFallbackCaption(card);
} }
@@ -48,7 +48,7 @@ export class CaptionGeneratorService {
); );
return caption; return caption;
} catch (error) { } catch (error) {
this.logger.error('Gemini caption generation failed', error); this.logger.error("Gemini caption generation failed", error);
return this.generateFallbackCaption(card); return this.generateFallbackCaption(card);
} }
} }
@@ -59,7 +59,7 @@ export class CaptionGeneratorService {
(p, i) => (p, i) =>
`${i + 1}. ${p.market} (${p.marketEn}) — ${p.pick} — Güven: %${p.confidence} — Oran: ${p.odds}`, `${i + 1}. ${p.market} (${p.marketEn}) — ${p.pick} — Güven: %${p.confidence} — Oran: ${p.odds}`,
) )
.join('\n'); .join("\n");
return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur: return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur:
@@ -79,12 +79,12 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`;
private ensureHashtags(text: string, card: PredictionCardDto): string { private ensureHashtags(text: string, card: PredictionCardDto): string {
// If no hashtags in text, add them // If no hashtags in text, add them
if (!text.includes('#')) { if (!text.includes("#")) {
const leagueTag = card.leagueName const leagueTag = card.leagueName
.replace(/\s+/g, '') .replace(/\s+/g, "")
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, ''); .replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
const homeTag = card.homeTeam.replace(/\s+/g, ''); const homeTag = card.homeTeam.replace(/\s+/g, "");
const awayTag = card.awayTeam.replace(/\s+/g, ''); const awayTag = card.awayTeam.replace(/\s+/g, "");
text += `\n\n#${leagueTag} #${homeTag} #${awayTag}`; text += `\n\n#${leagueTag} #${homeTag} #${awayTag}`;
} }
return text.trim(); return text.trim();
@@ -96,13 +96,13 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`;
private generateFallbackCaption(card: PredictionCardDto): string { private generateFallbackCaption(card: PredictionCardDto): string {
const topPick = card.topPicks[0]; const topPick = card.topPicks[0];
const leagueTag = card.leagueName const leagueTag = card.leagueName
.replace(/\s+/g, '') .replace(/\s+/g, "")
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, ''); .replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
return `${card.homeTeam} vs ${card.awayTeam} return `${card.homeTeam} vs ${card.awayTeam}
🎯 Tahminimiz: ${card.ftScore} (İY: ${card.htScore}) 🎯 Tahminimiz: ${card.ftScore} (İY: ${card.htScore})
📊 Güven: %${card.scoreConfidence} 📊 Güven: %${card.scoreConfidence}
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ''} ${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ""}
#${leagueTag} #SuggestBet #Bahis`.trim(); #${leagueTag} #SuggestBet #Bahis`.trim();
} }
@@ -42,7 +42,7 @@ export interface PredictionCardDto {
topPicks: TopPick[]; topPicks: TopPick[];
// ─── Risk ─── // ─── Risk ───
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME'; riskLevel: "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
// ─── Raw prediction JSON (for Gemini caption) ─── // ─── Raw prediction JSON (for Gemini caption) ───
rawPrediction?: Record<string, any>; rawPrediction?: Record<string, any>;
@@ -1,17 +1,17 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import * as fs from 'fs'; import * as fs from "fs";
import * as path from 'path'; import * as path from "path";
import axios from 'axios'; import axios from "axios";
import { createCanvas, loadImage } from 'canvas'; import { createCanvas, loadImage } from "canvas";
import { PredictionCardDto } from './dto/prediction-card.dto'; import { PredictionCardDto } from "./dto/prediction-card.dto";
@Injectable() @Injectable()
export class ImageRendererService implements OnModuleInit { export class ImageRendererService implements OnModuleInit {
private readonly logger = new Logger(ImageRendererService.name); private readonly logger = new Logger(ImageRendererService.name);
private readonly outputDir = path.join( private readonly outputDir = path.join(
process.cwd(), process.cwd(),
'public', "public",
'predictions', "predictions",
); );
onModuleInit() { onModuleInit() {
@@ -53,8 +53,8 @@ export class ImageRendererService implements OnModuleInit {
try { try {
// Case 1: Local relative path → read from public/ directory // Case 1: Local relative path → read from public/ directory
if (url.startsWith('/')) { if (url.startsWith("/")) {
const localPath = path.join(process.cwd(), 'public', url); const localPath = path.join(process.cwd(), "public", url);
if (fs.existsSync(localPath)) { if (fs.existsSync(localPath)) {
this.logger.debug(`Loading logo from local file: ${localPath}`); this.logger.debug(`Loading logo from local file: ${localPath}`);
return await loadImage(localPath); return await loadImage(localPath);
@@ -66,9 +66,9 @@ export class ImageRendererService implements OnModuleInit {
} }
// Case 2: Full HTTP/HTTPS URL → fetch directly // Case 2: Full HTTP/HTTPS URL → fetch directly
if (url.startsWith('http')) { if (url.startsWith("http")) {
const response = await axios.get(url, { const response = await axios.get(url, {
responseType: 'arraybuffer', responseType: "arraybuffer",
timeout: 5000, timeout: 5000,
}); });
return await loadImage(response.data); return await loadImage(response.data);
@@ -133,14 +133,14 @@ export class ImageRendererService implements OnModuleInit {
const width = 1080; const width = 1080;
const height = 1920; const height = 1920;
const canvas = createCanvas(width, height); const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext("2d");
// Background Gradient // Background Gradient
const bgGrad = ctx.createLinearGradient(0, 0, width, height); const bgGrad = ctx.createLinearGradient(0, 0, width, height);
bgGrad.addColorStop(0, '#0a0e27'); bgGrad.addColorStop(0, "#0a0e27");
bgGrad.addColorStop(0.35, '#1a1040'); bgGrad.addColorStop(0.35, "#1a1040");
bgGrad.addColorStop(0.7, '#0d1b2a'); bgGrad.addColorStop(0.7, "#0d1b2a");
bgGrad.addColorStop(1, '#0a0e27'); bgGrad.addColorStop(1, "#0a0e27");
ctx.fillStyle = bgGrad; ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
@@ -148,12 +148,12 @@ export class ImageRendererService implements OnModuleInit {
ctx.save(); ctx.save();
ctx.translate(width / 2, height / 2); ctx.translate(width / 2, height / 2);
ctx.rotate((-35 * Math.PI) / 180); ctx.rotate((-35 * Math.PI) / 180);
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)'; ctx.fillStyle = "rgba(255, 255, 255, 0.05)";
ctx.font = '900 100px sans-serif'; ctx.font = "900 100px sans-serif";
ctx.textAlign = 'center'; ctx.textAlign = "center";
ctx.textBaseline = 'middle'; ctx.textBaseline = "middle";
const wmLine = const wmLine =
'iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com'; "iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com";
for (let i = -15; i <= 15; i++) { for (let i = -15; i <= 15; i++) {
ctx.fillText(wmLine, 0, i * 180); ctx.fillText(wmLine, 0, i * 180);
} }
@@ -163,14 +163,14 @@ export class ImageRendererService implements OnModuleInit {
const paddingX = 80; const paddingX = 80;
// Header // Header
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; ctx.fillStyle = "rgba(255, 255, 255, 0.7)";
ctx.font = '600 28px sans-serif'; ctx.font = "600 28px sans-serif";
ctx.textAlign = 'left'; ctx.textAlign = "left";
ctx.fillText(data.leagueName.toUpperCase(), paddingX, 120); ctx.fillText(data.leagueName.toUpperCase(), paddingX, 120);
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)'; ctx.fillStyle = "rgba(255, 255, 255, 0.45)";
ctx.font = '400 22px sans-serif'; ctx.font = "400 22px sans-serif";
ctx.textAlign = 'right'; ctx.textAlign = "right";
ctx.fillText(data.matchDate, width - paddingX, 120); ctx.fillText(data.matchDate, width - paddingX, 120);
// Teams Section // Teams Section
@@ -184,31 +184,31 @@ export class ImageRendererService implements OnModuleInit {
if (awayImg) if (awayImg)
ctx.drawImage(awayImg, (width / 4) * 3 - 100, currentY, 200, 200); ctx.drawImage(awayImg, (width / 4) * 3 - 100, currentY, 200, 200);
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)'; ctx.fillStyle = "rgba(255, 255, 255, 0.15)";
ctx.font = '900 56px sans-serif'; ctx.font = "900 56px sans-serif";
ctx.textAlign = 'center'; ctx.textAlign = "center";
ctx.fillText('VS', width / 2, currentY + 110); ctx.fillText("VS", width / 2, currentY + 110);
currentY += 250; currentY += 250;
ctx.fillStyle = '#ffffff'; ctx.fillStyle = "#ffffff";
ctx.font = '700 36px sans-serif'; ctx.font = "700 36px sans-serif";
ctx.textAlign = 'center'; ctx.textAlign = "center";
ctx.fillText(data.homeTeam, width / 4, currentY); ctx.fillText(data.homeTeam, width / 4, currentY);
ctx.fillText(data.awayTeam, (width / 4) * 3, currentY); ctx.fillText(data.awayTeam, (width / 4) * 3, currentY);
// Divider: Skore Prediction // Divider: Skore Prediction
currentY += 140; currentY += 140;
const drawSectionTitle = (y: number, text: string) => { const drawSectionTitle = (y: number, text: string) => {
ctx.textAlign = 'center'; ctx.textAlign = "center";
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; ctx.fillStyle = "rgba(255, 255, 255, 0.5)";
ctx.font = '600 22px sans-serif'; ctx.font = "600 22px sans-serif";
ctx.fillText(text, width / 2, y + 8); ctx.fillText(text, width / 2, y + 8);
const txtWidth = ctx.measureText(text).width; const txtWidth = ctx.measureText(text).width;
const grad = ctx.createLinearGradient(paddingX, y, width - paddingX, y); const grad = ctx.createLinearGradient(paddingX, y, width - paddingX, y);
grad.addColorStop(0, 'rgba(120, 80, 255, 0)'); grad.addColorStop(0, "rgba(120, 80, 255, 0)");
grad.addColorStop(0.5, 'rgba(120, 80, 255, 0.6)'); grad.addColorStop(0.5, "rgba(120, 80, 255, 0.6)");
grad.addColorStop(1, 'rgba(120, 80, 255, 0)'); grad.addColorStop(1, "rgba(120, 80, 255, 0)");
ctx.fillStyle = grad; ctx.fillStyle = grad;
ctx.fillRect( ctx.fillRect(
@@ -225,7 +225,7 @@ export class ImageRendererService implements OnModuleInit {
); );
}; };
drawSectionTitle(currentY, 'SKOR TAHMİNİ / SCORE PREDICTION'); drawSectionTitle(currentY, "SKOR TAHMİNİ / SCORE PREDICTION");
// Scores // Scores
currentY += 80; currentY += 80;
@@ -235,20 +235,20 @@ export class ImageRendererService implements OnModuleInit {
const ftX = width / 2 + 24; const ftX = width / 2 + 24;
// HT Box // HT Box
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)'; ctx.fillStyle = "rgba(255, 255, 255, 0.04)";
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; ctx.strokeStyle = "rgba(255, 255, 255, 0.08)";
ctx.lineWidth = 2; ctx.lineWidth = 2;
this.fillRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20); this.fillRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
this.strokeRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20); this.strokeRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)'; ctx.fillStyle = "rgba(255, 255, 255, 0.45)";
ctx.font = '600 20px sans-serif'; ctx.font = "600 20px sans-serif";
ctx.fillText('İLK YARI', htX + scoreBoxWidth / 2, currentY + 40); ctx.fillText("İLK YARI", htX + scoreBoxWidth / 2, currentY + 40);
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)'; ctx.fillStyle = "rgba(255, 255, 255, 0.25)";
ctx.font = '400 16px sans-serif'; ctx.font = "400 16px sans-serif";
ctx.fillText('Half Time', htX + scoreBoxWidth / 2, currentY + 65); ctx.fillText("Half Time", htX + scoreBoxWidth / 2, currentY + 65);
ctx.fillStyle = '#ffffff'; ctx.fillStyle = "#ffffff";
ctx.font = '900 80px sans-serif'; ctx.font = "900 80px sans-serif";
ctx.fillText(data.htScore, htX + scoreBoxWidth / 2, currentY + 160); ctx.fillText(data.htScore, htX + scoreBoxWidth / 2, currentY + 160);
// FT Box // FT Box
@@ -258,19 +258,19 @@ export class ImageRendererService implements OnModuleInit {
ftX + scoreBoxWidth, ftX + scoreBoxWidth,
currentY + scoreBoxHeight, currentY + scoreBoxHeight,
); );
ftGrad.addColorStop(0, 'rgba(120, 80, 255, 0.15)'); ftGrad.addColorStop(0, "rgba(120, 80, 255, 0.15)");
ftGrad.addColorStop(1, 'rgba(0, 200, 255, 0.1)'); ftGrad.addColorStop(1, "rgba(0, 200, 255, 0.1)");
ctx.fillStyle = ftGrad; ctx.fillStyle = ftGrad;
ctx.strokeStyle = 'rgba(120, 80, 255, 0.3)'; ctx.strokeStyle = "rgba(120, 80, 255, 0.3)";
this.fillRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20); this.fillRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
this.strokeRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20); this.strokeRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)'; ctx.fillStyle = "rgba(255, 255, 255, 0.45)";
ctx.font = '600 20px sans-serif'; ctx.font = "600 20px sans-serif";
ctx.fillText('MAÇ SONU', ftX + scoreBoxWidth / 2, currentY + 40); ctx.fillText("MAÇ SONU", ftX + scoreBoxWidth / 2, currentY + 40);
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)'; ctx.fillStyle = "rgba(255, 255, 255, 0.25)";
ctx.font = '400 16px sans-serif'; ctx.font = "400 16px sans-serif";
ctx.fillText('Full Time', ftX + scoreBoxWidth / 2, currentY + 65); ctx.fillText("Full Time", ftX + scoreBoxWidth / 2, currentY + 65);
// Score text gradient // Score text gradient
const txtGrad = ctx.createLinearGradient( const txtGrad = ctx.createLinearGradient(
@@ -279,15 +279,15 @@ export class ImageRendererService implements OnModuleInit {
ftX, ftX,
currentY + 160, currentY + 160,
); );
txtGrad.addColorStop(0, '#9b6fff'); txtGrad.addColorStop(0, "#9b6fff");
txtGrad.addColorStop(1, '#00c8ff'); txtGrad.addColorStop(1, "#00c8ff");
ctx.fillStyle = txtGrad; ctx.fillStyle = txtGrad;
ctx.font = '900 80px sans-serif'; ctx.font = "900 80px sans-serif";
ctx.fillText(data.ftScore, ftX + scoreBoxWidth / 2, currentY + 160); ctx.fillText(data.ftScore, ftX + scoreBoxWidth / 2, currentY + 160);
// Confidence badge // Confidence badge
ctx.fillStyle = '#0a0e27'; ctx.fillStyle = "#0a0e27";
ctx.strokeStyle = 'rgba(120, 80, 255, 0.6)'; ctx.strokeStyle = "rgba(120, 80, 255, 0.6)";
this.fillRoundRect( this.fillRoundRect(
ctx, ctx,
ftX + scoreBoxWidth / 2 - 80, ftX + scoreBoxWidth / 2 - 80,
@@ -304,8 +304,8 @@ export class ImageRendererService implements OnModuleInit {
40, 40,
20, 20,
); );
ctx.fillStyle = '#b89dff'; ctx.fillStyle = "#b89dff";
ctx.font = '800 20px sans-serif'; ctx.font = "800 20px sans-serif";
ctx.fillText( ctx.fillText(
`🎯 %${data.scoreConfidence}`, `🎯 %${data.scoreConfidence}`,
ftX + scoreBoxWidth / 2, ftX + scoreBoxWidth / 2,
@@ -314,13 +314,13 @@ export class ImageRendererService implements OnModuleInit {
// Divider: Picks // Divider: Picks
currentY += scoreBoxHeight + 100; currentY += scoreBoxHeight + 100;
drawSectionTitle(currentY, 'EN İYİ TAHMİNLER / BEST PICKS'); drawSectionTitle(currentY, "EN İYİ TAHMİNLER / BEST PICKS");
// Picks rendering // Picks rendering
currentY += 80; currentY += 80;
data.topPicks.forEach((pick, index) => { data.topPicks.forEach((pick, index) => {
ctx.fillStyle = 'rgba(255, 255, 255, 0.03)'; ctx.fillStyle = "rgba(255, 255, 255, 0.03)";
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; ctx.strokeStyle = "rgba(255, 255, 255, 0.06)";
this.fillRoundRect( this.fillRoundRect(
ctx, ctx,
paddingX, paddingX,
@@ -338,18 +338,18 @@ export class ImageRendererService implements OnModuleInit {
16, 16,
); );
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; ctx.fillStyle = "rgba(255, 255, 255, 0.3)";
ctx.font = '700 28px sans-serif'; ctx.font = "700 28px sans-serif";
ctx.textAlign = 'left'; ctx.textAlign = "left";
ctx.fillText(String(index + 1), paddingX + 30, currentY + 58); ctx.fillText(String(index + 1), paddingX + 30, currentY + 58);
ctx.fillStyle = '#ffffff'; ctx.fillStyle = "#ffffff";
ctx.font = '600 26px sans-serif'; ctx.font = "600 26px sans-serif";
ctx.fillText(pick.market, paddingX + 80, currentY + 45); ctx.fillText(pick.market, paddingX + 80, currentY + 45);
const marketWidth = ctx.measureText(pick.market).width; const marketWidth = ctx.measureText(pick.market).width;
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'; ctx.fillStyle = "rgba(255, 255, 255, 0.35)";
ctx.font = '400 18px sans-serif'; ctx.font = "400 18px sans-serif";
ctx.fillText( ctx.fillText(
`(${pick.marketEn})`, `(${pick.marketEn})`,
paddingX + 80 + marketWidth + 10, paddingX + 80 + marketWidth + 10,
@@ -357,7 +357,7 @@ export class ImageRendererService implements OnModuleInit {
); );
// Pick Bar bg // Pick Bar bg
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)'; ctx.fillStyle = "rgba(255, 255, 255, 0.06)";
const barMaxWidth = width - 2 * paddingX - 220; const barMaxWidth = width - 2 * paddingX - 220;
this.fillRoundRect(ctx, paddingX + 80, currentY + 65, barMaxWidth, 12, 6); this.fillRoundRect(ctx, paddingX + 80, currentY + 65, barMaxWidth, 12, 6);
@@ -369,15 +369,15 @@ export class ImageRendererService implements OnModuleInit {
paddingX + 80 + barMaxWidth, paddingX + 80 + barMaxWidth,
0, 0,
); );
barGrad.addColorStop(0, '#7850ff'); barGrad.addColorStop(0, "#7850ff");
barGrad.addColorStop(1, '#00c8ff'); barGrad.addColorStop(1, "#00c8ff");
ctx.fillStyle = barGrad; ctx.fillStyle = barGrad;
this.fillRoundRect(ctx, paddingX + 80, currentY + 65, fillWidth, 12, 6); this.fillRoundRect(ctx, paddingX + 80, currentY + 65, fillWidth, 12, 6);
// Confidence text // Confidence text
ctx.fillStyle = '#b89dff'; ctx.fillStyle = "#b89dff";
ctx.font = '900 32px sans-serif'; ctx.font = "900 32px sans-serif";
ctx.textAlign = 'right'; ctx.textAlign = "right";
ctx.fillText(`%${pick.confidence}`, width - paddingX - 30, currentY + 58); ctx.fillText(`%${pick.confidence}`, width - paddingX - 30, currentY + 58);
currentY += 124; currentY += 124;
@@ -385,41 +385,41 @@ export class ImageRendererService implements OnModuleInit {
// Footer // Footer
currentY = height - 80; currentY = height - 80;
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'; ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
ctx.font = '700 26px sans-serif'; ctx.font = "700 26px sans-serif";
ctx.textAlign = 'left'; ctx.textAlign = "left";
ctx.fillText('⚡ AI Powered by SuggestBet', paddingX, currentY); ctx.fillText("⚡ AI Powered by SuggestBet", paddingX, currentY);
let riskBg, riskColor, riskBorder; let riskBg, riskColor, riskBorder;
switch (data.riskLevel) { switch (data.riskLevel) {
case 'LOW': case "LOW":
riskBg = 'rgba(0, 200, 100, 0.15)'; riskBg = "rgba(0, 200, 100, 0.15)";
riskColor = '#4ade80'; riskColor = "#4ade80";
riskBorder = 'rgba(0, 200, 100, 0.3)'; riskBorder = "rgba(0, 200, 100, 0.3)";
break; break;
case 'MEDIUM': case "MEDIUM":
riskBg = 'rgba(255, 200, 0, 0.12)'; riskBg = "rgba(255, 200, 0, 0.12)";
riskColor = '#fbbf24'; riskColor = "#fbbf24";
riskBorder = 'rgba(255, 200, 0, 0.25)'; riskBorder = "rgba(255, 200, 0, 0.25)";
break; break;
case 'HIGH': case "HIGH":
riskBg = 'rgba(255, 100, 50, 0.12)'; riskBg = "rgba(255, 100, 50, 0.12)";
riskColor = '#f97316'; riskColor = "#f97316";
riskBorder = 'rgba(255, 100, 50, 0.25)'; riskBorder = "rgba(255, 100, 50, 0.25)";
break; break;
case 'EXTREME': case "EXTREME":
riskBg = 'rgba(255, 50, 50, 0.15)'; riskBg = "rgba(255, 50, 50, 0.15)";
riskColor = '#ef4444'; riskColor = "#ef4444";
riskBorder = 'rgba(255, 50, 50, 0.3)'; riskBorder = "rgba(255, 50, 50, 0.3)";
break; break;
default: default:
riskBg = 'rgba(255, 255, 255, 0.1)'; riskBg = "rgba(255, 255, 255, 0.1)";
riskColor = '#ffffff'; riskColor = "#ffffff";
riskBorder = 'rgba(255, 255, 255, 0.3)'; riskBorder = "rgba(255, 255, 255, 0.3)";
} }
const riskText = `RISK: ${data.riskLevel}`; const riskText = `RISK: ${data.riskLevel}`;
ctx.font = '800 20px sans-serif'; ctx.font = "800 20px sans-serif";
const riskWidth = ctx.measureText(riskText).width; const riskWidth = ctx.measureText(riskText).width;
ctx.fillStyle = riskBg; ctx.fillStyle = riskBg;
ctx.strokeStyle = riskBorder; ctx.strokeStyle = riskBorder;
@@ -441,11 +441,11 @@ export class ImageRendererService implements OnModuleInit {
); );
ctx.fillStyle = riskColor; ctx.fillStyle = riskColor;
ctx.textAlign = 'center'; ctx.textAlign = "center";
ctx.fillText(riskText, width - paddingX - riskWidth / 2 - 24, currentY + 3); ctx.fillText(riskText, width - paddingX - riskWidth / 2 - 24, currentY + 3);
// Save Output directly using the buffer // Save Output directly using the buffer
const buffer = canvas.toBuffer('image/png'); const buffer = canvas.toBuffer("image/png");
fs.writeFileSync(outPath, buffer); fs.writeFileSync(outPath, buffer);
} }
@@ -454,9 +454,9 @@ export class ImageRendererService implements OnModuleInit {
*/ */
getImageUrl(filePath: string): string { getImageUrl(filePath: string): string {
const relativePath = path.relative( const relativePath = path.relative(
path.join(process.cwd(), 'public'), path.join(process.cwd(), "public"),
filePath, filePath,
); );
return `/${relativePath.replace(/\\/g, '/')}`; return `/${relativePath.replace(/\\/g, "/")}`;
} }
} }
+18 -18
View File
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from '@nestjs/config'; import { ConfigService } from "@nestjs/config";
import axios from 'axios'; import axios from "axios";
@Injectable() @Injectable()
export class MetaService { export class MetaService {
@@ -10,21 +10,21 @@ export class MetaService {
private readonly pageId: string; private readonly pageId: string;
private readonly igUserId: string; private readonly igUserId: string;
private readonly isEnabled: boolean; private readonly isEnabled: boolean;
private readonly graphApiBase = 'https://graph.facebook.com/v21.0'; private readonly graphApiBase = "https://graph.facebook.com/v21.0";
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
this.pageAccessToken = this.pageAccessToken =
this.configService.get<string>('META_PAGE_ACCESS_TOKEN') || ''; this.configService.get<string>("META_PAGE_ACCESS_TOKEN") || "";
this.pageId = this.configService.get<string>('META_PAGE_ID') || ''; this.pageId = this.configService.get<string>("META_PAGE_ID") || "";
this.igUserId = this.configService.get<string>('META_IG_USER_ID') || ''; this.igUserId = this.configService.get<string>("META_IG_USER_ID") || "";
this.isEnabled = !!(this.pageAccessToken && this.pageId); this.isEnabled = !!(this.pageAccessToken && this.pageId);
if (this.isEnabled) { if (this.isEnabled) {
this.logger.log('✅ Meta API client initialized'); this.logger.log("✅ Meta API client initialized");
} else { } else {
this.logger.warn( this.logger.warn(
'⚠️ Meta API not configured. Set META_PAGE_ACCESS_TOKEN, META_PAGE_ID, META_IG_USER_ID', "⚠️ Meta API not configured. Set META_PAGE_ACCESS_TOKEN, META_PAGE_ID, META_IG_USER_ID",
); );
} }
} }
@@ -53,7 +53,7 @@ export class MetaService {
imageUrl: string, imageUrl: string,
): Promise<string | null> { ): Promise<string | null> {
if (!this.facebookAvailable) { if (!this.facebookAvailable) {
this.logger.warn('Facebook not available, skipping post'); this.logger.warn("Facebook not available, skipping post");
return null; return null;
} }
@@ -98,7 +98,7 @@ export class MetaService {
imageUrl: string, imageUrl: string,
): Promise<string | null> { ): Promise<string | null> {
if (!this.instagramAvailable) { if (!this.instagramAvailable) {
this.logger.warn('Instagram not available, skipping post'); this.logger.warn("Instagram not available, skipping post");
return null; return null;
} }
@@ -115,7 +115,7 @@ export class MetaService {
const containerId = containerResponse.data?.id; const containerId = containerResponse.data?.id;
if (!containerId) { if (!containerId) {
throw new Error('No container ID returned'); throw new Error("No container ID returned");
} }
// Wait for container processing (IG needs a few seconds) // Wait for container processing (IG needs a few seconds)
@@ -156,25 +156,25 @@ export class MetaService {
`${this.graphApiBase}/${containerId}`, `${this.graphApiBase}/${containerId}`,
{ {
params: { params: {
fields: 'status_code', fields: "status_code",
access_token: this.pageAccessToken, access_token: this.pageAccessToken,
}, },
}, },
); );
const status = response.data?.status_code; const status = response.data?.status_code;
if (status === 'FINISHED') return; if (status === "FINISHED") return;
if (status === 'ERROR') { if (status === "ERROR") {
throw new Error('Container processing failed'); throw new Error("Container processing failed");
} }
} catch (error) { } catch (error) {
if (error.message === 'Container processing failed') throw error; if (error.message === "Container processing failed") throw error;
} }
// Wait 2 seconds before checking again // Wait 2 seconds before checking again
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
} }
this.logger.warn('Container wait timed out, attempting publish anyway'); this.logger.warn("Container wait timed out, attempting publish anyway");
} }
} }
@@ -1,25 +1,25 @@
import { Controller, Post, Param, Get, UseGuards } from '@nestjs/common'; import { Controller, Post, Param, Get, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { SocialPosterService } from './social-poster.service'; import { SocialPosterService } from "./social-poster.service";
import { Roles } from '../../common/decorators'; import { Roles } from "../../common/decorators";
import { RolesGuard } from '../auth/guards/auth.guards'; import { RolesGuard } from "../auth/guards/auth.guards";
@ApiTags('Social Poster') @ApiTags("Social Poster")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles('admin') @Roles("admin")
@Controller('social-poster') @Controller("social-poster")
export class SocialPosterController { export class SocialPosterController {
constructor(private readonly socialPosterService: SocialPosterService) {} constructor(private readonly socialPosterService: SocialPosterService) {}
@Get('preview/:matchId') @Get("preview/:matchId")
async previewCard(@Param('matchId') matchId: string) { async previewCard(@Param("matchId") matchId: string) {
return this.socialPosterService.renderPreview(matchId); return this.socialPosterService.renderPreview(matchId);
} }
@Post('post/:matchId') @Post("post/:matchId")
async postMatch(@Param('matchId') matchId: string) { async postMatch(@Param("matchId") matchId: string) {
return this.socialPosterService.manualPost(matchId); return this.socialPosterService.manualPost(matchId);
} }
} }
@@ -1,14 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from "@nestjs/schedule";
import { SocialPosterService } from './social-poster.service'; import { SocialPosterService } from "./social-poster.service";
import { ImageRendererService } from './image-renderer.service'; import { ImageRendererService } from "./image-renderer.service";
import { CaptionGeneratorService } from './caption-generator.service'; import { CaptionGeneratorService } from "./caption-generator.service";
import { TwitterService } from './twitter.service'; import { TwitterService } from "./twitter.service";
import { MetaService } from './meta.service'; import { MetaService } from "./meta.service";
import { SocialPosterController } from './social-poster.controller'; import { SocialPosterController } from "./social-poster.controller";
/** /**
* Social Poster Module * Social Poster Module
@@ -1,24 +1,24 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from "@nestjs/common";
import { Cron } from '@nestjs/schedule'; import { Cron } from "@nestjs/schedule";
import { ConfigService } from '@nestjs/config'; import { ConfigService } from "@nestjs/config";
import { PrismaService } from '../../database/prisma.service'; import { PrismaService } from "../../database/prisma.service";
import axios from 'axios'; import axios from "axios";
import * as fs from 'fs'; import * as fs from "fs";
import * as path from 'path'; import * as path from "path";
import { ImageRendererService } from './image-renderer.service'; import { ImageRendererService } from "./image-renderer.service";
import { CaptionGeneratorService } from './caption-generator.service'; import { CaptionGeneratorService } from "./caption-generator.service";
import { TwitterService } from './twitter.service'; import { TwitterService } from "./twitter.service";
import { MetaService } from './meta.service'; import { MetaService } from "./meta.service";
import { import {
PredictionCardDto, PredictionCardDto,
TopPick, TopPick,
SocialPostResult, SocialPostResult,
} from './dto/prediction-card.dto'; } from "./dto/prediction-card.dto";
// Top leagues loaded once // Top leagues loaded once
const TOP_LEAGUES_PATH = path.join(process.cwd(), 'top_leagues.json'); const TOP_LEAGUES_PATH = path.join(process.cwd(), "top_leagues.json");
@Injectable() @Injectable()
export class SocialPosterService { export class SocialPosterService {
@@ -38,24 +38,24 @@ export class SocialPosterService {
private readonly metaService: MetaService, private readonly metaService: MetaService,
) { ) {
this.aiEngineUrl = this.aiEngineUrl =
this.configService.get<string>('AI_ENGINE_URL') || this.configService.get<string>("AI_ENGINE_URL") ||
'http://localhost:8000'; "http://localhost:8000";
this.appBaseUrl = this.appBaseUrl =
this.configService.get<string>('APP_BASE_URL') || 'http://localhost:3000'; this.configService.get<string>("APP_BASE_URL") || "http://localhost:3000";
this.isEnabled = this.isEnabled =
this.configService.get<string>('SOCIAL_POSTER_ENABLED') === 'true'; this.configService.get<string>("SOCIAL_POSTER_ENABLED") === "true";
this.loadTopLeagues(); this.loadTopLeagues();
} }
private loadTopLeagues() { private loadTopLeagues() {
try { try {
const data = fs.readFileSync(TOP_LEAGUES_PATH, 'utf-8'); const data = fs.readFileSync(TOP_LEAGUES_PATH, "utf-8");
const ids = JSON.parse(data); const ids = JSON.parse(data);
this.topLeagueIds = new Set(ids); this.topLeagueIds = new Set(ids);
this.logger.log(`✅ Loaded ${this.topLeagueIds.size} top league IDs`); this.logger.log(`✅ Loaded ${this.topLeagueIds.size} top league IDs`);
} catch { } catch {
this.logger.warn('⚠️ Could not load top_leagues.json'); this.logger.warn("⚠️ Could not load top_leagues.json");
} }
} }
@@ -63,7 +63,7 @@ export class SocialPosterService {
* Cron: Every 10 minutes, check for upcoming matches. * Cron: Every 10 minutes, check for upcoming matches.
* Posts predictions 30 minutes before kickoff. * Posts predictions 30 minutes before kickoff.
*/ */
@Cron('*/10 * * * *') @Cron("*/10 * * * *")
async checkAndPostUpcomingMatches() { async checkAndPostUpcomingMatches() {
if (!this.isEnabled) return; if (!this.isEnabled) return;
@@ -115,7 +115,7 @@ export class SocialPosterService {
const matches = await this.prisma.liveMatch.findMany({ const matches = await this.prisma.liveMatch.findMany({
where: { where: {
sport: 'football', sport: "football",
leagueId: { in: Array.from(this.topLeagueIds) }, leagueId: { in: Array.from(this.topLeagueIds) },
mstUtc: { mstUtc: {
gte: minTime, gte: minTime,
@@ -144,7 +144,7 @@ export class SocialPosterService {
// Step 1: Get prediction from AI Engine // Step 1: Get prediction from AI Engine
const prediction = await this.getPrediction(matchId); const prediction = await this.getPrediction(matchId);
if (!prediction) { if (!prediction) {
throw new Error('No prediction returned from AI Engine'); throw new Error("No prediction returned from AI Engine");
} }
// Step 2: Build prediction card data // Step 2: Build prediction card data
@@ -194,9 +194,9 @@ export class SocialPosterService {
this.logger.log( this.logger.log(
`✅ Posted: ${match.homeTeam?.name} vs ${match.awayTeam?.name} ` + `✅ Posted: ${match.homeTeam?.name} vs ${match.awayTeam?.name} ` +
`[TW: ${result.twitterPostId ? '✅' : '❌'}, ` + `[TW: ${result.twitterPostId ? "✅" : "❌"}, ` +
`FB: ${result.facebookPostId ? '✅' : '❌'}, ` + `FB: ${result.facebookPostId ? "✅" : "❌"}, ` +
`IG: ${result.instagramPostId ? '✅' : '❌'}]`, `IG: ${result.instagramPostId ? "✅" : "❌"}]`,
); );
return result; return result;
@@ -229,8 +229,8 @@ export class SocialPosterService {
): PredictionCardDto { ): PredictionCardDto {
// V20+ returns score_prediction.ft / .ht // V20+ returns score_prediction.ft / .ht
const score = prediction.score_prediction || {}; const score = prediction.score_prediction || {};
const htScore = score.ht || '0-0'; const htScore = score.ht || "0-0";
const ftScore = score.ft || '1-1'; const ftScore = score.ft || "1-1";
// Extract best bets from bet_summary array // Extract best bets from bet_summary array
const topPicks = this.extractTopPicks(prediction); const topPicks = this.extractTopPicks(prediction);
@@ -247,18 +247,18 @@ export class SocialPosterService {
return { return {
matchId: match.id, matchId: match.id,
homeTeam: homeTeam:
match.homeTeam?.name || prediction.match_info?.home_team || 'Home', match.homeTeam?.name || prediction.match_info?.home_team || "Home",
awayTeam: awayTeam:
match.awayTeam?.name || prediction.match_info?.away_team || 'Away', match.awayTeam?.name || prediction.match_info?.away_team || "Away",
homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ''), homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ""),
awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ''), awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ""),
leagueName: match.league?.name || prediction.match_info?.league || '', leagueName: match.league?.name || prediction.match_info?.league || "",
matchDate, matchDate,
htScore, htScore,
ftScore, ftScore,
scoreConfidence, scoreConfidence,
topPicks, topPicks,
riskLevel: prediction.risk?.level || 'MEDIUM', riskLevel: prediction.risk?.level || "MEDIUM",
rawPrediction: prediction, rawPrediction: prediction,
}; };
} }
@@ -271,16 +271,16 @@ export class SocialPosterService {
// Market code to Turkish/English label mapping // Market code to Turkish/English label mapping
const marketLabels: Record<string, { tr: string; en: string }> = { const marketLabels: Record<string, { tr: string; en: string }> = {
MS: { tr: 'Maç Sonucu', en: 'Match Result' }, MS: { tr: "Maç Sonucu", en: "Match Result" },
OU15: { tr: 'Üst 1.5 Gol', en: 'Over 1.5' }, OU15: { tr: "Üst 1.5 Gol", en: "Over 1.5" },
OU25: { tr: 'Üst 2.5 Gol', en: 'Over 2.5' }, OU25: { tr: "Üst 2.5 Gol", en: "Over 2.5" },
OU35: { tr: 'Üst 3.5 Gol', en: 'Over 3.5' }, OU35: { tr: "Üst 3.5 Gol", en: "Over 3.5" },
BTTS: { tr: 'Karşılıklı Gol', en: 'Both Teams Score' }, BTTS: { tr: "Karşılıklı Gol", en: "Both Teams Score" },
DC: { tr: 'Çifte Şans', en: 'Double Chance' }, DC: { tr: "Çifte Şans", en: "Double Chance" },
HT: { tr: 'İlk Yarı Sonucu', en: 'Half Time Result' }, HT: { tr: "İlk Yarı Sonucu", en: "Half Time Result" },
HT_OU05: { tr: 'İY 0.5 Üst/Alt', en: 'HT Over/Under 0.5' }, HT_OU05: { tr: "İY 0.5 Üst/Alt", en: "HT Over/Under 0.5" },
OE: { tr: 'Tek/Çift', en: 'Odd/Even' }, OE: { tr: "Tek/Çift", en: "Odd/Even" },
HTFT: { tr: 'İY/MS', en: 'HT/FT' }, HTFT: { tr: "İY/MS", en: "HT/FT" },
}; };
const candidates: TopPick[] = betSummary.map((bet) => { const candidates: TopPick[] = betSummary.map((bet) => {
@@ -308,11 +308,11 @@ export class SocialPosterService {
* Locally during dev, we fetch them from the deployed server via APP_BASE_URL. * Locally during dev, we fetch them from the deployed server via APP_BASE_URL.
*/ */
private resolveLogoUrl(logoUrl: string): string { private resolveLogoUrl(logoUrl: string): string {
if (!logoUrl) return ''; if (!logoUrl) return "";
// Already a full URL // Already a full URL
if (logoUrl.startsWith('http')) return logoUrl; if (logoUrl.startsWith("http")) return logoUrl;
// Relative path → check local first, otherwise make full URL // Relative path → check local first, otherwise make full URL
const localPath = path.join(process.cwd(), 'public', logoUrl); const localPath = path.join(process.cwd(), "public", logoUrl);
if (fs.existsSync(localPath)) return logoUrl; // Keep relative, renderer reads local if (fs.existsSync(localPath)) return logoUrl; // Keep relative, renderer reads local
// Not local → prepend base URL for remote fetch // Not local → prepend base URL for remote fetch
return `${this.appBaseUrl}${logoUrl}`; return `${this.appBaseUrl}${logoUrl}`;
@@ -321,24 +321,24 @@ export class SocialPosterService {
private formatMatchDate(mstUtc: number | bigint): string { private formatMatchDate(mstUtc: number | bigint): string {
const d = new Date(Number(mstUtc)); const d = new Date(Number(mstUtc));
const months = [ const months = [
'Oca', "Oca",
'Şub', "Şub",
'Mar', "Mar",
'Nis', "Nis",
'May', "May",
'Haz', "Haz",
'Tem', "Tem",
'Ağu', "Ağu",
'Eyl', "Eyl",
'Eki', "Eki",
'Kas', "Kas",
'Ara', "Ara",
]; ];
const day = String(d.getDate()).padStart(2, '0'); const day = String(d.getDate()).padStart(2, "0");
const month = months[d.getMonth()]; const month = months[d.getMonth()];
const year = d.getFullYear(); const year = d.getFullYear();
const hour = String(d.getHours()).padStart(2, '0'); const hour = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, '0'); const min = String(d.getMinutes()).padStart(2, "0");
return `${day} ${month} ${year} - ${hour}:${min}`; return `${day} ${month} ${year} - ${hour}:${min}`;
} }
@@ -383,7 +383,7 @@ export class SocialPosterService {
const prediction = await this.getPrediction(matchId); const prediction = await this.getPrediction(matchId);
if (!prediction) { if (!prediction) {
throw new Error('No prediction returned from AI Engine'); throw new Error("No prediction returned from AI Engine");
} }
const card = this.buildCardFromPrediction(match, prediction); const card = this.buildCardFromPrediction(match, prediction);
+13 -13
View File
@@ -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
+41 -41
View File
@@ -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.`;
} }
+71 -71
View File
@@ -10,7 +10,7 @@ import {
HttpStatus, HttpStatus,
Logger, Logger,
UseGuards, UseGuards,
} from '@nestjs/common'; } from "@nestjs/common";
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
@@ -19,21 +19,21 @@ import {
ApiParam, ApiParam,
ApiBody, ApiBody,
ApiBearerAuth, ApiBearerAuth,
} from '@nestjs/swagger'; } from "@nestjs/swagger";
import { SporTotoService } from './spor-toto.service'; import { SporTotoService } from "./spor-toto.service";
import { import {
CreateBulletinDto, CreateBulletinDto,
UpdateResultsDto, UpdateResultsDto,
GenerateColumnsDto, GenerateColumnsDto,
GenerateSporTotoPredictionDto, GenerateSporTotoPredictionDto,
EvaluateColumnsDto, EvaluateColumnsDto,
} from './dto/spor-toto.dto'; } from "./dto/spor-toto.dto";
import { Public, Roles } from '../../common/decorators'; import { Public, Roles } from "../../common/decorators";
import { JwtAuthGuard } from '../auth/guards/auth.guards'; import { JwtAuthGuard } from "../auth/guards/auth.guards";
import { TotoBulletinStatus } from '@prisma/client'; import { TotoBulletinStatus } from "@prisma/client";
@ApiTags('Spor Toto') @ApiTags("Spor Toto")
@Controller('spor-toto') @Controller("spor-toto")
export class SporTotoController { export class SporTotoController {
private readonly logger = new Logger(SporTotoController.name); private readonly logger = new Logger(SporTotoController.name);
@@ -41,51 +41,51 @@ export class SporTotoController {
// ═══════════ BULLETINS ═══════════ // ═══════════ BULLETINS ═══════════
@Post('sync') @Post("sync")
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Roles('admin') @Roles("admin")
@ApiBearerAuth() @ApiBearerAuth()
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
summary: 'Sync current bulletin from Spor Toto API', summary: "Sync current bulletin from Spor Toto API",
description: description:
'Fetches the latest bulletin from sportotov2.iddaa.com and upserts it into the database. Updates match results and dividends if already exists.', "Fetches the latest bulletin from sportotov2.iddaa.com and upserts it into the database. Updates match results and dividends if already exists.",
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Sync result with action (created/updated/unchanged)', description: "Sync result with action (created/updated/unchanged)",
}) })
async syncFromApi() { async syncFromApi() {
const result = await this.sporTotoService.syncFromApi(); const result = await this.sporTotoService.syncFromApi();
return { success: true, data: result }; return { success: true, data: result };
} }
@Get('bulletins') @Get("bulletins")
@Public() @Public()
@ApiOperation({ @ApiOperation({
summary: 'List Spor Toto bulletins', summary: "List Spor Toto bulletins",
description: description:
'Returns a paginated list of bulletins, optionally filtered by status.', "Returns a paginated list of bulletins, optionally filtered by status.",
}) })
@ApiQuery({ @ApiQuery({
name: 'status', name: "status",
required: false, required: false,
enum: TotoBulletinStatus, enum: TotoBulletinStatus,
description: 'Filter by bulletin status', description: "Filter by bulletin status",
}) })
@ApiQuery({ @ApiQuery({
name: 'limit', name: "limit",
required: false, required: false,
type: Number, type: Number,
description: 'Max results (default: 10)', description: "Max results (default: 10)",
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Array of bulletins with matches and results', description: "Array of bulletins with matches and results",
}) })
async listBulletins( async listBulletins(
@Query('status') status?: TotoBulletinStatus, @Query("status") status?: TotoBulletinStatus,
@Query('limit') limit?: string, @Query("limit") limit?: string,
) { ) {
const bulletins = await this.sporTotoService.listBulletins( const bulletins = await this.sporTotoService.listBulletins(
status, status,
@@ -94,95 +94,95 @@ export class SporTotoController {
return { success: true, data: bulletins }; return { success: true, data: bulletins };
} }
@Get('bulletins/:id') @Get("bulletins/:id")
@Public() @Public()
@ApiOperation({ @ApiOperation({
summary: 'Get bulletin details', summary: "Get bulletin details",
description: description:
'Returns a single bulletin with all 15 matches, results, and dividend info.', "Returns a single bulletin with all 15 matches, results, and dividend info.",
}) })
@ApiParam({ name: 'id', description: 'Bulletin UUID' }) @ApiParam({ name: "id", description: "Bulletin UUID" })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Bulletin with matches and results', description: "Bulletin with matches and results",
}) })
@ApiResponse({ status: 404, description: 'Bulletin not found' }) @ApiResponse({ status: 404, description: "Bulletin not found" })
async getBulletin(@Param('id') id: string) { async getBulletin(@Param("id") id: string) {
const bulletin = await this.sporTotoService.getBulletinById(id); const bulletin = await this.sporTotoService.getBulletinById(id);
return { success: true, data: bulletin }; return { success: true, data: bulletin };
} }
@Post('bulletins') @Post("bulletins")
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Roles('admin') @Roles("admin")
@ApiBearerAuth() @ApiBearerAuth()
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ @ApiOperation({
summary: 'Create a bulletin manually', summary: "Create a bulletin manually",
description: description:
'Creates a new bulletin with 15 matches. Fails if gameCycleNo already exists.', "Creates a new bulletin with 15 matches. Fails if gameCycleNo already exists.",
}) })
@ApiBody({ type: CreateBulletinDto }) @ApiBody({ type: CreateBulletinDto })
@ApiResponse({ status: 201, description: 'Created bulletin with matches' }) @ApiResponse({ status: 201, description: "Created bulletin with matches" })
@ApiResponse({ @ApiResponse({
status: 409, status: 409,
description: 'Bulletin with this gameCycleNo already exists', description: "Bulletin with this gameCycleNo already exists",
}) })
async createBulletin(@Body() dto: CreateBulletinDto) { async createBulletin(@Body() dto: CreateBulletinDto) {
const bulletin = await this.sporTotoService.createBulletin(dto); const bulletin = await this.sporTotoService.createBulletin(dto);
return { success: true, data: bulletin }; return { success: true, data: bulletin };
} }
@Patch('bulletins/:id/results') @Patch("bulletins/:id/results")
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Roles('admin') @Roles("admin")
@ApiBearerAuth() @ApiBearerAuth()
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
summary: 'Update bulletin match results', summary: "Update bulletin match results",
description: description:
'Updates individual match results and optionally upserts dividend/prize data. Marks bulletin COMPLETED when all 15 results are entered.', "Updates individual match results and optionally upserts dividend/prize data. Marks bulletin COMPLETED when all 15 results are entered.",
}) })
@ApiParam({ name: 'id', description: 'Bulletin UUID' }) @ApiParam({ name: "id", description: "Bulletin UUID" })
@ApiBody({ type: UpdateResultsDto }) @ApiBody({ type: UpdateResultsDto })
@ApiResponse({ status: 200, description: 'Updated bulletin with results' }) @ApiResponse({ status: 200, description: "Updated bulletin with results" })
@ApiResponse({ status: 404, description: 'Bulletin not found' }) @ApiResponse({ status: 404, description: "Bulletin not found" })
async updateResults(@Param('id') id: string, @Body() dto: UpdateResultsDto) { async updateResults(@Param("id") id: string, @Body() dto: UpdateResultsDto) {
const bulletin = await this.sporTotoService.updateResults(id, dto); const bulletin = await this.sporTotoService.updateResults(id, dto);
return { success: true, data: bulletin }; return { success: true, data: bulletin };
} }
// ═══════════ STATS & ANALYTICS ═══════════ // ═══════════ STATS & ANALYTICS ═══════════
@Get('bulletins/:id/stats') @Get("bulletins/:id/stats")
@Public() @Public()
@ApiOperation({ @ApiOperation({
summary: 'Get bulletin pool & EV statistics', summary: "Get bulletin pool & EV statistics",
description: description:
'Returns pool distribution (35/20/20/25), expected value calculations, and rollover analysis for a bulletin.', "Returns pool distribution (35/20/20/25), expected value calculations, and rollover analysis for a bulletin.",
}) })
@ApiParam({ name: 'id', description: 'Bulletin UUID' }) @ApiParam({ name: "id", description: "Bulletin UUID" })
@ApiResponse({ status: 200, description: 'Pool distribution and EV stats' }) @ApiResponse({ status: 200, description: "Pool distribution and EV stats" })
async getBulletinStats(@Param('id') id: string) { async getBulletinStats(@Param("id") id: string) {
const stats = await this.sporTotoService.getBulletinStats(id); const stats = await this.sporTotoService.getBulletinStats(id);
return { success: true, data: stats }; return { success: true, data: stats };
} }
@Get('history') @Get("history")
@Public() @Public()
@ApiOperation({ @ApiOperation({
summary: 'Get rollover history and trends', summary: "Get rollover history and trends",
description: description:
'Returns the last N bulletins with rollover amounts and consecutive rollover streak.', "Returns the last N bulletins with rollover amounts and consecutive rollover streak.",
}) })
@ApiQuery({ @ApiQuery({
name: 'limit', name: "limit",
required: false, required: false,
type: Number, type: Number,
description: 'Number of results (default: 20)', description: "Number of results (default: 20)",
}) })
@ApiResponse({ status: 200, description: 'Rollover history with trend data' }) @ApiResponse({ status: 200, description: "Rollover history with trend data" })
async getRolloverHistory(@Query('limit') limit?: string) { async getRolloverHistory(@Query("limit") limit?: string) {
const history = await this.sporTotoService.getRolloverHistory( const history = await this.sporTotoService.getRolloverHistory(
Number(limit) || 20, Number(limit) || 20,
); );
@@ -191,38 +191,38 @@ export class SporTotoController {
// ═══════════ COLUMNS ═══════════ // ═══════════ COLUMNS ═══════════
@Post('columns/generate') @Post("columns/generate")
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
summary: 'Generate Spor Toto columns (full or reduced system)', summary: "Generate Spor Toto columns (full or reduced system)",
description: description:
'Takes match selections (1/X/2 per match) and generates columns via Cartesian product (full) or random sampling (reduced). Returns columns with cost calculation.', "Takes match selections (1/X/2 per match) and generates columns via Cartesian product (full) or random sampling (reduced). Returns columns with cost calculation.",
}) })
@ApiBody({ type: GenerateColumnsDto }) @ApiBody({ type: GenerateColumnsDto })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Generated columns with strategy, cost, and column strings', description: "Generated columns with strategy, cost, and column strings",
}) })
async generateColumns(@Body() dto: GenerateColumnsDto) { async generateColumns(@Body() dto: GenerateColumnsDto) {
const result = await this.sporTotoService.generateColumns(dto); const result = await this.sporTotoService.generateColumns(dto);
return { success: true, data: result }; return { success: true, data: result };
} }
@Post('columns/evaluate') @Post("columns/evaluate")
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
summary: 'Evaluate columns against results', summary: "Evaluate columns against results",
description: description:
'Compares generated column strings against actual match results. Returns correct count per column and summary (15/14/13/12 bilen).', "Compares generated column strings against actual match results. Returns correct count per column and summary (15/14/13/12 bilen).",
}) })
@ApiBody({ type: EvaluateColumnsDto }) @ApiBody({ type: EvaluateColumnsDto })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Evaluation results with correct counts per column', description: "Evaluation results with correct counts per column",
}) })
async evaluateColumns(@Body() dto: EvaluateColumnsDto) { async evaluateColumns(@Body() dto: EvaluateColumnsDto) {
const result = await this.sporTotoService.evaluateColumns( const result = await this.sporTotoService.evaluateColumns(
@@ -234,24 +234,24 @@ export class SporTotoController {
// ═══════════ AI PREDICTION ═══════════ // ═══════════ AI PREDICTION ═══════════
@Post('predict') @Post("predict")
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
summary: 'Generate AI predictions with contrarian strategy', summary: "Generate AI predictions with contrarian strategy",
description: description:
'Analyzes bulletin matches via AI Engine V20+, applies contrarian parimutüel strategy, and generates optimized system coupons. Supports 4 strategies: CONSERVATIVE (100 cols), BALANCED (500), AGGRESSIVE (2500), FORMULA_6PCT (6% sampling).', "Analyzes bulletin matches via AI Engine V20+, applies contrarian parimutüel strategy, and generates optimized system coupons. Supports 4 strategies: CONSERVATIVE (100 cols), BALANCED (500), AGGRESSIVE (2500), FORMULA_6PCT (6% sampling).",
}) })
@ApiBody({ type: GenerateSporTotoPredictionDto }) @ApiBody({ type: GenerateSporTotoPredictionDto })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: description:
'Prediction result with per-match analysis, system coupon, and EV report with play recommendation', "Prediction result with per-match analysis, system coupon, and EV report with play recommendation",
}) })
async generatePrediction(@Body() dto: GenerateSporTotoPredictionDto) { async generatePrediction(@Body() dto: GenerateSporTotoPredictionDto) {
this.logger.log( this.logger.log(
`Generating prediction for bulletin ${dto.bulletinId} with strategy ${dto.strategy || 'BALANCED'}`, `Generating prediction for bulletin ${dto.bulletinId} with strategy ${dto.strategy || "BALANCED"}`,
); );
const result = await this.sporTotoService.generatePrediction(dto); const result = await this.sporTotoService.generatePrediction(dto);
return { success: true, data: result }; return { success: true, data: result };
+10 -10
View File
@@ -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],
+31 -31
View File
@@ -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,
+13 -13
View File
@@ -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 {
+27 -27
View File
@@ -1,27 +1,27 @@
import { Controller, Get, Put, Patch, Body } from '@nestjs/common'; import { Controller, Get, Put, Patch, Body } from "@nestjs/common";
import { import {
ApiTags, ApiTags,
ApiBearerAuth, ApiBearerAuth,
ApiOperation, ApiOperation,
ApiOkResponse, ApiOkResponse,
} from '@nestjs/swagger'; } from "@nestjs/swagger";
import { BaseController } from '../../common/base'; import { BaseController } from "../../common/base";
import { UsersService } from './users.service'; import { UsersService } from "./users.service";
import { import {
CreateUserDto, CreateUserDto,
UpdateUserDto, UpdateUserDto,
UpdateProfileDto, UpdateProfileDto,
ChangePasswordDto, ChangePasswordDto,
} from './dto/user.dto'; } from "./dto/user.dto";
import { CurrentUser, Roles } from '../../common/decorators'; import { CurrentUser, Roles } from "../../common/decorators";
import { import {
ApiResponse, ApiResponse,
createSuccessResponse, createSuccessResponse,
} from '../../common/types/api-response.type'; } from "../../common/types/api-response.type";
import { User } from '@prisma/client'; import { User } from "@prisma/client";
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from "class-transformer";
import { UserResponseDto } from './dto/user.dto'; import { UserResponseDto } from "./dto/user.dto";
interface AuthenticatedUser { interface AuthenticatedUser {
id: string; id: string;
@@ -29,20 +29,20 @@ interface AuthenticatedUser {
role: string; role: string;
} }
@ApiTags('Users') @ApiTags("Users")
@ApiBearerAuth() @ApiBearerAuth()
@Controller('users') @Controller("users")
export class UsersController extends BaseController< export class UsersController extends BaseController<
User, User,
CreateUserDto, CreateUserDto,
UpdateUserDto UpdateUserDto
> { > {
constructor(private readonly usersService: UsersService) { constructor(private readonly usersService: UsersService) {
super(usersService, 'User'); super(usersService, "User");
} }
@Get('me') @Get("me")
@ApiOperation({ summary: 'Get current authenticated user profile' }) @ApiOperation({ summary: "Get current authenticated user profile" })
@ApiOkResponse({ type: UserResponseDto }) @ApiOkResponse({ type: UserResponseDto })
async getMe( async getMe(
@CurrentUser() user: AuthenticatedUser, @CurrentUser() user: AuthenticatedUser,
@@ -50,12 +50,12 @@ export class UsersController extends BaseController<
const fullUser = await this.usersService.findOneWithDetails(user.id); const fullUser = await this.usersService.findOneWithDetails(user.id);
return createSuccessResponse( return createSuccessResponse(
plainToInstance(UserResponseDto, fullUser), plainToInstance(UserResponseDto, fullUser),
'User profile retrieved successfully', "User profile retrieved successfully",
); );
} }
@Put('me') @Put("me")
@ApiOperation({ summary: 'Update current user profile' }) @ApiOperation({ summary: "Update current user profile" })
@ApiOkResponse({ type: UserResponseDto }) @ApiOkResponse({ type: UserResponseDto })
async updateMe( async updateMe(
@CurrentUser() user: AuthenticatedUser, @CurrentUser() user: AuthenticatedUser,
@@ -64,13 +64,13 @@ export class UsersController extends BaseController<
const updatedUser = await this.usersService.updateProfile(user.id, dto); const updatedUser = await this.usersService.updateProfile(user.id, dto);
return createSuccessResponse( return createSuccessResponse(
plainToInstance(UserResponseDto, updatedUser), plainToInstance(UserResponseDto, updatedUser),
'User profile updated successfully', "User profile updated successfully",
); );
} }
@Patch('me/password') @Patch("me/password")
@ApiOperation({ summary: 'Change current user password' }) @ApiOperation({ summary: "Change current user password" })
@ApiOkResponse({ description: 'Password changed successfully' }) @ApiOkResponse({ description: "Password changed successfully" })
async changePassword( async changePassword(
@CurrentUser() user: AuthenticatedUser, @CurrentUser() user: AuthenticatedUser,
@Body() dto: ChangePasswordDto, @Body() dto: ChangePasswordDto,
@@ -80,24 +80,24 @@ export class UsersController extends BaseController<
dto.currentPassword, dto.currentPassword,
dto.newPassword, dto.newPassword,
); );
return createSuccessResponse(null, 'Password changed successfully'); return createSuccessResponse(null, "Password changed successfully");
} }
// Override create to require admin role // Override create to require admin role
@Roles('admin') @Roles("admin")
async create( async create(
...args: Parameters< ...args: Parameters<
BaseController<User, CreateUserDto, UpdateUserDto>['create'] BaseController<User, CreateUserDto, UpdateUserDto>["create"]
> >
) { ) {
return super.create(...args); return super.create(...args);
} }
// Override delete to require admin role // Override delete to require admin role
@Roles('admin') @Roles("admin")
async delete( async delete(
...args: Parameters< ...args: Parameters<
BaseController<User, CreateUserDto, UpdateUserDto>['delete'] BaseController<User, CreateUserDto, UpdateUserDto>["delete"]
> >
) { ) {
return super.delete(...args); return super.delete(...args);
+3 -3
View File
@@ -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],
+10 -10
View File
@@ -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);
+76 -76
View File
@@ -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);
}); });
+12 -12
View File
@@ -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();
} }
+11 -11
View File
@@ -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("---------------------------------------------------");
} }
} }
+20 -20
View File
@@ -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();
} }
+24 -24
View File
@@ -12,7 +12,7 @@
* Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/compute-elo-ratings.ts * Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/compute-elo-ratings.ts
*/ */
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from "@prisma/client";
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Types // Types
@@ -72,9 +72,9 @@ function getResultChar(
scoreAway: number, scoreAway: number,
isHomeTeam: boolean, isHomeTeam: boolean,
): string { ): string {
if (scoreHome > scoreAway) return isHomeTeam ? 'W' : 'L'; if (scoreHome > scoreAway) return isHomeTeam ? "W" : "L";
if (scoreHome < scoreAway) return isHomeTeam ? 'L' : 'W'; if (scoreHome < scoreAway) return isHomeTeam ? "L" : "W";
return 'D'; return "D";
} }
function calculateFormElo(recentResults: string[]): number { function calculateFormElo(recentResults: string[]): number {
@@ -86,7 +86,7 @@ function calculateFormElo(recentResults: string[]): number {
for (let i = 0; i < recentResults.length; i++) { for (let i = 0; i < recentResults.length; i++) {
const weight = Math.pow(FORM_DECAY, i); // Most recent = highest weight const weight = Math.pow(FORM_DECAY, i); // Most recent = highest weight
const result = recentResults[i]; const result = recentResults[i];
const score = result === 'W' ? 3 : result === 'D' ? 1 : 0; const score = result === "W" ? 3 : result === "D" ? 1 : 0;
formScore += score * weight; formScore += score * weight;
totalWeight += 3 * weight; // Max possible per match totalWeight += 3 * weight; // Max possible per match
} }
@@ -105,14 +105,14 @@ async function computeEloRatings(): Promise<void> {
const startTime = Date.now(); const startTime = Date.now();
try { try {
console.log('🏟️ ELO Rating Computation — Starting...'); console.log("🏟️ ELO Rating Computation — Starting...");
console.log('─'.repeat(60)); console.log("─".repeat(60));
// 1. Fetch all finished football matches in chronological order // 1. Fetch all finished football matches in chronological order
const matches: MatchRecord[] = await prisma.match.findMany({ const matches: MatchRecord[] = await prisma.match.findMany({
where: { where: {
sport: 'football', sport: "football",
status: 'FT', status: "FT",
scoreHome: { not: null }, scoreHome: { not: null },
scoreAway: { not: null }, scoreAway: { not: null },
homeTeamId: { not: null }, homeTeamId: { not: null },
@@ -126,7 +126,7 @@ async function computeEloRatings(): Promise<void> {
scoreAway: true, scoreAway: true,
mstUtc: true, mstUtc: true,
}, },
orderBy: { mstUtc: 'asc' }, orderBy: { mstUtc: "asc" },
}); });
console.log( console.log(
@@ -228,7 +228,7 @@ async function computeEloRatings(): Promise<void> {
); );
// 4. Bulk upsert to team_elo_ratings // 4. Bulk upsert to team_elo_ratings
console.log('💾 Writing to team_elo_ratings...'); console.log("💾 Writing to team_elo_ratings...");
const BATCH_SIZE = 500; const BATCH_SIZE = 500;
const teams = Array.from(eloMap.entries()); const teams = Array.from(eloMap.entries());
@@ -246,7 +246,7 @@ async function computeEloRatings(): Promise<void> {
awayElo: Math.round(state.awayElo * 10) / 10, awayElo: Math.round(state.awayElo * 10) / 10,
formElo: Math.round(state.formElo * 10) / 10, formElo: Math.round(state.formElo * 10) / 10,
matchesPlayed: state.matchesPlayed, matchesPlayed: state.matchesPlayed,
recentForm: state.recentResults.join(''), recentForm: state.recentResults.join(""),
}, },
create: { create: {
teamId, teamId,
@@ -255,7 +255,7 @@ async function computeEloRatings(): Promise<void> {
awayElo: Math.round(state.awayElo * 10) / 10, awayElo: Math.round(state.awayElo * 10) / 10,
formElo: Math.round(state.formElo * 10) / 10, formElo: Math.round(state.formElo * 10) / 10,
matchesPlayed: state.matchesPlayed, matchesPlayed: state.matchesPlayed,
recentForm: state.recentResults.join(''), recentForm: state.recentResults.join(""),
}, },
}), }),
), ),
@@ -276,38 +276,38 @@ async function computeEloRatings(): Promise<void> {
.map((s) => s.overallElo) .map((s) => s.overallElo)
.sort((a, b) => b - a); .sort((a, b) => b - a);
console.log('─'.repeat(60)); console.log("─".repeat(60));
console.log('📊 ELO Rating Summary:'); console.log("📊 ELO Rating Summary:");
console.log(` Teams rated: ${eloMap.size.toLocaleString()}`); console.log(` Teams rated: ${eloMap.size.toLocaleString()}`);
console.log(` Matches used: ${processed.toLocaleString()}`); console.log(` Matches used: ${processed.toLocaleString()}`);
console.log(` Highest ELO: ${overallElos[0]?.toFixed(1) ?? 'N/A'}`); console.log(` Highest ELO: ${overallElos[0]?.toFixed(1) ?? "N/A"}`);
console.log( console.log(
` Lowest ELO: ${overallElos[overallElos.length - 1]?.toFixed(1) ?? 'N/A'}`, ` Lowest ELO: ${overallElos[overallElos.length - 1]?.toFixed(1) ?? "N/A"}`,
); );
console.log( console.log(
` Median ELO: ${overallElos[Math.floor(overallElos.length / 2)]?.toFixed(1) ?? 'N/A'}`, ` Median ELO: ${overallElos[Math.floor(overallElos.length / 2)]?.toFixed(1) ?? "N/A"}`,
); );
console.log(` Duration: ${elapsedTotal}s`); console.log(` Duration: ${elapsedTotal}s`);
console.log('─'.repeat(60)); console.log("─".repeat(60));
// Top 20 teams // Top 20 teams
const topTeams = await prisma.teamEloRating.findMany({ const topTeams = await prisma.teamEloRating.findMany({
orderBy: { overallElo: 'desc' }, orderBy: { overallElo: "desc" },
take: 20, take: 20,
include: { team: { select: { name: true } } }, include: { team: { select: { name: true } } },
}); });
console.log('\n🏆 Top 20 Teams by ELO:'); console.log("\n🏆 Top 20 Teams by ELO:");
topTeams.forEach((t, i) => { topTeams.forEach((t, i) => {
const form = t.recentForm.split('').join('-'); const form = t.recentForm.split("").join("-");
console.log( console.log(
` ${String(i + 1).padStart(2)}. ${t.team.name.padEnd(25)} Overall: ${t.overallElo.toFixed(1).padStart(7)} Home: ${t.homeElo.toFixed(1).padStart(7)} Away: ${t.awayElo.toFixed(1).padStart(7)} Form: ${form}`, ` ${String(i + 1).padStart(2)}. ${t.team.name.padEnd(25)} Overall: ${t.overallElo.toFixed(1).padStart(7)} Home: ${t.homeElo.toFixed(1).padStart(7)} Away: ${t.awayElo.toFixed(1).padStart(7)} Form: ${form}`,
); );
}); });
console.log('\n✅ Done!'); console.log("\n✅ Done!");
} catch (error) { } catch (error) {
console.error('❌ ELO computation failed:', error); console.error("❌ ELO computation failed:", error);
process.exit(1); process.exit(1);
} finally { } finally {
await prisma.$disconnect(); await prisma.$disconnect();
+170 -165
View File
@@ -1,9 +1,9 @@
import 'reflect-metadata'; import "reflect-metadata";
import { mkdirSync, writeFileSync } from 'node:fs'; import { mkdirSync, writeFileSync } from "node:fs";
import * as path from 'node:path'; import * as path from "node:path";
import { NestFactory } from '@nestjs/core'; import { NestFactory } from "@nestjs/core";
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from '../app.module'; import { AppModule } from "../app.module";
type JsonRecord = Record<string, unknown>; type JsonRecord = Record<string, unknown>;
type SwaggerPaths = Record<string, Record<string, JsonRecord>>; type SwaggerPaths = Record<string, Record<string, JsonRecord>>;
@@ -14,7 +14,7 @@ interface PostmanResponse {
originalRequest: JsonRecord; originalRequest: JsonRecord;
status: string; status: string;
code: number; code: number;
_postman_previewlanguage: 'json'; _postman_previewlanguage: "json";
header: Array<{ key: string; value: string }>; header: Array<{ key: string; value: string }>;
body: string; body: string;
} }
@@ -28,7 +28,7 @@ interface PostmanItem {
interface AiEndpointDefinition { interface AiEndpointDefinition {
name: string; name: string;
method: 'GET' | 'POST'; method: "GET" | "POST";
path: string; path: string;
description: string; description: string;
query?: Array<{ key: string; value: string; description: string }>; query?: Array<{ key: string; value: string; description: string }>;
@@ -40,7 +40,7 @@ function refName(ref: string | undefined): string | null {
if (!ref) { if (!ref) {
return null; return null;
} }
const parts = ref.split('/'); const parts = ref.split("/");
return parts[parts.length - 1] ?? null; return parts[parts.length - 1] ?? null;
} }
@@ -48,12 +48,13 @@ function resolveSchema(
schema: unknown, schema: unknown,
schemas: SwaggerSchemas, schemas: SwaggerSchemas,
): JsonRecord | null { ): JsonRecord | null {
if (!schema || typeof schema !== 'object') { if (!schema || typeof schema !== "object") {
return null; return null;
} }
const schemaObject = schema as JsonRecord; const schemaObject = schema as JsonRecord;
const schemaRef = typeof schemaObject.$ref === 'string' ? schemaObject.$ref : null; const schemaRef =
typeof schemaObject.$ref === "string" ? schemaObject.$ref : null;
if (schemaRef) { if (schemaRef) {
const name = refName(schemaRef); const name = refName(schemaRef);
return name ? (schemas[name] ?? null) : null; return name ? (schemas[name] ?? null) : null;
@@ -73,31 +74,31 @@ function examplePrimitive(schema: JsonRecord): unknown {
return schema.enum[0]; return schema.enum[0];
} }
const type = typeof schema.type === 'string' ? schema.type : 'string'; const type = typeof schema.type === "string" ? schema.type : "string";
const format = typeof schema.format === 'string' ? schema.format : ''; const format = typeof schema.format === "string" ? schema.format : "";
if (type === 'string') { if (type === "string") {
if (format === 'email') { if (format === "email") {
return 'user@example.com'; return "user@example.com";
} }
if (format === 'date-time') { if (format === "date-time") {
return '2026-04-14T00:00:00.000Z'; return "2026-04-14T00:00:00.000Z";
} }
if (format === 'date') { if (format === "date") {
return '2026-04-14'; return "2026-04-14";
} }
if (format === 'uuid') { if (format === "uuid") {
return '11111111-1111-1111-1111-111111111111'; return "11111111-1111-1111-1111-111111111111";
} }
return 'string'; return "string";
} }
if (type === 'integer' || type === 'number') { if (type === "integer" || type === "number") {
return 1; return 1;
} }
if (type === 'boolean') { if (type === "boolean") {
return true; return true;
} }
return 'string'; return "string";
} }
function buildExampleFromSchema( function buildExampleFromSchema(
@@ -110,7 +111,7 @@ function buildExampleFromSchema(
return null; return null;
} }
const schemaRef = typeof resolved.$ref === 'string' ? resolved.$ref : null; const schemaRef = typeof resolved.$ref === "string" ? resolved.$ref : null;
if (schemaRef) { if (schemaRef) {
const name = refName(schemaRef); const name = refName(schemaRef);
if (!name || visited.has(name)) { if (!name || visited.has(name)) {
@@ -124,7 +125,7 @@ function buildExampleFromSchema(
if (Array.isArray(resolved.allOf) && resolved.allOf.length > 0) { if (Array.isArray(resolved.allOf) && resolved.allOf.length > 0) {
return resolved.allOf.reduce<JsonRecord>((accumulator, part) => { return resolved.allOf.reduce<JsonRecord>((accumulator, part) => {
const partial = buildExampleFromSchema(part, schemas, visited); const partial = buildExampleFromSchema(part, schemas, visited);
if (partial && typeof partial === 'object' && !Array.isArray(partial)) { if (partial && typeof partial === "object" && !Array.isArray(partial)) {
return { ...accumulator, ...(partial as JsonRecord) }; return { ...accumulator, ...(partial as JsonRecord) };
} }
return accumulator; return accumulator;
@@ -139,12 +140,12 @@ function buildExampleFromSchema(
return buildExampleFromSchema(resolved.anyOf[0], schemas, visited); return buildExampleFromSchema(resolved.anyOf[0], schemas, visited);
} }
const type = typeof resolved.type === 'string' ? resolved.type : 'object'; const type = typeof resolved.type === "string" ? resolved.type : "object";
if (type === 'array') { if (type === "array") {
return [buildExampleFromSchema(resolved.items, schemas, visited)]; return [buildExampleFromSchema(resolved.items, schemas, visited)];
} }
if (type === 'object' || resolved.properties) { if (type === "object" || resolved.properties) {
const properties = (resolved.properties ?? {}) as JsonRecord; const properties = (resolved.properties ?? {}) as JsonRecord;
const output: JsonRecord = {}; const output: JsonRecord = {};
for (const [key, value] of Object.entries(properties)) { for (const [key, value] of Object.entries(properties)) {
@@ -159,23 +160,23 @@ function buildExampleFromSchema(
} }
function swaggerSchemaFromContent(content: unknown): unknown { function swaggerSchemaFromContent(content: unknown): unknown {
if (!content || typeof content !== 'object') { if (!content || typeof content !== "object") {
return null; return null;
} }
const contentObject = content as JsonRecord; const contentObject = content as JsonRecord;
const jsonContent = contentObject['application/json']; const jsonContent = contentObject["application/json"];
if (jsonContent && typeof jsonContent === 'object') { if (jsonContent && typeof jsonContent === "object") {
return (jsonContent as JsonRecord).schema ?? null; return (jsonContent as JsonRecord).schema ?? null;
} }
const firstContent = Object.values(contentObject)[0]; const firstContent = Object.values(contentObject)[0];
if (firstContent && typeof firstContent === 'object') { if (firstContent && typeof firstContent === "object") {
return (firstContent as JsonRecord).schema ?? null; return (firstContent as JsonRecord).schema ?? null;
} }
return null; return null;
} }
function toPostmanPath(pathname: string): string { function toPostmanPath(pathname: string): string {
return pathname.replace(/\{([^}]+)\}/g, '{{$1}}'); return pathname.replace(/\{([^}]+)\}/g, "{{$1}}");
} }
function buildRequestBody( function buildRequestBody(
@@ -213,25 +214,26 @@ function buildResponses(
name: `${method.toUpperCase()} ${rawPath} - ${statusCode}`, name: `${method.toUpperCase()} ${rawPath} - ${statusCode}`,
originalRequest: { originalRequest: {
method: method.toUpperCase(), method: method.toUpperCase(),
header: [{ key: 'Content-Type', value: 'application/json' }], header: [{ key: "Content-Type", value: "application/json" }],
body: body body: body
? { ? {
mode: 'raw', mode: "raw",
raw: body, raw: body,
} }
: undefined, : undefined,
url: { url: {
raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`, raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`,
host: [`{{${baseUrlVariable}}}`], host: [`{{${baseUrlVariable}}}`],
path: rawPath.split('/').filter(Boolean), path: rawPath.split("/").filter(Boolean),
}, },
}, },
status: typeof responseRecord.description === 'string' status:
? responseRecord.description typeof responseRecord.description === "string"
: `HTTP ${statusCode}`, ? responseRecord.description
: `HTTP ${statusCode}`,
code: Number.isFinite(numericStatus) ? numericStatus : 200, code: Number.isFinite(numericStatus) ? numericStatus : 200,
_postman_previewlanguage: 'json', _postman_previewlanguage: "json",
header: [{ key: 'Content-Type', value: 'application/json' }], header: [{ key: "Content-Type", value: "application/json" }],
body: JSON.stringify(example ?? {}, null, 2), body: JSON.stringify(example ?? {}, null, 2),
}; };
}); });
@@ -243,14 +245,14 @@ function buildQueryParams(operation: JsonRecord): Array<JsonRecord> {
: []; : [];
return parameters return parameters
.filter((parameter) => parameter.in === 'query') .filter((parameter) => parameter.in === "query")
.map((parameter) => ({ .map((parameter) => ({
key: String(parameter.name ?? ''), key: String(parameter.name ?? ""),
value: value:
parameter.schema && typeof parameter.schema === 'object' parameter.schema && typeof parameter.schema === "object"
? String(((parameter.schema as JsonRecord).default ?? '')) ? String((parameter.schema as JsonRecord).default ?? "")
: '', : "",
description: String(parameter.description ?? ''), description: String(parameter.description ?? ""),
disabled: parameter.required === true ? false : true, disabled: parameter.required === true ? false : true,
})); }));
} }
@@ -258,8 +260,8 @@ function buildQueryParams(operation: JsonRecord): Array<JsonRecord> {
function buildHeaders(operation: JsonRecord): Array<JsonRecord> { function buildHeaders(operation: JsonRecord): Array<JsonRecord> {
const headers: Array<JsonRecord> = [ const headers: Array<JsonRecord> = [
{ {
key: 'Content-Type', key: "Content-Type",
value: 'application/json', value: "application/json",
}, },
]; ];
@@ -268,8 +270,8 @@ function buildHeaders(operation: JsonRecord): Array<JsonRecord> {
: []; : [];
if (security.length > 0) { if (security.length > 0) {
headers.push({ headers.push({
key: 'Authorization', key: "Authorization",
value: 'Bearer {{accessToken}}', value: "Bearer {{accessToken}}",
}); });
} }
@@ -292,20 +294,22 @@ function createRequestItem(
method: method.toUpperCase(), method: method.toUpperCase(),
header: headers, header: headers,
description: description:
typeof operation.description === 'string' typeof operation.description === "string"
? operation.description ? operation.description
: (typeof operation.summary === 'string' ? operation.summary : ''), : typeof operation.summary === "string"
? operation.summary
: "",
url: { url: {
raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`, raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`,
host: [`{{${baseUrlVariable}}}`], host: [`{{${baseUrlVariable}}}`],
path: rawPath.split('/').filter(Boolean), path: rawPath.split("/").filter(Boolean),
query, query,
}, },
}; };
if (body) { if (body) {
request.body = { request.body = {
mode: 'raw', mode: "raw",
raw: body, raw: body,
}; };
} }
@@ -335,18 +339,19 @@ function buildNestFolders(document: JsonRecord): PostmanItem[] {
for (const [rawPath, pathItem] of Object.entries(paths)) { for (const [rawPath, pathItem] of Object.entries(paths)) {
for (const [method, operationObject] of Object.entries(pathItem)) { for (const [method, operationObject] of Object.entries(pathItem)) {
if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) { if (!["get", "post", "put", "patch", "delete"].includes(method)) {
continue; continue;
} }
const operation = operationObject as JsonRecord; const operation = operationObject;
const tags = Array.isArray(operation.tags) ? operation.tags : []; const tags = Array.isArray(operation.tags) ? operation.tags : [];
const folderName = const folderName =
typeof tags[0] === 'string' && tags[0].trim().length > 0 typeof tags[0] === "string" && tags[0].trim().length > 0
? tags[0] ? tags[0]
: 'Misc'; : "Misc";
const requestName = const requestName =
typeof operation.summary === 'string' && operation.summary.trim().length > 0 typeof operation.summary === "string" &&
operation.summary.trim().length > 0
? operation.summary ? operation.summary
: `${method.toUpperCase()} ${rawPath}`; : `${method.toUpperCase()} ${rawPath}`;
@@ -354,7 +359,7 @@ function buildNestFolders(document: JsonRecord): PostmanItem[] {
requestName, requestName,
method, method,
rawPath, rawPath,
'beBaseUrl', "beBaseUrl",
operation, operation,
safeSchemas, safeSchemas,
); );
@@ -379,8 +384,8 @@ function createAiRequest(
): PostmanItem { ): PostmanItem {
const url: JsonRecord = { const url: JsonRecord = {
raw: `{{aiBaseUrl}}${endpoint.path}`, raw: `{{aiBaseUrl}}${endpoint.path}`,
host: ['{{aiBaseUrl}}'], host: ["{{aiBaseUrl}}"],
path: endpoint.path.split('/').filter(Boolean), path: endpoint.path.split("/").filter(Boolean),
}; };
if (endpoint.query && endpoint.query.length > 0) { if (endpoint.query && endpoint.query.length > 0) {
@@ -393,14 +398,14 @@ function createAiRequest(
const request: JsonRecord = { const request: JsonRecord = {
method: endpoint.method, method: endpoint.method,
header: [{ key: 'Content-Type', value: 'application/json' }], header: [{ key: "Content-Type", value: "application/json" }],
description: endpoint.description, description: endpoint.description,
url, url,
}; };
if (endpoint.body) { if (endpoint.body) {
request.body = { request.body = {
mode: 'raw', mode: "raw",
raw: JSON.stringify(endpoint.body, null, 2), raw: JSON.stringify(endpoint.body, null, 2),
}; };
} }
@@ -412,10 +417,10 @@ function createAiRequest(
{ {
name: `${endpoint.method} ${endpoint.path}`, name: `${endpoint.method} ${endpoint.path}`,
originalRequest: request, originalRequest: request,
status: 'OK', status: "OK",
code: 200, code: 200,
_postman_previewlanguage: 'json', _postman_previewlanguage: "json",
header: [{ key: 'Content-Type', value: 'application/json' }], header: [{ key: "Content-Type", value: "application/json" }],
body: JSON.stringify(endpoint.response, null, 2), body: JSON.stringify(endpoint.response, null, 2),
}, },
], ],
@@ -425,105 +430,105 @@ function createAiRequest(
function buildAiFolder(): PostmanItem { function buildAiFolder(): PostmanItem {
const v20Endpoints: AiEndpointDefinition[] = [ const v20Endpoints: AiEndpointDefinition[] = [
{ {
name: 'Root', name: "Root",
method: 'GET', method: "GET",
path: '/', path: "/",
description: 'AI engine root status endpoint', description: "AI engine root status endpoint",
response: { response: {
status: 'Suggest-Bet AI Engine v20+', status: "Suggest-Bet AI Engine v20+",
engine: 'V20 Plus Single Match Orchestrator', engine: "V20 Plus Single Match Orchestrator",
}, },
}, },
{ {
name: 'Health', name: "Health",
method: 'GET', method: "GET",
path: '/health', path: "/health",
description: 'AI engine health endpoint', description: "AI engine health endpoint",
response: { status: 'healthy', engine: 'v20plus', ready: true }, response: { status: "healthy", engine: "v20plus", ready: true },
}, },
{ {
name: 'Analyze Match', name: "Analyze Match",
method: 'POST', method: "POST",
path: '/v20plus/analyze/{{match_id}}', path: "/v20plus/analyze/{{match_id}}",
description: 'Full V20+ single match analysis', description: "Full V20+ single match analysis",
response: { response: {
model_version: 'v30.0', model_version: "v30.0",
match_info: { match_id: '{{match_id}}' }, match_info: { match_id: "{{match_id}}" },
main_pick: { market: 'OU25', pick: '2.5 Üst' }, main_pick: { market: "OU25", pick: "2.5 Üst" },
market_board: {}, market_board: {},
}, },
}, },
{ {
name: 'Analyze HTMS', name: "Analyze HTMS",
method: 'GET', method: "GET",
path: '/v20plus/analyze-htms/{{match_id}}', path: "/v20plus/analyze-htms/{{match_id}}",
description: 'Half-time result analysis endpoint', description: "Half-time result analysis endpoint",
response: { match_id: '{{match_id}}', market: 'HT' }, response: { match_id: "{{match_id}}", market: "HT" },
}, },
{ {
name: 'Analyze HTFT', name: "Analyze HTFT",
method: 'GET', method: "GET",
path: '/v20plus/analyze-htft/{{match_id}}', path: "/v20plus/analyze-htft/{{match_id}}",
description: 'Half-time/full-time analysis endpoint', description: "Half-time/full-time analysis endpoint",
query: [ query: [
{ {
key: 'timeout_sec', key: "timeout_sec",
value: '30', value: "30",
description: 'Timeout between 3 and 120 seconds', description: "Timeout between 3 and 120 seconds",
}, },
], ],
response: { response: {
engine: 'v20plus.1', engine: "v20plus.1",
match_info: { match_id: '{{match_id}}' }, match_info: { match_id: "{{match_id}}" },
ht_ft_probs: { '1/1': 0.25, 'X/X': 0.18 }, ht_ft_probs: { "1/1": 0.25, "X/X": 0.18 },
}, },
}, },
{ {
name: 'Generate Coupon', name: "Generate Coupon",
method: 'POST', method: "POST",
path: '/v20plus/coupon', path: "/v20plus/coupon",
description: 'Generate V20+ coupon from selected matches', description: "Generate V20+ coupon from selected matches",
body: { body: {
match_ids: ['match-1', 'match-2'], match_ids: ["match-1", "match-2"],
strategy: 'BALANCED', strategy: "BALANCED",
max_matches: 4, max_matches: 4,
min_confidence: 55, min_confidence: 55,
}, },
response: { response: {
success: true, success: true,
data: { data: {
strategy: 'BALANCED', strategy: "BALANCED",
bets: [], bets: [],
}, },
}, },
}, },
{ {
name: 'Daily Banker', name: "Daily Banker",
method: 'GET', method: "GET",
path: '/v20plus/daily-banker', path: "/v20plus/daily-banker",
description: 'Get daily banker picks', description: "Get daily banker picks",
query: [ query: [
{ {
key: 'count', key: "count",
value: '3', value: "3",
description: 'Number of banker picks', description: "Number of banker picks",
}, },
], ],
response: { count: 3, bankers: [] }, response: { count: 3, bankers: [] },
}, },
{ {
name: 'Reversal Watchlist', name: "Reversal Watchlist",
method: 'GET', method: "GET",
path: '/v20plus/reversal-watchlist', path: "/v20plus/reversal-watchlist",
description: 'Reversal watchlist candidates', description: "Reversal watchlist candidates",
query: [ query: [
{ key: 'count', value: '20', description: 'Result size' }, { key: "count", value: "20", description: "Result size" },
{ key: 'horizon_hours', value: '72', description: 'Future horizon' }, { key: "horizon_hours", value: "72", description: "Future horizon" },
{ key: 'min_score', value: '45', description: 'Minimum score' }, { key: "min_score", value: "45", description: "Minimum score" },
{ {
key: 'top_leagues_only', key: "top_leagues_only",
value: 'false', value: "false",
description: 'Filter to top leagues', description: "Filter to top leagues",
}, },
], ],
response: { count: 0, items: [] }, response: { count: 0, items: [] },
@@ -532,42 +537,42 @@ function buildAiFolder(): PostmanItem {
const v2Endpoints: AiEndpointDefinition[] = [ const v2Endpoints: AiEndpointDefinition[] = [
{ {
name: 'V2 Health', name: "V2 Health",
method: 'GET', method: "GET",
path: '/v2/health', path: "/v2/health",
description: 'V2 betting engine health', description: "V2 betting engine health",
response: { response: {
status: 'healthy', status: "healthy",
engine: 'v2.betting_engine', engine: "v2.betting_engine",
models_loaded: true, models_loaded: true,
}, },
}, },
{ {
name: 'V2 Analyze Match', name: "V2 Analyze Match",
method: 'POST', method: "POST",
path: '/v2/analyze/{{match_id}}', path: "/v2/analyze/{{match_id}}",
description: 'V2 leakage-free match analysis', description: "V2 leakage-free match analysis",
response: { response: {
model_version: 'v2.betting_engine', model_version: "v2.betting_engine",
match_info: { match_id: '{{match_id}}' }, match_info: { match_id: "{{match_id}}" },
main_pick: { market: 'MS', pick: '1' }, main_pick: { market: "MS", pick: "1" },
market_board: { market_board: {
MS: { pick: '1', confidence: 58.4 }, MS: { pick: "1", confidence: 58.4 },
}, },
}, },
}, },
]; ];
return { return {
name: 'AI Engine', name: "AI Engine",
item: [ item: [
{ {
name: 'V20+', name: "V20+",
item: v20Endpoints.map((endpoint) => createAiRequest(endpoint, 'V20+')), item: v20Endpoints.map((endpoint) => createAiRequest(endpoint, "V20+")),
}, },
{ {
name: 'V2', name: "V2",
item: v2Endpoints.map((endpoint) => createAiRequest(endpoint, 'V2')), item: v2Endpoints.map((endpoint) => createAiRequest(endpoint, "V2")),
}, },
], ],
}; };
@@ -575,21 +580,21 @@ function buildAiFolder(): PostmanItem {
async function run(): Promise<void> { async function run(): Promise<void> {
const projectRoot = process.cwd(); const projectRoot = process.cwd();
const outputDir = path.join(projectRoot, 'mds'); const outputDir = path.join(projectRoot, "mds");
const outputFile = path.join( const outputFile = path.join(
outputDir, outputDir,
'suggest-bet-platform.postman_collection.json', "suggest-bet-platform.postman_collection.json",
); );
process.env.REDIS_ENABLED = 'true'; process.env.REDIS_ENABLED = "true";
const app = await NestFactory.create(AppModule, { logger: false }); const app = await NestFactory.create(AppModule, { logger: false });
app.setGlobalPrefix('api'); app.setGlobalPrefix("api");
const swaggerConfig = new DocumentBuilder() const swaggerConfig = new DocumentBuilder()
.setTitle('Suggest Bet Backend API') .setTitle("Suggest Bet Backend API")
.setDescription('Postman collection export source') .setDescription("Postman collection export source")
.setVersion('1.0') .setVersion("1.0")
.addBearerAuth() .addBearerAuth()
.build(); .build();
@@ -600,25 +605,25 @@ async function run(): Promise<void> {
const collection: JsonRecord = { const collection: JsonRecord = {
info: { info: {
name: 'Suggest-Bet Platform API', name: "Suggest-Bet Platform API",
description: description:
'Auto-generated Postman collection for Nest backend and AI engine endpoints.', "Auto-generated Postman collection for Nest backend and AI engine endpoints.",
schema: schema:
'https://schema.getpostman.com/json/collection/v2.1.0/collection.json', "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
}, },
variable: [ variable: [
{ key: 'beBaseUrl', value: 'http://localhost:3005' }, { key: "beBaseUrl", value: "http://localhost:3005" },
{ key: 'aiBaseUrl', value: 'http://localhost:8000' }, { key: "aiBaseUrl", value: "http://localhost:8000" },
{ key: 'accessToken', value: '' }, { key: "accessToken", value: "" },
{ key: 'match_id', value: 'sample-match-id' }, { key: "match_id", value: "sample-match-id" },
], ],
auth: { auth: {
type: 'bearer', type: "bearer",
bearer: [{ key: 'token', value: '{{accessToken}}', type: 'string' }], bearer: [{ key: "token", value: "{{accessToken}}", type: "string" }],
}, },
item: [ item: [
{ {
name: 'Nest API', name: "Nest API",
item: buildNestFolders(document), item: buildNestFolders(document),
}, },
buildAiFolder(), buildAiFolder(),
@@ -626,7 +631,7 @@ async function run(): Promise<void> {
}; };
mkdirSync(outputDir, { recursive: true }); mkdirSync(outputDir, { recursive: true });
writeFileSync(outputFile, JSON.stringify(collection, null, 2), 'utf8'); writeFileSync(outputFile, JSON.stringify(collection, null, 2), "utf8");
await app.close(); await app.close();
+88 -88
View File
@@ -1,20 +1,20 @@
import 'reflect-metadata'; import "reflect-metadata";
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import * as path from 'node:path'; import * as path from "node:path";
import ts from 'typescript'; import ts from "typescript";
import { NestFactory } from '@nestjs/core'; import { NestFactory } from "@nestjs/core";
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { AppModule } from '../app.module'; import { AppModule } from "../app.module";
type HttpMethod = type HttpMethod =
| 'get' | "get"
| 'post' | "post"
| 'put' | "put"
| 'patch' | "patch"
| 'delete' | "delete"
| 'options' | "options"
| 'head' | "head"
| 'all'; | "all";
interface TsDecoratorMeta { interface TsDecoratorMeta {
name: string; name: string;
@@ -42,14 +42,14 @@ interface TsMethodMeta {
} }
const HTTP_DECORATOR_TO_METHOD: Record<string, HttpMethod> = { const HTTP_DECORATOR_TO_METHOD: Record<string, HttpMethod> = {
Get: 'get', Get: "get",
Post: 'post', Post: "post",
Put: 'put', Put: "put",
Patch: 'patch', Patch: "patch",
Delete: 'delete', Delete: "delete",
Options: 'options', Options: "options",
Head: 'head', Head: "head",
All: 'all', All: "all",
}; };
function getDecorators(node: ts.Node): readonly ts.Decorator[] { function getDecorators(node: ts.Node): readonly ts.Decorator[] {
@@ -105,7 +105,7 @@ function collectControllerFiles(dirPath: string): string[] {
continue; continue;
} }
if (entry.isFile() && entry.name.endsWith('.controller.ts')) { if (entry.isFile() && entry.name.endsWith(".controller.ts")) {
files.push(absolutePath); files.push(absolutePath);
} }
} }
@@ -115,9 +115,9 @@ function collectControllerFiles(dirPath: string): string[] {
function normalizeRoutePart(value: string | undefined): string { function normalizeRoutePart(value: string | undefined): string {
if (!value || value === "''" || value === '""') { if (!value || value === "''" || value === '""') {
return ''; return "";
} }
return value.trim().replace(/^\/+|\/+$/g, ''); return value.trim().replace(/^\/+|\/+$/g, "");
} }
function buildSwaggerPath( function buildSwaggerPath(
@@ -131,18 +131,18 @@ function buildSwaggerPath(
normalizeRoutePart(routePath), normalizeRoutePart(routePath),
].filter(Boolean); ].filter(Boolean);
return `/${parts.join('/')}`; return `/${parts.join("/")}`;
} }
function collectTsEndpointMetadata( function collectTsEndpointMetadata(
projectRoot: string, projectRoot: string,
): Map<string, TsMethodMeta> { ): Map<string, TsMethodMeta> {
const modulesDir = path.join(projectRoot, 'src', 'modules'); const modulesDir = path.join(projectRoot, "src", "modules");
const controllerFiles = collectControllerFiles(modulesDir); const controllerFiles = collectControllerFiles(modulesDir);
const metadataByOperationId = new Map<string, TsMethodMeta>(); const metadataByOperationId = new Map<string, TsMethodMeta>();
for (const filePath of controllerFiles) { for (const filePath of controllerFiles) {
const sourceText = readFileSync(filePath, 'utf8'); const sourceText = readFileSync(filePath, "utf8");
const sourceFile = ts.createSourceFile( const sourceFile = ts.createSourceFile(
filePath, filePath,
sourceText, sourceText,
@@ -164,7 +164,7 @@ function collectTsEndpointMetadata(
); );
const controllerDecorator = classDecorators.find( const controllerDecorator = classDecorators.find(
(decorator) => decorator.name === 'Controller', (decorator) => decorator.name === "Controller",
); );
if (!controllerDecorator) { if (!controllerDecorator) {
return; return;
@@ -221,7 +221,7 @@ function collectTsEndpointMetadata(
routePath, routePath,
returnType, returnType,
hasPublicDecorator: methodDecorators.some( hasPublicDecorator: methodDecorators.some(
(decorator) => decorator.name === 'Public', (decorator) => decorator.name === "Public",
), ),
methodDecorators: methodDecorators.map((decorator) => decorator.name), methodDecorators: methodDecorators.map((decorator) => decorator.name),
params, params,
@@ -235,10 +235,10 @@ function collectTsEndpointMetadata(
} }
function refName(ref?: string): string | null { function refName(ref?: string): string | null {
if (!ref || typeof ref !== 'string') { if (!ref || typeof ref !== "string") {
return null; return null;
} }
const parts = ref.split('/'); const parts = ref.split("/");
return parts[parts.length - 1] ?? null; return parts[parts.length - 1] ?? null;
} }
@@ -246,13 +246,13 @@ function collectSchemaRefs(
value: unknown, value: unknown,
refs = new Set<string>(), refs = new Set<string>(),
): Set<string> { ): Set<string> {
if (!value || typeof value !== 'object') { if (!value || typeof value !== "object") {
return refs; return refs;
} }
const recordValue = value as Record<string, unknown>; const recordValue = value as Record<string, unknown>;
const maybeRef = recordValue.$ref; const maybeRef = recordValue.$ref;
if (typeof maybeRef === 'string') { if (typeof maybeRef === "string") {
const name = refName(maybeRef); const name = refName(maybeRef);
if (name) { if (name) {
refs.add(name); refs.add(name);
@@ -267,22 +267,22 @@ function collectSchemaRefs(
} }
function schemaTypeSummary(schema: unknown): string { function schemaTypeSummary(schema: unknown): string {
if (!schema || typeof schema !== 'object') { if (!schema || typeof schema !== "object") {
return 'unknown'; return "unknown";
} }
const schemaObj = schema as Record<string, unknown>; const schemaObj = schema as Record<string, unknown>;
if (typeof schemaObj.$ref === 'string') { if (typeof schemaObj.$ref === "string") {
return refName(schemaObj.$ref) ?? 'unknown'; return refName(schemaObj.$ref) ?? "unknown";
} }
const type = typeof schemaObj.type === 'string' ? schemaObj.type : 'object'; const type = typeof schemaObj.type === "string" ? schemaObj.type : "object";
if (type === 'array') { if (type === "array") {
return `array<${schemaTypeSummary(schemaObj.items)}>`; return `array<${schemaTypeSummary(schemaObj.items)}>`;
} }
if (Array.isArray(schemaObj.enum) && schemaObj.enum.length > 0) { if (Array.isArray(schemaObj.enum) && schemaObj.enum.length > 0) {
return `${type}(${schemaObj.enum.join(' | ')})`; return `${type}(${schemaObj.enum.join(" | ")})`;
} }
return type; return type;
@@ -295,35 +295,35 @@ function normalizeParameters(parameters: unknown[] = []) {
.map((parameter) => { .map((parameter) => {
const schema = (parameter.schema ?? {}) as Record<string, unknown>; const schema = (parameter.schema ?? {}) as Record<string, unknown>;
return { return {
name: typeof parameter.name === 'string' ? parameter.name : '', name: typeof parameter.name === "string" ? parameter.name : "",
in: typeof parameter.in === 'string' ? parameter.in : '', in: typeof parameter.in === "string" ? parameter.in : "",
required: Boolean(parameter.required), required: Boolean(parameter.required),
description: description:
typeof parameter.description === 'string' typeof parameter.description === "string"
? parameter.description ? parameter.description
: null, : null,
type: schemaTypeSummary(schema), type: schemaTypeSummary(schema),
enum: Array.isArray(schema.enum) ? schema.enum : [], enum: Array.isArray(schema.enum) ? schema.enum : [],
default: schema.default ?? null, default: schema.default ?? null,
format: typeof schema.format === 'string' ? schema.format : null, format: typeof schema.format === "string" ? schema.format : null,
}; };
}); });
return { return {
path: parsed.filter((item) => item.in === 'path'), path: parsed.filter((item) => item.in === "path"),
query: parsed.filter((item) => item.in === 'query'), query: parsed.filter((item) => item.in === "query"),
header: parsed.filter((item) => item.in === 'header'), header: parsed.filter((item) => item.in === "header"),
cookie: parsed.filter((item) => item.in === 'cookie'), cookie: parsed.filter((item) => item.in === "cookie"),
}; };
} }
function normalizeRequestBody(requestBody: unknown) { function normalizeRequestBody(requestBody: unknown) {
if (!requestBody || typeof requestBody !== 'object') { if (!requestBody || typeof requestBody !== "object") {
return null; return null;
} }
const requestBodyObj = requestBody as Record<string, unknown>; const requestBodyObj = requestBody as Record<string, unknown>;
if (typeof requestBodyObj.$ref === 'string') { if (typeof requestBodyObj.$ref === "string") {
return { return {
required: false, required: false,
contentTypes: [], contentTypes: [],
@@ -378,9 +378,9 @@ function normalizeResponses(responses: Record<string, unknown>) {
return { return {
status: Number(statusCode), status: Number(statusCode),
description: description:
typeof responseObj.description === 'string' typeof responseObj.description === "string"
? responseObj.description ? responseObj.description
: '', : "",
contentTypes, contentTypes,
schemaTypes, schemaTypes,
schemaRefs: [...refs].sort(), schemaRefs: [...refs].sort(),
@@ -392,25 +392,25 @@ function normalizeResponses(responses: Record<string, unknown>) {
async function run() { async function run() {
const projectRoot = process.cwd(); const projectRoot = process.cwd();
const outputDir = path.join(projectRoot, 'mds'); const outputDir = path.join(projectRoot, "mds");
const outputFile = path.join( const outputFile = path.join(
outputDir, outputDir,
'backend_endpoints_swagger_summary.json', "backend_endpoints_swagger_summary.json",
); );
// Predictions module is conditionally loaded with REDIS_ENABLED in AppModule. // Predictions module is conditionally loaded with REDIS_ENABLED in AppModule.
// Force-enable here to include all backend endpoints in one Swagger export. // Force-enable here to include all backend endpoints in one Swagger export.
process.env.REDIS_ENABLED = 'true'; process.env.REDIS_ENABLED = "true";
const tsMetadata = collectTsEndpointMetadata(projectRoot); const tsMetadata = collectTsEndpointMetadata(projectRoot);
const app = await NestFactory.create(AppModule, { logger: false }); const app = await NestFactory.create(AppModule, { logger: false });
app.setGlobalPrefix('api'); app.setGlobalPrefix("api");
const swaggerConfig = new DocumentBuilder() const swaggerConfig = new DocumentBuilder()
.setTitle('Suggest Bet Backend API') .setTitle("Suggest Bet Backend API")
.setDescription('Auto-generated endpoint summary from Swagger document') .setDescription("Auto-generated endpoint summary from Swagger document")
.setVersion('1.0') .setVersion("1.0")
.addBearerAuth() .addBearerAuth()
.build(); .build();
@@ -419,7 +419,7 @@ async function run() {
const endpoints: Array<Record<string, unknown>> = []; const endpoints: Array<Record<string, unknown>> = [];
const seenOperationIds = new Set<string>(); const seenOperationIds = new Set<string>();
const globalPrefix = 'api'; const globalPrefix = "api";
const sortedPaths = Object.keys(paths).sort((a, b) => a.localeCompare(b)); const sortedPaths = Object.keys(paths).sort((a, b) => a.localeCompare(b));
for (const endpointPath of sortedPaths) { for (const endpointPath of sortedPaths) {
@@ -427,7 +427,7 @@ async function run() {
const methods = Object.keys(pathItem) const methods = Object.keys(pathItem)
.filter((method) => .filter((method) =>
['get', 'post', 'put', 'patch', 'delete', 'options', 'head'].includes( ["get", "post", "put", "patch", "delete", "options", "head"].includes(
method, method,
), ),
) )
@@ -436,7 +436,7 @@ async function run() {
for (const method of methods) { for (const method of methods) {
const operation = pathItem[method] as Record<string, unknown>; const operation = pathItem[method] as Record<string, unknown>;
const operationId = const operationId =
typeof operation.operationId === 'string' ? operation.operationId : ''; typeof operation.operationId === "string" ? operation.operationId : "";
if (operationId) { if (operationId) {
seenOperationIds.add(operationId); seenOperationIds.add(operationId);
@@ -464,13 +464,13 @@ async function run() {
const tsBodyParams = const tsBodyParams =
tsMeta?.params tsMeta?.params
.filter((param) => .filter((param) =>
param.decorators.some((decorator) => decorator.name === 'Body'), param.decorators.some((decorator) => decorator.name === "Body"),
) )
.map((param) => ({ .map((param) => ({
name: param.name, name: param.name,
type: param.type, type: param.type,
bodyKey: bodyKey:
param.decorators.find((decorator) => decorator.name === 'Body') param.decorators.find((decorator) => decorator.name === "Body")
?.firstArg ?? null, ?.firstArg ?? null,
})) ?? []; })) ?? [];
@@ -482,9 +482,9 @@ async function run() {
tag: tags[0] ?? null, tag: tags[0] ?? null,
tags, tags,
summary: summary:
typeof operation.summary === 'string' ? operation.summary : null, typeof operation.summary === "string" ? operation.summary : null,
description: description:
typeof operation.description === 'string' typeof operation.description === "string"
? operation.description ? operation.description
: null, : null,
auth: { auth: {
@@ -527,10 +527,10 @@ async function run() {
tsMeta.controllerRoute, tsMeta.controllerRoute,
tsMeta.routePath, tsMeta.routePath,
), ),
tag: tsMeta.controller.replace(/Controller$/, ''), tag: tsMeta.controller.replace(/Controller$/, ""),
tags: [tsMeta.controller.replace(/Controller$/, '')], tags: [tsMeta.controller.replace(/Controller$/, "")],
summary: null, summary: null,
description: 'Not present in generated Swagger document', description: "Not present in generated Swagger document",
auth: { auth: {
swaggerSecurityRequired: null, swaggerSecurityRequired: null,
swaggerSecuritySchemes: [], swaggerSecuritySchemes: [],
@@ -546,13 +546,13 @@ async function run() {
body: null, body: null,
tsBodyParams: tsMeta.params tsBodyParams: tsMeta.params
.filter((param) => .filter((param) =>
param.decorators.some((decorator) => decorator.name === 'Body'), param.decorators.some((decorator) => decorator.name === "Body"),
) )
.map((param) => ({ .map((param) => ({
name: param.name, name: param.name,
type: param.type, type: param.type,
bodyKey: bodyKey:
param.decorators.find((decorator) => decorator.name === 'Body') param.decorators.find((decorator) => decorator.name === "Body")
?.firstArg ?? null, ?.firstArg ?? null,
})), })),
}, },
@@ -569,19 +569,19 @@ async function run() {
} }
endpoints.sort((a, b) => { endpoints.sort((a, b) => {
const pathA = typeof a.path === 'string' ? a.path : ''; const pathA = typeof a.path === "string" ? a.path : "";
const pathB = typeof b.path === 'string' ? b.path : ''; const pathB = typeof b.path === "string" ? b.path : "";
if (pathA !== pathB) { if (pathA !== pathB) {
return pathA.localeCompare(pathB); return pathA.localeCompare(pathB);
} }
return (typeof a.method === 'string' ? a.method : '').localeCompare( return (typeof a.method === "string" ? a.method : "").localeCompare(
typeof b.method === 'string' ? b.method : '', typeof b.method === "string" ? b.method : "",
); );
}); });
const tagStats = new Map<string, number>(); const tagStats = new Map<string, number>();
for (const endpoint of endpoints) { for (const endpoint of endpoints) {
const tag = typeof endpoint.tag === 'string' ? endpoint.tag : 'Unknown'; const tag = typeof endpoint.tag === "string" ? endpoint.tag : "Unknown";
tagStats.set(tag, (tagStats.get(tag) ?? 0) + 1); tagStats.set(tag, (tagStats.get(tag) ?? 0) + 1);
} }
@@ -591,7 +591,7 @@ async function run() {
.body as Record<string, unknown> | null; .body as Record<string, unknown> | null;
if (requestBody && Array.isArray(requestBody.schemaRefs)) { if (requestBody && Array.isArray(requestBody.schemaRefs)) {
for (const schemaName of requestBody.schemaRefs) { for (const schemaName of requestBody.schemaRefs) {
if (typeof schemaName === 'string') { if (typeof schemaName === "string") {
referencedSchemas.add(schemaName); referencedSchemas.add(schemaName);
} }
} }
@@ -604,7 +604,7 @@ async function run() {
continue; continue;
} }
for (const schemaName of status.schemaRefs) { for (const schemaName of status.schemaRefs) {
if (typeof schemaName === 'string') { if (typeof schemaName === "string") {
referencedSchemas.add(schemaName); referencedSchemas.add(schemaName);
} }
} }
@@ -626,16 +626,16 @@ async function run() {
const summary = { const summary = {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
generatedBy: 'src/scripts/export-swagger-endpoints-summary.ts', generatedBy: "src/scripts/export-swagger-endpoints-summary.ts",
project: 'Suggest-Bet-BE', project: "Suggest-Bet-BE",
swagger: { swagger: {
docsPath: '/api/docs', docsPath: "/api/docs",
globalPrefix: '/api', globalPrefix: "/api",
endpointCountInSwagger: endpoints.filter((item) => item.inSwagger).length, endpointCountInSwagger: endpoints.filter((item) => item.inSwagger).length,
endpointCountTotal: endpoints.length, endpointCountTotal: endpoints.length,
warnings: [ warnings: [
'Swagger output reflects loaded modules for current environment.', "Swagger output reflects loaded modules for current environment.",
'This export forces REDIS_ENABLED=true to include conditional Prediction endpoints.', "This export forces REDIS_ENABLED=true to include conditional Prediction endpoints.",
], ],
}, },
stats: { stats: {
@@ -668,7 +668,7 @@ async function run() {
}; };
mkdirSync(outputDir, { recursive: true }); mkdirSync(outputDir, { recursive: true });
writeFileSync(outputFile, JSON.stringify(summary, null, 2), 'utf8'); writeFileSync(outputFile, JSON.stringify(summary, null, 2), "utf8");
await app.close(); await app.close();
@@ -680,7 +680,7 @@ async function run() {
} }
void run().catch((error: unknown) => { void run().catch((error: unknown) => {
console.error('❌ Failed to export Swagger endpoint summary'); console.error("❌ Failed to export Swagger endpoint summary");
console.error(error); console.error(error);
process.exit(1); process.exit(1);
+41 -41
View File
@@ -16,7 +16,7 @@
* Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/populate-feature-store.ts * Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/populate-feature-store.ts
*/ */
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -180,16 +180,16 @@ function buildFormIndex(
const homeResult = const homeResult =
match.scoreHome > match.scoreAway match.scoreHome > match.scoreAway
? 'W' ? "W"
: match.scoreHome < match.scoreAway : match.scoreHome < match.scoreAway
? 'L' ? "L"
: 'D'; : "D";
const awayResult = const awayResult =
match.scoreAway > match.scoreHome match.scoreAway > match.scoreHome
? 'W' ? "W"
: match.scoreAway < match.scoreHome : match.scoreAway < match.scoreHome
? 'L' ? "L"
: 'D'; : "D";
homeState.results.unshift(homeResult); homeState.results.unshift(homeResult);
awayState.results.unshift(awayResult); awayState.results.unshift(awayResult);
@@ -222,14 +222,14 @@ function extractFormFeatures(formState: TeamFormState): {
let winStreak = 0; let winStreak = 0;
for (const r of formState.results) { for (const r of formState.results) {
if (r === 'W') winStreak++; if (r === "W") winStreak++;
else break; else break;
} }
// Form score: (W=3, D=1, L=0) over last 5, normalized to 0-100 // Form score: (W=3, D=1, L=0) over last 5, normalized to 0-100
const last5Results = formState.results.slice(0, 5); const last5Results = formState.results.slice(0, 5);
const points = last5Results.reduce( const points = last5Results.reduce(
(sum, r) => sum + (r === 'W' ? 3 : r === 'D' ? 1 : 0), (sum, r) => sum + (r === "W" ? 3 : r === "D" ? 1 : 0),
0, 0,
); );
const maxPoints = last5Results.length * 3 || 1; const maxPoints = last5Results.length * 3 || 1;
@@ -302,20 +302,20 @@ async function loadOddsIndex(): Promise<Map<string, OddsData>> {
let bttsY = 0; let bttsY = 0;
for (const s of selections) { for (const s of selections) {
if (s.cat === 'Maç Sonucu') { if (s.cat === "Maç Sonucu") {
if (s.sel === '1') msH = s.odds; if (s.sel === "1") msH = s.odds;
else if (s.sel === 'X' || s.sel === '0') msD = s.odds; else if (s.sel === "X" || s.sel === "0") msD = s.odds;
else if (s.sel === '2') msA = s.odds; else if (s.sel === "2") msA = s.odds;
} else if (s.cat === 'Alt/Üst 2,5') { } else if (s.cat === "Alt/Üst 2,5") {
if ( if (
s.sel.toLowerCase().includes('üst') || s.sel.toLowerCase().includes("üst") ||
s.sel.toLowerCase().includes('over') s.sel.toLowerCase().includes("over")
) )
ou25O = s.odds; ou25O = s.odds;
} else if (s.cat === 'Karşılıklı Gol') { } else if (s.cat === "Karşılıklı Gol") {
if ( if (
s.sel.toLowerCase().includes('var') || s.sel.toLowerCase().includes("var") ||
s.sel.toLowerCase().includes('yes') s.sel.toLowerCase().includes("yes")
) )
bttsY = s.odds; bttsY = s.odds;
} }
@@ -411,7 +411,7 @@ function buildLeagueIndex(matches: MatchRow[]): Map<string, LeagueStats> {
const leagueMap = new Map<string, LeagueStats>(); const leagueMap = new Map<string, LeagueStats>();
for (const match of matches) { for (const match of matches) {
const key = match.leagueId ?? 'unknown'; const key = match.leagueId ?? "unknown";
let stats = leagueMap.get(key); let stats = leagueMap.get(key);
if (!stats) { if (!stats) {
stats = { totalMatches: 0, totalGoals: 0, homeWins: 0, over25Count: 0 }; stats = { totalMatches: 0, totalGoals: 0, homeWins: 0, over25Count: 0 };
@@ -520,15 +520,15 @@ async function populateFeatureStore(): Promise<void> {
const startTime = Date.now(); const startTime = Date.now();
try { try {
console.log('🧠 Feature Store Population — Starting...'); console.log("🧠 Feature Store Population — Starting...");
console.log('─'.repeat(60)); console.log("─".repeat(60));
// Load all finished football matches // Load all finished football matches
console.log('📥 Loading matches...'); console.log("📥 Loading matches...");
const rawMatches = await prisma.match.findMany({ const rawMatches = await prisma.match.findMany({
where: { where: {
sport: 'football', sport: "football",
status: 'FT', status: "FT",
scoreHome: { not: null }, scoreHome: { not: null },
scoreAway: { not: null }, scoreAway: { not: null },
homeTeamId: { not: null }, homeTeamId: { not: null },
@@ -543,7 +543,7 @@ async function populateFeatureStore(): Promise<void> {
scoreAway: true, scoreAway: true,
mstUtc: true, mstUtc: true,
}, },
orderBy: { mstUtc: 'asc' }, orderBy: { mstUtc: "asc" },
}); });
const matches: MatchRow[] = rawMatches.map((m) => ({ const matches: MatchRow[] = rawMatches.map((m) => ({
@@ -559,31 +559,31 @@ async function populateFeatureStore(): Promise<void> {
console.log(` 📊 Matches loaded: ${matches.length.toLocaleString()}`); console.log(` 📊 Matches loaded: ${matches.length.toLocaleString()}`);
// Pre-compute all indexes // Pre-compute all indexes
console.log('\n📊 Building feature indexes...'); console.log("\n📊 Building feature indexes...");
console.log(' 🏅 Pillar 1: Loading ELO ratings...'); console.log(" 🏅 Pillar 1: Loading ELO ratings...");
const eloMap = await loadEloMap(); const eloMap = await loadEloMap();
console.log(' 📈 Pillar 2: Building form index...'); console.log(" 📈 Pillar 2: Building form index...");
const formIndex = buildFormIndex(matches); const formIndex = buildFormIndex(matches);
console.log(' 💰 Pillar 3: Loading odds data...'); console.log(" 💰 Pillar 3: Loading odds data...");
const oddsIndex = await loadOddsIndex(); const oddsIndex = await loadOddsIndex();
console.log(' ⚔️ Pillar 5: Building H2H index...'); console.log(" ⚔️ Pillar 5: Building H2H index...");
const h2hIndex = buildH2HIndex(matches); const h2hIndex = buildH2HIndex(matches);
console.log(' 📋 Pillar 6: Loading referee data...'); console.log(" 📋 Pillar 6: Loading referee data...");
const refereeIndex = await loadRefereeIndex(matches); const refereeIndex = await loadRefereeIndex(matches);
console.log(' 🏟️ Pillar 7: Building league DNA...'); console.log(" 🏟️ Pillar 7: Building league DNA...");
const leagueIndex = buildLeagueIndex(matches); const leagueIndex = buildLeagueIndex(matches);
console.log('\n✅ All indexes built!'); console.log("\n✅ All indexes built!");
console.log('─'.repeat(60)); console.log("─".repeat(60));
// Build feature vectors and batch upsert // Build feature vectors and batch upsert
console.log('💾 Writing features to database...'); console.log("💾 Writing features to database...");
const BATCH_SIZE = 1000; const BATCH_SIZE = 1000;
let processed = 0; let processed = 0;
@@ -651,7 +651,7 @@ async function populateFeatureStore(): Promise<void> {
const refTotal = refStats?.totalMatches ?? 0; const refTotal = refStats?.totalMatches ?? 0;
// Pillar 7: League DNA // Pillar 7: League DNA
const leagueKey = match.leagueId ?? 'unknown'; const leagueKey = match.leagueId ?? "unknown";
const leagueStats = leagueIndex.get(leagueKey) ?? { const leagueStats = leagueIndex.get(leagueKey) ?? {
totalMatches: 1, totalMatches: 1,
totalGoals: 0, totalGoals: 0,
@@ -730,7 +730,7 @@ async function populateFeatureStore(): Promise<void> {
), ),
// Meta // Meta
missingPlayersImpact: 0, missingPlayersImpact: 0,
calculatorVer: 'v2.0', calculatorVer: "v2.0",
}); });
} }
@@ -749,7 +749,7 @@ async function populateFeatureStore(): Promise<void> {
} }
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log('─'.repeat(60)); console.log("─".repeat(60));
console.log(`✅ Feature Store population complete!`); console.log(`✅ Feature Store population complete!`);
console.log(` Features written: ${processed.toLocaleString()}`); console.log(` Features written: ${processed.toLocaleString()}`);
console.log(` Skipped: ${skipped}`); console.log(` Skipped: ${skipped}`);
@@ -758,9 +758,9 @@ async function populateFeatureStore(): Promise<void> {
// Verify // Verify
const count = await prisma.footballAiFeature.count(); const count = await prisma.footballAiFeature.count();
console.log(` DB row count: ${count.toLocaleString()}`); console.log(` DB row count: ${count.toLocaleString()}`);
console.log('─'.repeat(60)); console.log("─".repeat(60));
} catch (error) { } catch (error) {
console.error('❌ Feature store population failed:', error); console.error("❌ Feature store population failed:", error);
process.exit(1); process.exit(1);
} finally { } finally {
await prisma.$disconnect(); await prisma.$disconnect();
+4 -3
View File
@@ -1,5 +1,6 @@
process.env.PORT = process.env.PORT || '3005'; process.env.PORT = process.env.PORT || "3005";
process.env.AI_ENGINE_URL = process.env.AI_ENGINE_URL || 'http://127.0.0.1:8000'; process.env.AI_ENGINE_URL =
process.env.AI_ENGINE_URL || "http://127.0.0.1:8000";
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
require('./run-full-stack'); require("./run-full-stack");

Some files were not shown because too many files have changed in this diff Show More