4 Commits

Author SHA1 Message Date
fahricansecer e4c74025e5 Merge pull request 'cron' (#1) from cron into main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 2m48s
Reviewed-on: #1
2026-04-16 17:22:36 +03:00
fahricansecer c8e7e4e927 cr 2026-04-16 17:21:48 +03:00
Gitea Actions c8fa4c442d chore: add docker info 2026-04-16 12:20:56 +00:00
fahricansecer 0f917695dd chore: add workflow
Check Docker Pi / check-docker (push) Successful in 6s
2026-04-16 15:20:42 +03:00
116 changed files with 3782 additions and 4171 deletions
+62
View File
@@ -0,0 +1,62 @@
Thu Apr 16 12:20:54 UTC 2026
==== DOCKER PS ====
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
78aab6872b85 gitea/runner-images:ubuntu-latest "/bin/sleep 10800" 2 seconds ago Up 1 second GITEA-ACTIONS-TASK-185_WORKFLOW-Check-Docker-Pi_JOB-check-docker
784ca4842e79 iddaai-be:latest "docker-entrypoint.s…" 6 minutes ago Up 6 minutes 3000/tcp, 127.0.0.1:1810->3005/tcp iddaai-be
48f495d45025 iddaai-fe:latest "docker-entrypoint.s…" 2 hours ago Up 2 hours 127.0.0.1:1510->3000/tcp iddaai-fe
a60b07c52d7a gitea/act_runner:latest "/sbin/tini -- run.sh" 22 hours ago Up 22 hours gitea_runner
436552af4199 iddaai-ai-engine "uvicorn main:app --…" 23 hours ago Up 23 hours (healthy) 8000/tcp iddaai-ai-engine
696050fc89de postgres:17-alpine "docker-entrypoint.s…" 23 hours ago Up 23 hours (healthy) 5432/tcp iddaai-postgres
abcc43242dbb redis:7-alpine "docker-entrypoint.s…" 23 hours ago Up 23 hours (healthy) 6379/tcp iddaai-redis
da0f2d5bc898 temporalio/auto-setup:latest "/etc/temporal/entry…" 3 weeks ago Up 8 days 6933-6935/tcp, 6939/tcp, 7233-7235/tcp, 7239/tcp temporal
4768eec66926 ghcr.io/gitroomhq/postiz-app:latest "docker-entrypoint.s…" 3 weeks ago Up 8 days 0.0.0.0:4007->5000/tcp, [::]:4007->5000/tcp postiz
5cfb55782d8b postgres:16 "docker-entrypoint.s…" 3 weeks ago Up 8 days 5432/tcp temporal-postgresql
cf8591458662 redis:7.2 "docker-entrypoint.s…" 3 weeks ago Up 8 days (healthy) 6379/tcp postiz-redis
0108dc0b875d postgres:17-alpine "docker-entrypoint.s…" 3 weeks ago Up 8 days (healthy) 5432/tcp postiz-postgres
c88a569ddb22 elasticsearch:8.16.2 "/bin/tini -- /usr/l…" 3 weeks ago Up 8 days 9200/tcp, 9300/tcp temporal-elasticsearch
208bbf92c2d8 temporalio/ui:latest "./start-ui-server.sh" 3 weeks ago Up 8 days 0.0.0.0:8085->8080/tcp, [::]:8085->8080/tcp temporal-ui
a0555f255857 haruncan-studio-fe:latest "/docker-entrypoint.…" 3 weeks ago Up 8 days 0.0.0.0:1509->80/tcp, [::]:1509->80/tcp haruncan-studio-fe-container
7591abf68bf5 backend-haruncan-studio:latest "docker-entrypoint.s…" 3 weeks ago Up 8 days 0.0.0.0:1809->3000/tcp, [::]:1809->3000/tcp backend-haruncan-studio-container
96d02609b108 ui-indir:latest "docker-entrypoint.s…" 5 weeks ago Up 8 days 0.0.0.0:1507->3000/tcp, [::]:1507->3000/tcp ui-indir-container
f67335b1625f ghcr.io/open-webui/open-webui:main "bash start.sh" 6 weeks ago Up 8 days (healthy) 0.0.0.0:3001->8080/tcp, [::]:3001->8080/tcp openclaw
24b3c6e32817 gitea/gitea:latest "/usr/bin/entrypoint…" 6 weeks ago Up 8 days 0.0.0.0:222->22/tcp, [::]:222->22/tcp, 0.0.0.0:1224->3000/tcp, [::]:1224->3000/tcp gitea
4e64e3199178 postgres:14 "docker-entrypoint.s…" 6 weeks ago Up 8 days 5432/tcp gitea_db
cb7fdcbcd79f postgres:16-alpine "docker-entrypoint.s…" 6 weeks ago Up 8 days 5432/tcp backend_db
f0784aedcadf redis:alpine "docker-entrypoint.s…" 6 weeks ago Up 8 days 6379/tcp apps_redis
fdc89d4a236a portainer/portainer-ce:latest "/portainer" 6 weeks ago Up 8 days 8000/tcp, 9443/tcp, 0.0.0.0:9000->9000/tcp, [::]:9000->9000/tcp portainer
2de41ca39c1f backend-proje:latest "docker-entrypoint.s…" 2 months ago Restarting (1) 35 seconds ago backend-container
89268da2ab86 skript-ui "docker-entrypoint.s…" 2 months ago Up 8 days 0.0.0.0:1506->3000/tcp, [::]:1506->3000/tcp ui-skript-container
8fced773c984 skript-be "docker-entrypoint.s…" 2 months ago Exited (1) 8 days ago backend-skript-container
ec90982f14b6 backend-digicraft "docker-entrypoint.s…" 2 months ago Up 8 days 0.0.0.0:1805->3001/tcp, [::]:1805->3001/tcp backend-digicraft-container
4eec58a7f453 ui-digicraft "/docker-entrypoint.…" 2 months ago Up 8 days 0.0.0.0:1505->80/tcp, [::]:1505->80/tcp ui-digicraft-container
37f844a6cd20 frontend-proje:latest "docker-entrypoint.s…" 2 months ago Up 8 days 0.0.0.0:1800->3000/tcp, [::]:1800->3000/tcp frontend-container
==== DOCKER STATS ====
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
78aab6872b85 GITEA-ACTIONS-TASK-185_WORKFLOW-Check-Docker-Pi_JOB-check-docker 1.07% 0B / 0B 0.00% 1.12MB / 13.6kB 135kB / 0B 12
784ca4842e79 iddaai-be 0.00% 0B / 0B 0.00% 12.9MB / 1.04MB 250kB / 0B 18
48f495d45025 iddaai-fe 0.00% 0B / 0B 0.00% 491kB / 221kB 1.84MB / 0B 26
a60b07c52d7a gitea_runner 0.10% 0B / 0B 0.00% 25.6MB / 24.8MB 4MB / 0B 11
436552af4199 iddaai-ai-engine 0.14% 0B / 0B 0.00% 881kB / 840kB 175MB / 0B 20
696050fc89de iddaai-postgres 0.00% 0B / 0B 0.00% 130MB / 311MB 685MB / 0B 13
abcc43242dbb iddaai-redis 2.39% 0B / 0B 0.00% 222kB / 126B 3.95MB / 0B 6
da0f2d5bc898 temporal 1.49% 0B / 0B 0.00% 1.75GB / 1.88GB 300MB / 0B 15
4768eec66926 postiz 0.54% 0B / 0B 0.00% 8.09MB / 4.33MB 474MB / 0B 152
5cfb55782d8b temporal-postgresql 0.03% 0B / 0B 0.00% 1.88GB / 1.75GB 57.1MB / 0B 39
cf8591458662 postiz-redis 0.17% 0B / 0B 0.00% 1.17MB / 545kB 23.4MB / 0B 6
0108dc0b875d postiz-postgres 0.00% 0B / 0B 0.00% 945kB / 176kB 46.1MB / 0B 8
c88a569ddb22 temporal-elasticsearch 0.18% 0B / 0B 0.00% 763kB / 96.1kB 288MB / 0B 94
208bbf92c2d8 temporal-ui 0.00% 0B / 0B 0.00% 655kB / 22.7kB 66.6MB / 0B 8
a0555f255857 haruncan-studio-fe-container 0.00% 0B / 0B 0.00% 2.13MB / 7.98MB 4.78MB / 0B 5
7591abf68bf5 backend-haruncan-studio-container 0.00% 0B / 0B 0.00% 776kB / 724kB 127MB / 0B 17
96d02609b108 ui-indir-container 0.00% 0B / 0B 0.00% 118MB / 27.3MB 139MB / 0B 11
f67335b1625f openclaw 0.11% 0B / 0B 0.00% 652kB / 16.4kB 1GB / 0B 19
24b3c6e32817 gitea 2.44% 0B / 0B 0.00% 1.69GB / 1.24GB 200MB / 0B 20
4e64e3199178 gitea_db 0.74% 0B / 0B 0.00% 1.03GB / 1.25GB 69.1MB / 0B 10
cb7fdcbcd79f backend_db 0.00% 0B / 0B 0.00% 677kB / 126B 41.4MB / 0B 6
f0784aedcadf apps_redis 0.23% 0B / 0B 0.00% 677kB / 126B 31MB / 0B 6
fdc89d4a236a portainer 0.00% 0B / 0B 0.00% 4.08MB / 20.8MB 126MB / 0B 7
2de41ca39c1f backend-container 0.00% 0B / 0B 0.00% 0B / 0B 0B / 0B 0
89268da2ab86 ui-skript-container 0.00% 0B / 0B 0.00% 1.71MB / 11.3kB 51.3MB / 0B 11
ec90982f14b6 backend-digicraft-container 0.06% 0B / 0B 0.00% 2.41MB / 436kB 142MB / 0B 38
4eec58a7f453 ui-digicraft-container 0.00% 0B / 0B 0.00% 3.95MB / 27.3MB 5.8MB / 0B 5
37f844a6cd20 frontend-container 0.01% 0B / 0B 0.00% 1.93MB / 3.11MB 13.6MB / 0B 11
+6 -6
View File
@@ -1,8 +1,8 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { Test, TestingModule } from "@nestjs/testing";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
describe('AppController', () => {
describe("AppController", () => {
let appController: AppController;
beforeEach(async () => {
@@ -14,9 +14,9 @@ describe('AppController', () => {
appController = app.get<AppController>(AppController);
});
describe('root', () => {
describe("root", () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
expect(appController.getHello()).toBe("Hello World!");
});
});
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { Controller, Get } from "@nestjs/common";
import { AppService } from "./app.service";
@Controller()
export class AppController {
+54 -54
View File
@@ -1,19 +1,19 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { ScheduleModule } from '@nestjs/schedule';
import { redisStore } from 'cache-manager-redis-yet';
import { LoggerModule } from 'nestjs-pino';
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
import { CacheModule } from "@nestjs/cache-manager";
import { ScheduleModule } from "@nestjs/schedule";
import { redisStore } from "cache-manager-redis-yet";
import { LoggerModule } from "nestjs-pino";
import {
I18nModule,
AcceptLanguageResolver,
HeaderResolver,
QueryResolver,
} from 'nestjs-i18n';
import { ServeStaticModule } from '@nestjs/serve-static';
import * as path from 'path';
} from "nestjs-i18n";
import { ServeStaticModule } from "@nestjs/serve-static";
import * as path from "path";
// Config
import {
@@ -24,52 +24,52 @@ import {
i18nConfig,
featuresConfig,
throttleConfig,
} from './config/configuration';
import { geminiConfig } from './modules/gemini/gemini.config';
import { validateEnv } from './config/env.validation';
} from "./config/configuration";
import { geminiConfig } from "./modules/gemini/gemini.config";
import { validateEnv } from "./config/env.validation";
// Common
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { GlobalExceptionFilter } from "./common/filters/global-exception.filter";
import { ResponseInterceptor } from "./common/interceptors/response.interceptor";
// Database
import { DatabaseModule } from './database/database.module';
import { DatabaseModule } from "./database/database.module";
// Core Modules
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { AdminModule } from './modules/admin/admin.module';
import { HealthModule } from './modules/health/health.module';
import { GeminiModule } from './modules/gemini/gemini.module';
import { SocialPosterModule } from './modules/social-poster/social-poster.module';
import { AuthModule } from "./modules/auth/auth.module";
import { UsersModule } from "./modules/users/users.module";
import { AdminModule } from "./modules/admin/admin.module";
import { HealthModule } from "./modules/health/health.module";
import { GeminiModule } from "./modules/gemini/gemini.module";
import { SocialPosterModule } from "./modules/social-poster/social-poster.module";
// Sports Domain Modules
import { MatchesModule } from './modules/matches/matches.module';
import { PredictionsModule } from './modules/predictions/predictions.module';
import { LeaguesModule } from './modules/leagues/leagues.module';
import { AnalysisModule } from './modules/analysis/analysis.module';
import { CouponsModule } from './modules/coupons/coupons.module';
import { SporTotoModule } from './modules/spor-toto/spor-toto.module';
import { MatchesModule } from "./modules/matches/matches.module";
import { PredictionsModule } from "./modules/predictions/predictions.module";
import { LeaguesModule } from "./modules/leagues/leagues.module";
import { AnalysisModule } from "./modules/analysis/analysis.module";
import { CouponsModule } from "./modules/coupons/coupons.module";
import { SporTotoModule } from "./modules/spor-toto/spor-toto.module";
// Services and Tasks
import { ServicesModule } from './services/services.module';
import { TasksModule } from './tasks/tasks.module';
import { ServicesModule } from "./services/services.module";
import { TasksModule } from "./tasks/tasks.module";
// Feeder Module (Historical Data Scraping)
import { FeederModule } from './modules/feeder/feeder.module';
import { FeederModule } from "./modules/feeder/feeder.module";
// Guards
import {
JwtAuthGuard,
RolesGuard,
PermissionsGuard,
} from './modules/auth/guards';
} from "./modules/auth/guards";
// Queue
import { QueueModule } from './common/queues/queue.module';
import { QueueModule } from "./common/queues/queue.module";
const redisEnabled = process.env.REDIS_ENABLED === 'true';
const historicalFeederMode = process.env.FEEDER_MODE === 'historical';
const redisEnabled = process.env.REDIS_ENABLED === "true";
const historicalFeederMode = process.env.FEEDER_MODE === "historical";
@Module({
imports: [
@@ -94,8 +94,8 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical';
// Static Assets (Images, Uploads)
ServeStaticModule.forRoot({
rootPath: path.join(__dirname, '..', 'public'),
serveRoot: '/', // This means public/uploads/x.png -> /uploads/x.png
rootPath: path.join(__dirname, "..", "public"),
serveRoot: "/", // This means public/uploads/x.png -> /uploads/x.png
}),
// Logger (Structured Logging with Pino)
@@ -105,10 +105,10 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical';
useFactory: (configService: ConfigService) => {
return {
pinoHttp: {
level: configService.get('app.isDevelopment') ? 'debug' : 'info',
transport: configService.get('app.isDevelopment')
level: configService.get("app.isDevelopment") ? "debug" : "info",
transport: configService.get("app.isDevelopment")
? {
target: 'pino-pretty',
target: "pino-pretty",
options: {
singleLine: true,
},
@@ -122,15 +122,15 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical';
// i18n
I18nModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
fallbackLanguage: configService.get('i18n.fallbackLanguage', 'en'),
fallbackLanguage: configService.get("i18n.fallbackLanguage", "en"),
loaderOptions: {
path: path.join(__dirname, '../i18n/'),
watch: configService.get('app.isDevelopment', true),
path: path.join(__dirname, "../i18n/"),
watch: configService.get("app.isDevelopment", true),
},
}),
resolvers: [
new HeaderResolver(['x-lang']),
new QueryResolver(['lang']),
new HeaderResolver(["x-lang"]),
new QueryResolver(["lang"]),
AcceptLanguageResolver,
],
inject: [ConfigService],
@@ -141,8 +141,8 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical';
inject: [ConfigService],
useFactory: (configService: ConfigService) => [
{
ttl: configService.get('throttle.ttl', 60000),
limit: configService.get('throttle.limit', 100),
ttl: configService.get("throttle.ttl", 60000),
limit: configService.get("throttle.limit", 100),
},
],
}),
@@ -153,29 +153,29 @@ const historicalFeederMode = process.env.FEEDER_MODE === 'historical';
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
// FORCE DISABLE REDIS if user doesn't want it
const useRedis = configService.get('redis.enabled', false);
const useRedis = configService.get("redis.enabled", false);
if (useRedis) {
try {
const store = await redisStore({
socket: {
host: configService.get('redis.host', 'localhost'),
port: configService.get('redis.port', 6379),
host: configService.get("redis.host", "localhost"),
port: configService.get("redis.port", 6379),
},
ttl: 60 * 1000, // 1 minute default
});
console.log('✅ Redis cache connected');
console.log("✅ Redis cache connected");
return {
store: store as unknown as any,
ttl: 60 * 1000,
};
} catch {
console.warn('⚠️ Redis connection failed, using in-memory cache');
console.warn("⚠️ Redis connection failed, using in-memory cache");
}
}
// Fallback to in-memory cache
console.log('📦 Using in-memory cache');
console.log("📦 Using in-memory cache");
return {
ttl: 60 * 1000,
};
+2 -2
View File
@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { Injectable } from "@nestjs/common";
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
return "Hello World!";
}
}
+29 -29
View File
@@ -8,20 +8,20 @@ import {
Body,
HttpCode,
ParseUUIDPipe,
} from '@nestjs/common';
} from "@nestjs/common";
import {
ApiOperation,
ApiOkResponse,
ApiNotFoundResponse,
ApiBadRequestResponse,
} from '@nestjs/swagger';
import { BaseService } from './base.service';
import { PaginationDto } from '../dto/pagination.dto';
} from "@nestjs/swagger";
import { BaseService } from "./base.service";
import { PaginationDto } from "../dto/pagination.dto";
import {
ApiResponse,
createSuccessResponse,
createPaginatedResponse,
} from '../types/api-response.type';
} from "../types/api-response.type";
/**
* Generic base controller with common CRUD endpoints
@@ -37,8 +37,8 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
@Get()
@HttpCode(200)
@ApiOperation({ summary: 'Get all records with pagination' })
@ApiOkResponse({ description: 'Records retrieved successfully' })
@ApiOperation({ summary: "Get all records with pagination" })
@ApiOkResponse({ description: "Records retrieved successfully" })
async findAll(
@Query() pagination: PaginationDto,
): Promise<ApiResponse<{ items: T[]; meta: any }>> {
@@ -52,13 +52,13 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
);
}
@Get(':id')
@Get(":id")
@HttpCode(200)
@ApiOperation({ summary: 'Get a record by ID' })
@ApiOkResponse({ description: 'Record retrieved successfully' })
@ApiNotFoundResponse({ description: 'Record not found' })
@ApiOperation({ summary: "Get a record by ID" })
@ApiOkResponse({ description: "Record retrieved successfully" })
@ApiNotFoundResponse({ description: "Record not found" })
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
): Promise<ApiResponse<T>> {
const result = await this.service.findOne(id);
return createSuccessResponse(
@@ -69,9 +69,9 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
@Post()
@HttpCode(200)
@ApiOperation({ summary: 'Create a new record' })
@ApiOkResponse({ description: 'Record created successfully' })
@ApiBadRequestResponse({ description: 'Validation failed' })
@ApiOperation({ summary: "Create a new record" })
@ApiOkResponse({ description: "Record created successfully" })
@ApiBadRequestResponse({ description: "Validation failed" })
async create(@Body() createDto: CreateDto): Promise<ApiResponse<T>> {
const result = await this.service.create(createDto);
return createSuccessResponse(
@@ -81,13 +81,13 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
);
}
@Put(':id')
@Put(":id")
@HttpCode(200)
@ApiOperation({ summary: 'Update an existing record' })
@ApiOkResponse({ description: 'Record updated successfully' })
@ApiNotFoundResponse({ description: 'Record not found' })
@ApiOperation({ summary: "Update an existing record" })
@ApiOkResponse({ description: "Record updated successfully" })
@ApiNotFoundResponse({ description: "Record not found" })
async update(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() updateDto: UpdateDto,
): Promise<ApiResponse<T>> {
const result = await this.service.update(id, updateDto);
@@ -97,13 +97,13 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
);
}
@Delete(':id')
@Delete(":id")
@HttpCode(200)
@ApiOperation({ summary: 'Delete a record (soft delete)' })
@ApiOkResponse({ description: 'Record deleted successfully' })
@ApiNotFoundResponse({ description: 'Record not found' })
@ApiOperation({ summary: "Delete a record (soft delete)" })
@ApiOkResponse({ description: "Record deleted successfully" })
@ApiNotFoundResponse({ description: "Record not found" })
async delete(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
): Promise<ApiResponse<T>> {
const result = await this.service.delete(id);
return createSuccessResponse(
@@ -112,12 +112,12 @@ export abstract class BaseController<T, CreateDto, UpdateDto> {
);
}
@Post(':id/restore')
@Post(":id/restore")
@HttpCode(200)
@ApiOperation({ summary: 'Restore a soft-deleted record' })
@ApiOkResponse({ description: 'Record restored successfully' })
@ApiOperation({ summary: "Restore a soft-deleted record" })
@ApiOkResponse({ description: "Record restored successfully" })
async restore(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
): Promise<ApiResponse<T>> {
const result = await this.service.restore(id);
return createSuccessResponse(
+4 -4
View File
@@ -1,7 +1,7 @@
import { NotFoundException, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { PaginationDto } from '../dto/pagination.dto';
import { PaginationMeta } from '../types/api-response.type';
import { NotFoundException, Logger } from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { PaginationDto } from "../dto/pagination.dto";
import { PaginationMeta } from "../types/api-response.type";
/**
* Generic base service with common CRUD operations
+2 -2
View File
@@ -1,2 +1,2 @@
export * from './base.service';
export * from './base.controller';
export * from "./base.service";
export * from "./base.controller";
+5 -5
View File
@@ -2,7 +2,7 @@ import {
createParamDecorator,
ExecutionContext,
SetMetadata,
} from '@nestjs/common';
} from "@nestjs/common";
/**
* Get the current authenticated user from request
@@ -23,19 +23,19 @@ export const CurrentUser = createParamDecorator(
/**
* Mark a route as public (no authentication required)
*/
export const IS_PUBLIC_KEY = 'isPublic';
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
/**
* Require specific roles to access a route
*/
export const ROLES_KEY = 'roles';
export const ROLES_KEY = "roles";
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
/**
* Require specific permissions to access a route
*/
export const PERMISSIONS_KEY = 'permissions';
export const PERMISSIONS_KEY = "permissions";
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
@@ -55,6 +55,6 @@ export const CurrentTenant = createParamDecorator(
export const CurrentLang = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.headers['accept-language'] || 'en';
return request.headers["accept-language"] || "en";
},
);
+15 -15
View File
@@ -1,9 +1,9 @@
import { IsOptional, IsInt, Min, Max, IsString, IsIn } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsInt, Min, Max, IsString, IsIn } from "class-validator";
import { Transform } from "class-transformer";
import { ApiPropertyOptional } from "@nestjs/swagger";
export class PaginationDto {
@ApiPropertyOptional({ default: 1, minimum: 1, description: 'Page number' })
@ApiPropertyOptional({ default: 1, minimum: 1, description: "Page number" })
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsInt()
@@ -14,7 +14,7 @@ export class PaginationDto {
default: 10,
minimum: 1,
maximum: 100,
description: 'Items per page',
description: "Items per page",
})
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@@ -23,21 +23,21 @@ export class PaginationDto {
@Max(100)
limit?: number = 10;
@ApiPropertyOptional({ description: 'Field to sort by' })
@ApiPropertyOptional({ description: "Field to sort by" })
@IsOptional()
@IsString()
sortBy?: string = 'createdAt';
sortBy?: string = "createdAt";
@ApiPropertyOptional({
enum: ['asc', 'desc'],
default: 'desc',
description: 'Sort order',
enum: ["asc", "desc"],
default: "desc",
description: "Sort order",
})
@IsOptional()
@IsIn(['asc', 'desc'])
sortOrder?: 'asc' | 'desc' = 'desc';
@IsIn(["asc", "desc"])
sortOrder?: "asc" | "desc" = "desc";
@ApiPropertyOptional({ description: 'Search query' })
@ApiPropertyOptional({ description: "Search query" })
@IsOptional()
@IsString()
search?: string;
@@ -59,7 +59,7 @@ export class PaginationDto {
/**
* Get orderBy object for Prisma
*/
get orderBy(): Record<string, 'asc' | 'desc'> {
return { [this.sortBy || 'createdAt']: this.sortOrder || 'desc' };
get orderBy(): Record<string, "asc" | "desc"> {
return { [this.sortBy || "createdAt"]: this.sortOrder || "desc" };
}
}
+15 -15
View File
@@ -5,10 +5,10 @@ import {
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { I18nService, I18nContext } from 'nestjs-i18n';
import { ApiResponse, createErrorResponse } from '../types/api-response.type';
} from "@nestjs/common";
import { Request, Response } from "express";
import { I18nService, I18nContext } from "nestjs-i18n";
import { ApiResponse, createErrorResponse } from "../types/api-response.type";
/**
* Global exception filter that catches all exceptions
@@ -27,23 +27,23 @@ export class GlobalExceptionFilter implements ExceptionFilter {
// Determine status and message
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
let message = "Internal server error";
let errors: string[] = [];
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
if (typeof exceptionResponse === "string") {
message = exceptionResponse;
} else if (typeof exceptionResponse === 'object') {
} else if (typeof exceptionResponse === "object") {
const responseObj = exceptionResponse as Record<string, unknown>;
message = (responseObj.message as string) || exception.message;
// Handle validation errors (class-validator)
if (Array.isArray(responseObj.message)) {
errors = responseObj.message as string[];
message = 'VALIDATION_FAILED';
message = "VALIDATION_FAILED";
}
}
} else if (exception instanceof Error) {
@@ -57,22 +57,22 @@ export class GlobalExceptionFilter implements ExceptionFilter {
let lang = i18nContext?.lang;
if (!lang) {
const acceptLanguage = request.headers['accept-language'];
const xLang = request.headers['x-lang'];
const acceptLanguage = request.headers["accept-language"];
const xLang = request.headers["x-lang"];
if (xLang) {
lang = Array.isArray(xLang) ? xLang[0] : xLang;
} else if (acceptLanguage) {
// Take first preferred language: "tr-TR,en;q=0.9" -> "tr"
lang = acceptLanguage.split(',')[0].split(';')[0].split('-')[0];
lang = acceptLanguage.split(",")[0].split(";")[0].split("-")[0];
}
}
lang = lang || 'en';
lang = lang || "en";
// Translate validation error specially
if (message === 'VALIDATION_FAILED') {
message = this.i18n.translate('errors.VALIDATION_FAILED', { lang });
if (message === "VALIDATION_FAILED") {
message = this.i18n.translate("errors.VALIDATION_FAILED", { lang });
} else {
// Try dynamic translation
const translatedMessage = this.i18n.translate(`errors.${message}`, {
@@ -95,7 +95,7 @@ export class GlobalExceptionFilter implements ExceptionFilter {
);
// Build response
const isDevelopment = process.env.NODE_ENV === 'development';
const isDevelopment = process.env.NODE_ENV === "development";
const errorResponse: ApiResponse<null> = createErrorResponse(
message,
status,
+26 -26
View File
@@ -3,16 +3,16 @@ import {
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ApiResponse, createSuccessResponse } from '../types/api-response.type';
} from "@nestjs/common";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";
import { ApiResponse, createSuccessResponse } from "../types/api-response.type";
/**
* Response interceptor that wraps all successful responses
* in the standard ApiResponse format
*/
import { I18nService, I18nContext } from 'nestjs-i18n';
import { I18nService, I18nContext } from "nestjs-i18n";
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<
@@ -34,17 +34,17 @@ export class ResponseInterceptor<T> implements NestInterceptor<
let lang = i18nContext?.lang;
if (!lang) {
const acceptLanguage = request.headers['accept-language'];
const xLang = request.headers['x-lang'];
const acceptLanguage = request.headers["accept-language"];
const xLang = request.headers["x-lang"];
if (xLang) {
lang = Array.isArray(xLang) ? xLang[0] : xLang;
} else if (acceptLanguage) {
lang = acceptLanguage.split(',')[0].split(';')[0].split('-')[0];
lang = acceptLanguage.split(",")[0].split(";")[0].split("-")[0];
}
}
lang = lang || 'en';
lang = lang || "en";
// If data is already an ApiResponse, we should still translate its 'data' property
// But first let's just do it directly on 'data' below before returning
@@ -68,7 +68,7 @@ export class ResponseInterceptor<T> implements NestInterceptor<
}
}
const message = this.i18n.translate('common.success', {
const message = this.i18n.translate("common.success", {
lang,
});
@@ -79,7 +79,7 @@ export class ResponseInterceptor<T> implements NestInterceptor<
}
private translateReasons(data: any, lang: string) {
if (!data || typeof data !== 'object') {
if (!data || typeof data !== "object") {
return;
}
@@ -91,44 +91,44 @@ export class ResponseInterceptor<T> implements NestInterceptor<
Object.keys(data).forEach((key) => {
const val = data[key];
if (
(key === 'reasons' ||
key === 'decision_reasons' ||
key === 'reasoning_factors') &&
(key === "reasons" ||
key === "decision_reasons" ||
key === "reasoning_factors") &&
Array.isArray(val)
) {
data[key] = val.map((r: any) => {
if (typeof r !== 'string') return r;
if (typeof r !== "string") return r;
const translationKey = `predictions.reasons.${r}`;
const translated = this.i18n.translate(translationKey, {
lang,
});
return translated === translationKey ? r : translated;
});
} else if (key === 'reason' && typeof val === 'string') {
} else if (key === "reason" && typeof val === "string") {
const translationKey = `predictions.reasons.${val}`;
const translated = this.i18n.translate(translationKey, {
lang,
});
data[key] = translated === translationKey ? val : translated;
} else if (key === 'flags' && Array.isArray(val)) {
} else if (key === "flags" && Array.isArray(val)) {
data[key] = val.map((r: any) => {
if (typeof r !== 'string') return r;
if (typeof r !== "string") return r;
const translationKey = `predictions.flags.${r}`;
const translated = this.i18n.translate(translationKey, {
lang,
});
return translated === translationKey ? r : translated;
});
} else if (key === 'warnings' && Array.isArray(val)) {
} else if (key === "warnings" && Array.isArray(val)) {
data[key] = val.map((r: any) => {
if (typeof r !== 'string') return r;
if (typeof r !== "string") return r;
const translationKey = `predictions.warnings.${r}`;
const translated = this.i18n.translate(translationKey, {
lang,
});
return translated === translationKey ? r : translated;
});
} else if (typeof val === 'object' && val !== null) {
} else if (typeof val === "object" && val !== null) {
this.translateReasons(val, lang);
}
});
@@ -137,11 +137,11 @@ export class ResponseInterceptor<T> implements NestInterceptor<
private isApiResponse(data: unknown): boolean {
return (
data !== null &&
typeof data === 'object' &&
'success' in data &&
'status' in data &&
'message' in data &&
'data' in data
typeof data === "object" &&
"success" in data &&
"status" in data &&
"message" in data &&
"data" in data
);
}
}
@@ -3,8 +3,8 @@ import {
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
} from "@nestjs/common";
import { Observable } from "rxjs";
/**
* Strips HTML/script tags from all string values in the request body.
@@ -15,7 +15,7 @@ export class SanitizeInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
if (request.body && typeof request.body === 'object') {
if (request.body && typeof request.body === "object") {
request.body = this.sanitize(request.body);
}
@@ -23,7 +23,7 @@ export class SanitizeInterceptor implements NestInterceptor {
}
private sanitize(value: unknown): unknown {
if (typeof value === 'string') {
if (typeof value === "string") {
return this.stripTags(value);
}
@@ -31,7 +31,7 @@ export class SanitizeInterceptor implements NestInterceptor {
return value.map((item) => this.sanitize(item));
}
if (value !== null && typeof value === 'object') {
if (value !== null && typeof value === "object") {
const sanitized: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
sanitized[key] = this.sanitize(val);
@@ -43,6 +43,6 @@ export class SanitizeInterceptor implements NestInterceptor {
}
private stripTags(input: string): string {
return input.replace(/<[^>]*>/g, '');
return input.replace(/<[^>]*>/g, "");
}
}
+7 -7
View File
@@ -1,6 +1,6 @@
import { Module, Global } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Module, Global } from "@nestjs/common";
import { BullModule } from "@nestjs/bullmq";
import { ConfigModule, ConfigService } from "@nestjs/config";
@Global()
@Module({
@@ -9,14 +9,14 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
connection: {
host: configService.get('redis.host', 'localhost'),
port: configService.get('redis.port', 6379),
password: configService.get('redis.password'),
host: configService.get("redis.host", "localhost"),
port: configService.get("redis.port", 6379),
password: configService.get("redis.password"),
},
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
type: "exponential",
delay: 1000,
},
removeOnComplete: true,
+2 -2
View File
@@ -33,7 +33,7 @@ export interface PaginationMeta {
*/
export function createSuccessResponse<T>(
data: T,
message = 'Success',
message = "Success",
status = 200,
): ApiResponse<T> {
return {
@@ -72,7 +72,7 @@ export function createPaginatedResponse<T>(
total: number,
page: number,
limit: number,
message = 'Success',
message = "Success",
): ApiResponse<PaginatedData<T>> {
const totalPages = Math.ceil(total / limit);
+9 -9
View File
@@ -1,10 +1,10 @@
import { existsSync, createWriteStream, mkdirSync } from 'fs';
import { dirname } from 'path';
import axios from 'axios';
import { Logger } from '@nestjs/common';
import { existsSync, createWriteStream, mkdirSync } from "fs";
import { dirname } from "path";
import axios from "axios";
import { Logger } from "@nestjs/common";
export class ImageUtils {
private static readonly logger = new Logger('ImageUtils');
private static readonly logger = new Logger("ImageUtils");
/**
* Downloads an image from a URL and saves it to a local path.
@@ -26,8 +26,8 @@ export class ImageUtils {
// Download
const response = await axios({
url,
method: 'GET',
responseType: 'stream',
method: "GET",
responseType: "stream",
timeout: 5000,
validateStatus: (status) => status === 200, // Only save if 200 OK
});
@@ -37,8 +37,8 @@ export class ImageUtils {
response.data.pipe(writer);
return new Promise((resolve, reject) => {
writer.on('finish', () => resolve(true));
writer.on('error', (err) => {
writer.on("finish", () => resolve(true));
writer.on("error", (err) => {
this.logger.warn(
`Failed to write image to ${localPath}: ${err.message}`,
);
+29 -29
View File
@@ -1,58 +1,58 @@
import { registerAs } from '@nestjs/config';
import { registerAs } from "@nestjs/config";
export const appConfig = registerAs('app', () => ({
env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3005', 10),
isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production',
export const appConfig = registerAs("app", () => ({
env: process.env.NODE_ENV || "development",
port: parseInt(process.env.PORT || "3005", 10),
isDevelopment: process.env.NODE_ENV === "development",
isProduction: process.env.NODE_ENV === "production",
}));
export const databaseConfig = registerAs('database', () => ({
export const databaseConfig = registerAs("database", () => ({
url: process.env.DATABASE_URL,
}));
export const jwtConfig = registerAs('jwt', () => ({
export const jwtConfig = registerAs("jwt", () => ({
secret: process.env.JWT_SECRET,
accessExpiration: process.env.JWT_ACCESS_EXPIRATION || '15m',
refreshExpiration: process.env.JWT_REFRESH_EXPIRATION || '7d',
accessExpiration: process.env.JWT_ACCESS_EXPIRATION || "15m",
refreshExpiration: process.env.JWT_REFRESH_EXPIRATION || "7d",
}));
export const redisConfig = registerAs('redis', () => ({
enabled: process.env.REDIS_ENABLED === 'true',
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
export const redisConfig = registerAs("redis", () => ({
enabled: process.env.REDIS_ENABLED === "true",
host: process.env.REDIS_HOST || "localhost",
port: parseInt(process.env.REDIS_PORT || "6379", 10),
password: process.env.REDIS_PASSWORD || undefined,
}));
export const i18nConfig = registerAs('i18n', () => ({
defaultLanguage: process.env.DEFAULT_LANGUAGE || 'en',
fallbackLanguage: process.env.FALLBACK_LANGUAGE || 'en',
export const i18nConfig = registerAs("i18n", () => ({
defaultLanguage: process.env.DEFAULT_LANGUAGE || "en",
fallbackLanguage: process.env.FALLBACK_LANGUAGE || "en",
}));
export const featuresConfig = registerAs('features', () => ({
mail: process.env.ENABLE_MAIL === 'true',
s3: process.env.ENABLE_S3 === 'true',
websocket: process.env.ENABLE_WEBSOCKET === 'true',
multiTenancy: process.env.ENABLE_MULTI_TENANCY === 'true',
export const featuresConfig = registerAs("features", () => ({
mail: process.env.ENABLE_MAIL === "true",
s3: process.env.ENABLE_S3 === "true",
websocket: process.env.ENABLE_WEBSOCKET === "true",
multiTenancy: process.env.ENABLE_MULTI_TENANCY === "true",
}));
export const mailConfig = registerAs('mail', () => ({
export const mailConfig = registerAs("mail", () => ({
host: process.env.MAIL_HOST,
port: parseInt(process.env.MAIL_PORT || '587', 10),
port: parseInt(process.env.MAIL_PORT || "587", 10),
user: process.env.MAIL_USER,
password: process.env.MAIL_PASSWORD,
from: process.env.MAIL_FROM,
}));
export const s3Config = registerAs('s3', () => ({
export const s3Config = registerAs("s3", () => ({
endpoint: process.env.S3_ENDPOINT,
accessKey: process.env.S3_ACCESS_KEY,
secretKey: process.env.S3_SECRET_KEY,
bucket: process.env.S3_BUCKET,
region: process.env.S3_REGION || 'us-east-1',
region: process.env.S3_REGION || "us-east-1",
}));
export const throttleConfig = registerAs('throttle', () => ({
ttl: parseInt(process.env.THROTTLE_TTL || '60000', 10),
limit: parseInt(process.env.THROTTLE_LIMIT || '100', 10),
export const throttleConfig = registerAs("throttle", () => ({
ttl: parseInt(process.env.THROTTLE_TTL || "60000", 10),
limit: parseInt(process.env.THROTTLE_LIMIT || "100", 10),
}));
+20 -20
View File
@@ -1,4 +1,4 @@
import { z } from 'zod';
import { z } from "zod";
/**
* Helper to parse boolean from string
@@ -6,8 +6,8 @@ import { z } from 'zod';
const booleanString = z
.string()
.optional()
.default('false')
.transform((val) => val === 'true');
.default("false")
.transform((val) => val === "true");
/**
* Environment variables schema validation using Zod
@@ -15,46 +15,46 @@ const booleanString = z
export const envSchema = z.object({
// Environment
NODE_ENV: z
.enum(['development', 'production', 'test'])
.default('development'),
.enum(["development", "production", "test"])
.default("development"),
PORT: z.coerce.number().default(3005),
// Database
DATABASE_URL: z.string().url(),
// AI Engine
AI_ENGINE_URL: z.string().url().default('http://localhost:8000'),
AI_ENGINE_URL: z.string().url().default("http://localhost:8000"),
// JWT
JWT_SECRET: z.string().min(32),
JWT_ACCESS_EXPIRATION: z.string().default('15m'),
JWT_REFRESH_EXPIRATION: z.string().default('7d'),
JWT_ACCESS_EXPIRATION: z.string().default("15m"),
JWT_REFRESH_EXPIRATION: z.string().default("7d"),
// Redis
REDIS_ENABLED: z
.string()
.transform((val) => val === 'true')
.default('false' as any),
REDIS_HOST: z.string().default('localhost'),
.transform((val) => val === "true")
.default("false" as any),
REDIS_HOST: z.string().default("localhost"),
REDIS_PORT: z.coerce.number().default(6379),
REDIS_PASSWORD: z.string().optional(),
// i18n
DEFAULT_LANGUAGE: z.string().default('en'),
FALLBACK_LANGUAGE: z.string().default('en'),
DEFAULT_LANGUAGE: z.string().default("en"),
FALLBACK_LANGUAGE: z.string().default("en"),
// Gemini AI
ENABLE_GEMINI: z
.string()
.transform((val) => val === 'true')
.default('false' as any),
.transform((val) => val === "true")
.default("false" as any),
GOOGLE_API_KEY: z.string().optional(),
GEMINI_DEFAULT_MODEL: z.string().default('gemini-2.5-flash'),
GEMINI_DEFAULT_MODEL: z.string().default("gemini-2.5-flash"),
// Social Poster
SOCIAL_POSTER_ENABLED: z
.string()
.transform((val) => val === 'true')
.default('false' as any),
.transform((val) => val === "true")
.default("false" as any),
TWITTER_API_KEY: z.string().optional(),
TWITTER_API_SECRET: z.string().optional(),
TWITTER_ACCESS_TOKEN: z.string().optional(),
@@ -98,9 +98,9 @@ export function validateEnv(config: Record<string, unknown>): EnvConfig {
if (!result.success) {
const errors = result.error.issues.map(
(err) => `${err.path.join('.')}: ${err.message}`,
(err) => `${err.path.join(".")}: ${err.message}`,
);
throw new Error(`Environment validation failed:\n${errors.join('\n')}`);
throw new Error(`Environment validation failed:\n${errors.join("\n")}`);
}
return result.data;
+2 -2
View File
@@ -1,5 +1,5 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
@Global()
@Module({
+9 -9
View File
@@ -3,11 +3,11 @@ import {
OnModuleInit,
OnModuleDestroy,
Logger,
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
} from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
// Models that support soft delete
const SOFT_DELETE_MODELS = ['user', 'role', 'tenant'];
const SOFT_DELETE_MODELS = ["user", "role", "tenant"];
// Type for Prisma model delegate with common operations
interface PrismaDelegate {
@@ -29,20 +29,20 @@ export class PrismaService
constructor() {
super({
log: [
{ emit: 'event', level: 'query' },
{ emit: 'event', level: 'error' },
{ emit: 'event', level: 'warn' },
{ emit: "event", level: "query" },
{ emit: "event", level: "error" },
{ emit: "event", level: "warn" },
],
});
}
async onModuleInit() {
this.logger.log(
`Connecting to database... URL: ${process.env.DATABASE_URL?.split('@')[1]}`,
`Connecting to database... URL: ${process.env.DATABASE_URL?.split("@")[1]}`,
); // Mask password
try {
await this.$connect();
this.logger.log('✅ Database connected successfully');
this.logger.log("✅ Database connected successfully");
} catch (error) {
this.logger.error(
`❌ Database connection failed: ${error.message}`,
@@ -54,7 +54,7 @@ export class PrismaService
async onModuleDestroy() {
await this.$disconnect();
this.logger.log('🔌 Database disconnected');
this.logger.log("🔌 Database disconnected");
}
/**
+42 -42
View File
@@ -1,12 +1,12 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger as NestLogger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import helmet from 'helmet';
import * as express from 'express';
import { Logger, LoggerErrorInterceptor } from 'nestjs-pino';
import { SanitizeInterceptor } from './common/interceptors/sanitize.interceptor';
import { NestFactory } from "@nestjs/core";
import { ValidationPipe, Logger as NestLogger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { AppModule } from "./app.module";
import helmet from "helmet";
import * as express from "express";
import { Logger, LoggerErrorInterceptor } from "nestjs-pino";
import { SanitizeInterceptor } from "./common/interceptors/sanitize.interceptor";
// BigInt serialization polyfill — Prisma returns BigInt for mstUtc etc.
(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function () {
@@ -14,9 +14,9 @@ import { SanitizeInterceptor } from './common/interceptors/sanitize.interceptor'
};
async function bootstrap() {
const logger = new NestLogger('Bootstrap');
const logger = new NestLogger("Bootstrap");
logger.log('🔄 Starting application...');
logger.log("🔄 Starting application...");
const app = await NestFactory.create(AppModule, { bufferLogs: false });
@@ -31,33 +31,33 @@ async function bootstrap() {
app.use(helmet());
// Request payload size limit
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
app.use(express.json({ limit: "1mb" }));
app.use(express.urlencoded({ extended: true, limit: "1mb" }));
// Graceful Shutdown (Prisma & Docker)
app.enableShutdownHooks();
// Get config service
const configService = app.get(ConfigService);
const port = configService.get<number>('PORT', 3005);
const nodeEnv = configService.get('NODE_ENV', 'development');
const port = configService.get<number>("PORT", 3005);
const nodeEnv = configService.get("NODE_ENV", "development");
// Enable CORS
app.enableCors({
origin:
nodeEnv === 'production'
nodeEnv === "production"
? [
'https://ui-suggestbet.bilgich.com',
'https://suggestbet.bilgich.com',
'https://iddaai.com',
'https://www.iddaai.com',
"https://ui-suggestbet.bilgich.com",
"https://suggestbet.bilgich.com",
"https://iddaai.com",
"https://www.iddaai.com",
]
: true,
credentials: true,
});
// Global prefix
app.setGlobalPrefix('api');
app.setGlobalPrefix("api");
// Validation pipe (Strict)
app.useGlobalPipes(
@@ -72,47 +72,47 @@ async function bootstrap() {
);
// Swagger setup — hidden in production
if (nodeEnv !== 'production') {
if (nodeEnv !== "production") {
const swaggerConfig = new DocumentBuilder()
.setTitle('Suggest-Bet API')
.setTitle("Suggest-Bet API")
.setDescription(
'AI-driven sports betting prediction engine with smart coupon generation',
"AI-driven sports betting prediction engine with smart coupon generation",
)
.setVersion('1.0')
.setVersion("1.0")
.addBearerAuth()
.addTag('Auth', 'Authentication endpoints')
.addTag('Users', 'User management endpoints')
.addTag('Admin', 'Admin management endpoints')
.addTag('Health', 'Health check endpoints')
.addTag('Matches', 'Match listing and detail endpoints')
.addTag('Leagues', 'League, country, and team discovery endpoints')
.addTag('Analysis', 'AI analysis and analysis history endpoints')
.addTag('Coupon', 'Coupon generation and coupon management endpoints')
.addTag('Predictions', 'Prediction and smart-coupon endpoints')
.addTag("Auth", "Authentication endpoints")
.addTag("Users", "User management endpoints")
.addTag("Admin", "Admin management endpoints")
.addTag("Health", "Health check endpoints")
.addTag("Matches", "Match listing and detail endpoints")
.addTag("Leagues", "League, country, and team discovery endpoints")
.addTag("Analysis", "AI analysis and analysis history endpoints")
.addTag("Coupon", "Coupon generation and coupon management endpoints")
.addTag("Predictions", "Prediction and smart-coupon endpoints")
.build();
logger.log('Initializing Swagger...');
logger.log("Initializing Swagger...");
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document, {
SwaggerModule.setup("api/docs", app, document, {
swaggerOptions: {
persistAuthorization: true,
},
});
logger.log('Swagger initialized');
logger.log("Swagger initialized");
}
logger.log(`Attempting to listen on port ${port}...`);
await app.listen(port, '0.0.0.0');
await app.listen(port, "0.0.0.0");
logger.log('═══════════════════════════════════════════════════════════');
logger.log("═══════════════════════════════════════════════════════════");
logger.log(`🚀 Server is running on: http://localhost:${port}/api`);
logger.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`);
logger.log(`💚 Health check: http://localhost:${port}/api/health`);
logger.log(`🌍 Environment: ${nodeEnv.toUpperCase()}`);
logger.log('═══════════════════════════════════════════════════════════');
logger.log("═══════════════════════════════════════════════════════════");
if (nodeEnv === 'development') {
logger.warn('⚠️ Running in development mode');
if (nodeEnv === "development") {
logger.warn("⚠️ Running in development mode");
}
}
+57 -57
View File
@@ -10,32 +10,32 @@ import {
UseInterceptors,
Inject,
NotFoundException,
} from '@nestjs/common';
} from "@nestjs/common";
import {
CacheInterceptor,
CacheKey,
CacheTTL,
CACHE_MANAGER,
} from '@nestjs/cache-manager';
import * as cacheManager from 'cache-manager';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { Roles } from '../../common/decorators';
import { PrismaService } from '../../database/prisma.service';
import { PaginationDto } from '../../common/dto/pagination.dto';
} from "@nestjs/cache-manager";
import * as cacheManager from "cache-manager";
import { ApiTags, ApiBearerAuth, ApiOperation } from "@nestjs/swagger";
import { Roles } from "../../common/decorators";
import { PrismaService } from "../../database/prisma.service";
import { PaginationDto } from "../../common/dto/pagination.dto";
import {
ApiResponse,
createSuccessResponse,
createPaginatedResponse,
PaginatedData,
} from '../../common/types/api-response.type';
import { plainToInstance } from 'class-transformer';
import { UserResponseDto } from '../users/dto/user.dto';
import { UserRole } from '@prisma/client';
} from "../../common/types/api-response.type";
import { plainToInstance } from "class-transformer";
import { UserResponseDto } from "../users/dto/user.dto";
import { UserRole } from "@prisma/client";
@ApiTags('Admin')
@ApiTags("Admin")
@ApiBearerAuth()
@Controller('admin')
@Roles('superadmin')
@Controller("admin")
@Roles("superadmin")
export class AdminController {
constructor(
private readonly prisma: PrismaService,
@@ -44,8 +44,8 @@ export class AdminController {
// ================== Users Management ==================
@Get('users')
@ApiOperation({ summary: 'Get all users (admin)' })
@Get("users")
@ApiOperation({ summary: "Get all users (admin)" })
async getAllUsers(
@Query() pagination: PaginationDto,
): Promise<ApiResponse<PaginatedData<UserResponseDto>>> {
@@ -73,10 +73,10 @@ export class AdminController {
);
}
@Get('users/:id')
@ApiOperation({ summary: 'Get user by ID' })
@Get("users/:id")
@ApiOperation({ summary: "Get user by ID" })
async getUserById(
@Param('id') id: string,
@Param("id") id: string,
): Promise<ApiResponse<UserResponseDto>> {
const user = await this.prisma.user.findUnique({
where: { id },
@@ -84,27 +84,27 @@ export class AdminController {
usageLimit: true,
analyses: {
take: 5,
orderBy: { createdAt: 'desc' },
orderBy: { createdAt: "desc" },
},
},
});
if (!user) {
throw new NotFoundException('User not found');
throw new NotFoundException("User not found");
}
return createSuccessResponse(plainToInstance(UserResponseDto, user));
}
@Put('users/:id/toggle-active')
@ApiOperation({ summary: 'Toggle user active status' })
@Put("users/:id/toggle-active")
@ApiOperation({ summary: "Toggle user active status" })
async toggleUserActive(
@Param('id') id: string,
@Param("id") id: string,
): Promise<ApiResponse<UserResponseDto>> {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException('User not found');
throw new NotFoundException("User not found");
}
const updated = await this.prisma.user.update({
@@ -114,14 +114,14 @@ export class AdminController {
return createSuccessResponse(
plainToInstance(UserResponseDto, updated),
'User status updated',
"User status updated",
);
}
@Put('users/:id/role')
@ApiOperation({ summary: 'Update user role' })
@Put("users/:id/role")
@ApiOperation({ summary: "Update user role" })
async updateUserRole(
@Param('id') id: string,
@Param("id") id: string,
@Body() data: { role: UserRole },
): Promise<ApiResponse<UserResponseDto>> {
const user = await this.prisma.user.update({
@@ -131,14 +131,14 @@ export class AdminController {
return createSuccessResponse(
plainToInstance(UserResponseDto, user),
'User role updated',
"User role updated",
);
}
@Put('users/:id/subscription')
@ApiOperation({ summary: 'Update user subscription' })
@Put("users/:id/subscription")
@ApiOperation({ summary: "Update user subscription" })
async updateUserSubscription(
@Param('id') id: string,
@Param("id") id: string,
@Body()
data: { subscriptionStatus: string; subscriptionExpiresAt?: string },
): Promise<ApiResponse<UserResponseDto>> {
@@ -154,40 +154,40 @@ export class AdminController {
return createSuccessResponse(
plainToInstance(UserResponseDto, user),
'User subscription updated',
"User subscription updated",
);
}
@Delete('users/:id')
@ApiOperation({ summary: 'Soft delete a user' })
async deleteUser(@Param('id') id: string): Promise<ApiResponse<null>> {
@Delete("users/:id")
@ApiOperation({ summary: "Soft delete a user" })
async deleteUser(@Param("id") id: string): Promise<ApiResponse<null>> {
await this.prisma.user.update({
where: { id },
data: { deletedAt: new Date() },
});
return createSuccessResponse(null, 'User deleted');
return createSuccessResponse(null, "User deleted");
}
// ================== App Settings ==================
@Get('settings')
@Get("settings")
@UseInterceptors(CacheInterceptor)
@CacheKey('app_settings')
@CacheKey("app_settings")
@CacheTTL(60 * 1000)
@ApiOperation({ summary: 'Get all app settings' })
@ApiOperation({ summary: "Get all app settings" })
async getAllSettings(): Promise<ApiResponse<Record<string, string>>> {
const settings = await this.prisma.appSetting.findMany();
const settingsMap: Record<string, string> = {};
for (const s of settings) {
settingsMap[s.key] = s.value || '';
settingsMap[s.key] = s.value || "";
}
return createSuccessResponse(settingsMap);
}
@Put('settings/:key')
@ApiOperation({ summary: 'Update an app setting' })
@Put("settings/:key")
@ApiOperation({ summary: "Update an app setting" })
async updateSetting(
@Param('key') key: string,
@Param("key") key: string,
@Body() data: { value: string },
): Promise<ApiResponse<{ key: string; value: string }>> {
const setting = await this.prisma.appSetting.upsert({
@@ -195,17 +195,17 @@ export class AdminController {
update: { value: data.value },
create: { key, value: data.value },
});
await this.cacheManager.del('app_settings');
await this.cacheManager.del("app_settings");
return createSuccessResponse(
{ key: setting.key, value: setting.value || '' },
'Setting updated',
{ key: setting.key, value: setting.value || "" },
"Setting updated",
);
}
// ================== Usage Limits ==================
@Get('usage-limits')
@ApiOperation({ summary: 'Get all usage limits' })
@Get("usage-limits")
@ApiOperation({ summary: "Get all usage limits" })
async getAllUsageLimits(@Query() pagination: PaginationDto) {
const { skip, take } = pagination;
@@ -218,7 +218,7 @@ export class AdminController {
select: { id: true, email: true, firstName: true, lastName: true },
},
},
orderBy: { lastResetDate: 'desc' },
orderBy: { lastResetDate: "desc" },
}),
this.prisma.usageLimit.count(),
]);
@@ -231,8 +231,8 @@ export class AdminController {
);
}
@Post('usage-limits/reset-all')
@ApiOperation({ summary: 'Reset all usage limits' })
@Post("usage-limits/reset-all")
@ApiOperation({ summary: "Reset all usage limits" })
async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> {
const result = await this.prisma.usageLimit.updateMany({
data: {
@@ -244,14 +244,14 @@ export class AdminController {
return createSuccessResponse(
{ count: result.count },
'All usage limits reset',
"All usage limits reset",
);
}
// ================== Analytics ==================
@Get('analytics/overview')
@ApiOperation({ summary: 'Get system analytics overview' })
@Get("analytics/overview")
@ApiOperation({ summary: "Get system analytics overview" })
async getAnalyticsOverview() {
const [
totalUsers,
@@ -262,7 +262,7 @@ export class AdminController {
] = await Promise.all([
this.prisma.user.count(),
this.prisma.user.count({ where: { isActive: true } }),
this.prisma.user.count({ where: { subscriptionStatus: 'active' } }),
this.prisma.user.count({ where: { subscriptionStatus: "active" } }),
this.prisma.match.count(),
this.prisma.prediction.count(),
]);
+2 -2
View File
@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { Module } from "@nestjs/common";
import { AdminController } from "./admin.controller";
@Module({
controllers: [AdminController],
+1 -1
View File
@@ -1,4 +1,4 @@
import { Exclude, Expose, Type } from 'class-transformer';
import { Exclude, Expose, Type } from "class-transformer";
@Exclude()
export class PermissionResponseDto {
+19 -19
View File
@@ -6,20 +6,20 @@ import {
HttpCode,
HttpStatus,
ForbiddenException,
} from '@nestjs/common';
} from "@nestjs/common";
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse,
} from '@nestjs/swagger';
import { AnalysisService } from './analysis.service';
import { AnalyzeMatchesDto } from './dto/analysis-request.dto';
import { CurrentUser } from '../../common/decorators';
} from "@nestjs/swagger";
import { AnalysisService } from "./analysis.service";
import { AnalyzeMatchesDto } from "./dto/analysis-request.dto";
import { CurrentUser } from "../../common/decorators";
@ApiTags('Analysis')
@ApiTags("Analysis")
@ApiBearerAuth()
@Controller('analysis')
@Controller("analysis")
export class AnalysisController {
constructor(private readonly analysisService: AnalysisService) {}
@@ -27,12 +27,12 @@ export class AnalysisController {
* POST /analysis/analyze-matches
* Analyze multiple matches (coupon generation)
*/
@Post('analyze-matches')
@Post("analyze-matches")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Analyze multiple matches for coupon' })
@ApiResponse({ status: 200, description: 'Analysis successful' })
@ApiResponse({ status: 400, description: 'Invalid input' })
@ApiResponse({ status: 429, description: 'Usage limit exceeded' })
@ApiOperation({ summary: "Analyze multiple matches for coupon" })
@ApiResponse({ status: 200, description: "Analysis successful" })
@ApiResponse({ status: 400, description: "Invalid input" })
@ApiResponse({ status: 429, description: "Usage limit exceeded" })
async analyzeMatches(
@CurrentUser() user: any,
@Body() dto: AnalyzeMatchesDto,
@@ -48,7 +48,7 @@ export class AnalysisController {
);
if (!canProceed) {
throw new ForbiddenException('You have exceeded your daily usage limit');
throw new ForbiddenException("You have exceeded your daily usage limit");
}
// Run analysis
@@ -57,7 +57,7 @@ export class AnalysisController {
if (!result) {
return {
success: false,
message: 'None of the provided matches could be analyzed successfully',
message: "None of the provided matches could be analyzed successfully",
};
}
@@ -73,10 +73,10 @@ export class AnalysisController {
/**
* POST /analysis/analyze (alias for /analyze-matches - frontend compatibility)
*/
@Post('analyze')
@Post("analyze")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Analyze multiple matches for coupon (alias)',
summary: "Analyze multiple matches for coupon (alias)",
deprecated: true,
})
async analyzeMatchesAlias(
@@ -90,9 +90,9 @@ export class AnalysisController {
* GET /analysis/history
* Get user's analysis history
*/
@Get('history')
@ApiOperation({ summary: 'Get analysis history' })
@ApiResponse({ status: 200, description: 'History retrieved' })
@Get("history")
@ApiOperation({ summary: "Get analysis history" })
@ApiResponse({ status: 200, description: "History retrieved" })
async getHistory(@CurrentUser() user: any) {
const history = await this.analysisService.getAnalysisHistory(user.id);
return { success: true, data: history };
+5 -5
View File
@@ -1,8 +1,8 @@
import { Module } from '@nestjs/common';
import { AnalysisController } from './analysis.controller';
import { AnalysisService } from './analysis.service';
import { DatabaseModule } from '../../database/database.module';
import { ServicesModule } from '../../services/services.module';
import { Module } from "@nestjs/common";
import { AnalysisController } from "./analysis.controller";
import { AnalysisService } from "./analysis.service";
import { DatabaseModule } from "../../database/database.module";
import { ServicesModule } from "../../services/services.module";
@Module({
imports: [DatabaseModule, ServicesModule],
+7 -7
View File
@@ -1,9 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import {
MatchAnalysisService,
AnalysisResult,
} from '../../services/match-analysis.service';
} from "../../services/match-analysis.service";
@Injectable()
export class AnalysisService {
@@ -50,9 +50,9 @@ export class AnalysisService {
}
// Build URL for analysis
const sport = (targetMatch as any).sport || 'football';
const sport = (targetMatch as any).sport || "football";
const slug = (targetMatch as any).matchSlug || matchId;
const url = `https://www.mackolik.com/${sport === 'basketball' ? 'basketbol/mac' : 'mac'}/${slug}/${matchId}`;
const url = `https://www.mackolik.com/${sport === "basketball" ? "basketbol/mac" : "mac"}/${slug}/${matchId}`;
// Run analysis
const result = await this.matchAnalysisService.analyzeMatch(
@@ -110,7 +110,7 @@ export class AnalysisService {
// Check limits (default: 10 analyses, 3 coupons per day)
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const isPremium = user?.subscriptionStatus === 'active';
const isPremium = user?.subscriptionStatus === "active";
const maxAnalyses = isPremium ? 50 : 10;
const maxCoupons = isPremium ? 10 : 3;
@@ -145,7 +145,7 @@ export class AnalysisService {
async getAnalysisHistory(userId: string, limit: number = 20) {
return this.prisma.analysis.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
orderBy: { createdAt: "desc" },
take: limit,
});
}
@@ -1,10 +1,10 @@
import { IsArray, IsString, ArrayMinSize, ArrayMaxSize } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsString, ArrayMinSize, ArrayMaxSize } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class AnalyzeMatchesDto {
@ApiProperty({
description: 'List of match IDs to analyze',
example: ['match-1', 'match-2'],
description: "List of match IDs to analyze",
example: ["match-1", "match-2"],
minItems: 1,
maxItems: 20,
})
+25 -25
View File
@@ -1,30 +1,30 @@
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
import { I18n, I18nContext } from 'nestjs-i18n';
import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { Controller, Post, Body, HttpCode } from "@nestjs/common";
import { I18n, I18nContext } from "nestjs-i18n";
import { ApiTags, ApiOperation, ApiOkResponse } from "@nestjs/swagger";
import { AuthService } from "./auth.service";
import {
RegisterDto,
LoginDto,
RefreshTokenDto,
TokenResponseDto,
} from './dto/auth.dto';
import { Public } from '../../common/decorators';
} from "./dto/auth.dto";
import { Public } from "../../common/decorators";
import {
ApiResponse,
createSuccessResponse,
} from '../../common/types/api-response.type';
} from "../../common/types/api-response.type";
@ApiTags('Auth')
@Controller('auth')
@ApiTags("Auth")
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@Post("register")
@Public()
@HttpCode(200)
@ApiOperation({ summary: 'Register a new user' })
@ApiOperation({ summary: "Register a new user" })
@ApiOkResponse({
description: 'User registered successfully',
description: "User registered successfully",
type: TokenResponseDto,
})
async register(
@@ -32,28 +32,28 @@ export class AuthController {
@I18n() i18n: I18nContext,
): Promise<ApiResponse<TokenResponseDto>> {
const result = await this.authService.register(dto);
return createSuccessResponse(result, i18n.t('auth.registered'), 201);
return createSuccessResponse(result, i18n.t("auth.registered"), 201);
}
@Post('login')
@Post("login")
@Public()
@HttpCode(200)
@ApiOperation({ summary: 'Login with email and password' })
@ApiOkResponse({ description: 'Login successful', type: TokenResponseDto })
@ApiOperation({ summary: "Login with email and password" })
@ApiOkResponse({ description: "Login successful", type: TokenResponseDto })
async login(
@Body() dto: LoginDto,
@I18n() i18n: I18nContext,
): Promise<ApiResponse<TokenResponseDto>> {
const result = await this.authService.login(dto);
return createSuccessResponse(result, i18n.t('auth.login_success'));
return createSuccessResponse(result, i18n.t("auth.login_success"));
}
@Post('refresh')
@Post("refresh")
@Public()
@HttpCode(200)
@ApiOperation({ summary: 'Refresh access token' })
@ApiOperation({ summary: "Refresh access token" })
@ApiOkResponse({
description: 'Token refreshed successfully',
description: "Token refreshed successfully",
type: TokenResponseDto,
})
async refreshToken(
@@ -61,18 +61,18 @@ export class AuthController {
@I18n() i18n: I18nContext,
): Promise<ApiResponse<TokenResponseDto>> {
const result = await this.authService.refreshToken(dto.refreshToken);
return createSuccessResponse(result, i18n.t('auth.refresh_success'));
return createSuccessResponse(result, i18n.t("auth.refresh_success"));
}
@Post('logout')
@Post("logout")
@HttpCode(200)
@ApiOperation({ summary: 'Logout and invalidate refresh token' })
@ApiOkResponse({ description: 'Logout successful' })
@ApiOperation({ summary: "Logout and invalidate refresh token" })
@ApiOkResponse({ description: "Logout successful" })
async logout(
@Body() dto: RefreshTokenDto,
@I18n() i18n: I18nContext,
): Promise<ApiResponse<null>> {
await this.authService.logout(dto.refreshToken);
return createSuccessResponse(null, i18n.t('auth.logout_success'));
return createSuccessResponse(null, i18n.t("auth.logout_success"));
}
}
+11 -11
View File
@@ -1,22 +1,22 @@
import { Module } from '@nestjs/common';
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtAuthGuard, RolesGuard, PermissionsGuard } from './guards';
import { Module } from "@nestjs/common";
import { JwtModule, JwtModuleOptions } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { ConfigService } from "@nestjs/config";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./strategies/jwt.strategy";
import { JwtAuthGuard, RolesGuard, PermissionsGuard } from "./guards";
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
PassportModule.register({ defaultStrategy: "jwt" }),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService): JwtModuleOptions => {
const expiresIn =
configService.get<string>('JWT_ACCESS_EXPIRATION') || '15m';
configService.get<string>("JWT_ACCESS_EXPIRATION") || "15m";
return {
secret: configService.get<string>('JWT_SECRET'),
secret: configService.get<string>("JWT_SECRET"),
signOptions: {
expiresIn: expiresIn as any,
},
+21 -21
View File
@@ -2,14 +2,14 @@ import {
Injectable,
UnauthorizedException,
ConflictException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { PrismaService } from '../../database/prisma.service';
import { RegisterDto, LoginDto, TokenResponseDto } from './dto/auth.dto';
import { User, UserRole } from '@prisma/client';
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config";
import * as bcrypt from "bcrypt";
import * as crypto from "crypto";
import { PrismaService } from "../../database/prisma.service";
import { RegisterDto, LoginDto, TokenResponseDto } from "./dto/auth.dto";
import { User, UserRole } from "@prisma/client";
export interface JwtPayload {
sub: string;
@@ -36,7 +36,7 @@ export class AuthService {
});
if (existingUser) {
throw new ConflictException('EMAIL_ALREADY_EXISTS');
throw new ConflictException("EMAIL_ALREADY_EXISTS");
}
// Hash password
@@ -76,7 +76,7 @@ export class AuthService {
});
if (!user) {
throw new UnauthorizedException('INVALID_CREDENTIALS');
throw new UnauthorizedException("INVALID_CREDENTIALS");
}
// Verify password
@@ -86,11 +86,11 @@ export class AuthService {
);
if (!isPasswordValid) {
throw new UnauthorizedException('INVALID_CREDENTIALS');
throw new UnauthorizedException("INVALID_CREDENTIALS");
}
if (!user.isActive) {
throw new UnauthorizedException('ACCOUNT_DISABLED');
throw new UnauthorizedException("ACCOUNT_DISABLED");
}
return this.generateTokens(user);
@@ -109,7 +109,7 @@ export class AuthService {
});
if (!storedToken) {
throw new UnauthorizedException('INVALID_REFRESH_TOKEN');
throw new UnauthorizedException("INVALID_REFRESH_TOKEN");
}
if (storedToken.expiresAt < new Date()) {
@@ -117,7 +117,7 @@ export class AuthService {
await this.prisma.refreshToken.delete({
where: { id: storedToken.id },
});
throw new UnauthorizedException('INVALID_REFRESH_TOKEN');
throw new UnauthorizedException("INVALID_REFRESH_TOKEN");
}
// Delete old refresh token
@@ -167,13 +167,13 @@ export class AuthService {
// Generate access token
const accessToken = this.jwtService.sign(payload, {
expiresIn: this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
expiresIn: this.configService.get("JWT_ACCESS_EXPIRATION", "15m"),
});
// Generate refresh token
const refreshTokenValue = crypto.randomUUID();
const refreshExpiration = this.parseExpiration(
this.configService.get('JWT_REFRESH_EXPIRATION', '7d'),
this.configService.get("JWT_REFRESH_EXPIRATION", "7d"),
);
// Store refresh token
@@ -190,7 +190,7 @@ export class AuthService {
refreshToken: refreshTokenValue,
expiresIn:
this.parseExpiration(
this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
this.configService.get("JWT_ACCESS_EXPIRATION", "15m"),
) / 1000, // Convert to seconds
user: {
id: user.id,
@@ -233,13 +233,13 @@ export class AuthService {
const unit = match[2];
switch (unit) {
case 's':
case "s":
return value * 1000;
case 'm':
case "m":
return value * 60 * 1000;
case 'h':
case "h":
return value * 60 * 60 * 1000;
case 'd':
case "d":
return value * 24 * 60 * 60 * 1000;
default:
return 15 * 60 * 1000;
+8 -8
View File
@@ -1,33 +1,33 @@
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength, IsOptional } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export class RegisterDto {
@ApiProperty({ example: 'user@example.com' })
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email: string;
@ApiProperty({ example: 'password123', minLength: 8 })
@ApiProperty({ example: "password123", minLength: 8 })
@IsString()
@MinLength(8)
password: string;
@ApiPropertyOptional({ example: 'John' })
@ApiPropertyOptional({ example: "John" })
@IsOptional()
@IsString()
firstName?: string;
@ApiPropertyOptional({ example: 'Doe' })
@ApiPropertyOptional({ example: "Doe" })
@IsOptional()
@IsString()
lastName?: string;
}
export class LoginDto {
@ApiProperty({ example: 'user@example.com' })
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email: string;
@ApiProperty({ example: 'password123' })
@ApiProperty({ example: "password123" })
@IsString()
password: string;
}
+14 -14
View File
@@ -4,15 +4,15 @@ import {
ExecutionContext,
UnauthorizedException,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Request } from 'express';
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
import { Request } from "express";
import {
IS_PUBLIC_KEY,
ROLES_KEY,
PERMISSIONS_KEY,
} from '../../../common/decorators';
} from "../../../common/decorators";
interface AuthenticatedUser {
id: string;
@@ -25,14 +25,14 @@ interface AuthenticatedUser {
* JWT Auth Guard - Validates JWT token
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
export class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<Request>();
if (request?.method === 'OPTIONS') {
if (request?.method === "OPTIONS") {
return true;
}
@@ -55,10 +55,10 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
info: any,
): TUser {
if (err || !user) {
if (info?.name === 'TokenExpiredError') {
throw new UnauthorizedException('TOKEN_EXPIRED');
if (info?.name === "TokenExpiredError") {
throw new UnauthorizedException("TOKEN_EXPIRED");
}
throw err || new UnauthorizedException('AUTH_REQUIRED');
throw err || new UnauthorizedException("AUTH_REQUIRED");
}
return user;
}
@@ -73,7 +73,7 @@ export class RolesGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
if (req?.method === 'OPTIONS') {
if (req?.method === "OPTIONS") {
return true;
}
@@ -94,7 +94,7 @@ export class RolesGuard implements CanActivate {
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
if (!hasRole) {
throw new ForbiddenException('PERMISSION_DENIED');
throw new ForbiddenException("PERMISSION_DENIED");
}
return true;
@@ -110,7 +110,7 @@ export class PermissionsGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
if (req?.method === 'OPTIONS') {
if (req?.method === "OPTIONS") {
return true;
}
@@ -134,7 +134,7 @@ export class PermissionsGuard implements CanActivate {
);
if (!hasPermission) {
throw new ForbiddenException('PERMISSION_DENIED');
throw new ForbiddenException("PERMISSION_DENIED");
}
return true;
+1 -1
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 { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService, JwtPayload } from '../auth.service';
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
import { AuthService, JwtPayload } from "../auth.service";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -10,9 +10,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
const secret = configService.get<string>('JWT_SECRET');
const secret = configService.get<string>("JWT_SECRET");
if (!secret) {
throw new Error('JWT_SECRET is not defined');
throw new Error("JWT_SECRET is not defined");
}
super({
+36 -36
View File
@@ -9,31 +9,31 @@ import {
UseGuards,
Req,
Logger,
} from '@nestjs/common';
} from "@nestjs/common";
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse,
} from '@nestjs/swagger';
import { CouponsService } from './coupons.service';
import { MatchesService } from '../matches/matches.service';
import { SmartCouponService } from './services/smart-coupon.service';
} from "@nestjs/swagger";
import { CouponsService } from "./coupons.service";
import { MatchesService } from "../matches/matches.service";
import { SmartCouponService } from "./services/smart-coupon.service";
import {
UserCouponService,
CreateCouponDto,
} from './services/user-coupon.service';
} from "./services/user-coupon.service";
import {
AnalyzeMatchDto,
DailyBankoDto,
SuggestCouponDto,
} from './dto/coupons-request.dto';
import { Public } from '../../common/decorators';
import { JwtAuthGuard } from '../auth/guards/auth.guards'; // Assuming standard guard
import { Sport } from '../matches/dto';
} from "./dto/coupons-request.dto";
import { Public } from "../../common/decorators";
import { JwtAuthGuard } from "../auth/guards/auth.guards"; // Assuming standard guard
import { Sport } from "../matches/dto";
@ApiTags('Coupon')
@Controller('coupon')
@ApiTags("Coupon")
@Controller("coupon")
export class CouponsController {
private readonly logger = new Logger(CouponsController.name);
@@ -48,15 +48,15 @@ export class CouponsController {
* POST /coupon/analyze-match
* Analyze a single match with V20+ single-match package
*/
@Post('analyze-match')
@Post("analyze-match")
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Analyze single match with V20 model' })
@ApiResponse({ status: 200, description: 'Match analysis' })
@ApiOperation({ summary: "Analyze single match with V20 model" })
@ApiResponse({ status: 200, description: "Match analysis" })
async analyzeMatch(@Body() dto: AnalyzeMatchDto) {
const analysis = await this.smartCouponService.analyzeMatch(dto.matchId);
if (!analysis) {
return { success: false, message: 'Analiz yapılamadı.' };
return { success: false, message: "Analiz yapılamadı." };
}
return { success: true, data: analysis };
}
@@ -64,11 +64,11 @@ export class CouponsController {
/**
* POST /coupon/analyze (alias for /analyze-match - frontend compatibility)
*/
@Post('analyze')
@Post("analyze")
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Analyze single match with V20 model (alias)',
summary: "Analyze single match with V20 model (alias)",
deprecated: true,
})
async analyzeMatchAlias(@Body() dto: AnalyzeMatchDto) {
@@ -83,7 +83,7 @@ export class CouponsController {
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create and save a user coupon (alias)' })
@ApiOperation({ summary: "Create and save a user coupon (alias)" })
async createCouponAlias(@Body() dto: CreateCouponDto, @Req() req: any) {
return this.createCoupon(dto, req);
}
@@ -92,11 +92,11 @@ export class CouponsController {
* POST /coupon/daily-banko
* Generate a high-confidence banko combo (2 matches)
*/
@Post('daily-banko')
@Post("daily-banko")
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Generate a high-confidence banko combo (2 matches)',
summary: "Generate a high-confidence banko combo (2 matches)",
})
async getDailyBanko(@Body() dto: DailyBankoDto) {
// If no match IDs provided, fetch from system (top 50 upcoming)
@@ -122,7 +122,7 @@ export class CouponsController {
if (candidateMatches.length === 0) {
return {
success: false,
message: 'Kupon için uygun, henüz başlamamış maç bulunamadı.',
message: "Kupon için uygun, henüz başlamamış maç bulunamadı.",
};
}
@@ -131,7 +131,7 @@ export class CouponsController {
if (!coupon) {
return {
success: false,
message: 'Kriterlere uygun (80%+ güvenli) yeterli maç bulunamadı.',
message: "Kriterlere uygun (80%+ güvenli) yeterli maç bulunamadı.",
};
}
return { success: true, data: coupon };
@@ -141,11 +141,11 @@ export class CouponsController {
* POST /coupon/suggest
* Generate Smart Coupon
*/
@Post('suggest')
@Post("suggest")
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Suggest Smart Coupon' })
@ApiResponse({ status: 200, description: 'Smart Coupon generated' })
@ApiOperation({ summary: "Suggest Smart Coupon" })
@ApiResponse({ status: 200, description: "Smart Coupon generated" })
async suggestCoupon(@Body() dto: SuggestCouponDto) {
// If no match IDs provided, fetch from system (top 50 upcoming)
let candidateMatches = dto.matchIds || [];
@@ -170,7 +170,7 @@ export class CouponsController {
if (candidateMatches.length === 0) {
return {
success: false,
message: 'Tahmin için uygun, henüz başlamamış maç bulunamadı.',
message: "Tahmin için uygun, henüz başlamamış maç bulunamadı.",
};
}
@@ -183,7 +183,7 @@ export class CouponsController {
},
);
if (!coupon) {
return { success: false, message: 'Kupon oluşturulamadı.' };
return { success: false, message: "Kupon oluşturulamadı." };
}
return { success: true, data: coupon };
}
@@ -196,11 +196,11 @@ export class CouponsController {
* POST /coupon/create
* Save a user generated coupon
*/
@Post('create')
@Post("create")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create and save a user coupon' })
@ApiOperation({ summary: "Create and save a user coupon" })
async createCoupon(@Body() dto: CreateCouponDto, @Req() req: any) {
// req.user is populated by JwtAuthGuard
const coupon = await this.userCouponService.createCoupon(req.user, dto);
@@ -211,10 +211,10 @@ export class CouponsController {
* GET /coupon/my-stats
* Get user betting statistics (ROI, Win Rate)
*/
@Get('my-stats')
@Get("my-stats")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get user betting statistics' })
@ApiOperation({ summary: "Get user betting statistics" })
async getUserStats(@Req() req: any) {
const stats = await this.userCouponService.getUserStatistics(req.user.id);
return { success: true, data: stats };
@@ -224,11 +224,11 @@ export class CouponsController {
* GET /coupon/history
* Get coupon history (Public/System coupons)
*/
@Get('history')
@Get("history")
@ApiBearerAuth()
@ApiOperation({ summary: 'Get coupon history' })
@ApiResponse({ status: 200, description: 'History retrieved' })
async getHistory(@Query('limit') limit?: string) {
@ApiOperation({ summary: "Get coupon history" })
@ApiResponse({ status: 200, description: "History retrieved" })
async getHistory(@Query("limit") limit?: string) {
// eslint-disable-next-line @typescript-eslint/await-thenable
const results = await this.couponsService.getCouponHistory(
Number(limit) || 10,
+8 -8
View File
@@ -1,11 +1,11 @@
import { Module } from '@nestjs/common';
import { CouponsController } from './coupons.controller';
import { SmartCouponService } from './services/smart-coupon.service';
import { UserCouponService } from './services/user-coupon.service';
import { CouponsService } from './coupons.service';
import { DatabaseModule } from '../../database/database.module';
import { ServicesModule } from '../../services/services.module';
import { MatchesModule } from '../matches/matches.module';
import { Module } from "@nestjs/common";
import { CouponsController } from "./coupons.controller";
import { SmartCouponService } from "./services/smart-coupon.service";
import { UserCouponService } from "./services/user-coupon.service";
import { CouponsService } from "./coupons.service";
import { DatabaseModule } from "../../database/database.module";
import { ServicesModule } from "../../services/services.module";
import { MatchesModule } from "../matches/matches.module";
@Module({
imports: [DatabaseModule, ServicesModule, MatchesModule],
+4 -4
View File
@@ -1,9 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { AiService } from '../../services/ai.service';
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { AiService } from "../../services/ai.service";
// [REMOVED V16 IMPORTS]
export type RiskLevel = 'banko' | 'safe' | 'value';
export type RiskLevel = "banko" | "safe" | "value";
export interface CouponMatch {
matchId: string;
+14 -14
View File
@@ -8,19 +8,19 @@ import {
ArrayMaxSize,
Min,
Max,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
} from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export enum CouponStrategyEnum {
SAFE = 'SAFE',
BALANCED = 'BALANCED',
AGGRESSIVE = 'AGGRESSIVE',
VALUE = 'VALUE',
MIRACLE = 'MIRACLE',
SAFE = "SAFE",
BALANCED = "BALANCED",
AGGRESSIVE = "AGGRESSIVE",
VALUE = "VALUE",
MIRACLE = "MIRACLE",
}
export class AnalyzeMatchDto {
@ApiProperty({ description: 'Match ID to analyze' })
@ApiProperty({ description: "Match ID to analyze" })
@IsString()
@IsNotEmpty()
matchId: string;
@@ -28,8 +28,8 @@ export class AnalyzeMatchDto {
export class DailyBankoDto {
@ApiPropertyOptional({
description: 'Optional match IDs — system fetches if empty',
example: ['match-1', 'match-2'],
description: "Optional match IDs — system fetches if empty",
example: ["match-1", "match-2"],
})
@IsOptional()
@IsArray()
@@ -40,8 +40,8 @@ export class DailyBankoDto {
export class SuggestCouponDto {
@ApiPropertyOptional({
description: 'Match IDs — system fetches if empty',
example: ['match-1', 'match-2'],
description: "Match IDs — system fetches if empty",
example: ["match-1", "match-2"],
})
@IsOptional()
@IsArray()
@@ -57,7 +57,7 @@ export class SuggestCouponDto {
@IsEnum(CouponStrategyEnum)
strategy?: CouponStrategyEnum;
@ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 })
@ApiPropertyOptional({ description: "Maximum matches in coupon", example: 5 })
@IsOptional()
@IsNumber()
@Min(1)
@@ -65,7 +65,7 @@ export class SuggestCouponDto {
maxMatches?: number;
@ApiPropertyOptional({
description: 'Minimum confidence threshold (0-100)',
description: "Minimum confidence threshold (0-100)",
example: 60,
})
@IsOptional()
@@ -1,10 +1,10 @@
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { GeminiService } from '../../gemini/gemini.service';
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
import axios from "axios";
import { GeminiService } from "../../gemini/gemini.service";
export type PredictionRiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
export type PredictionDataQuality = 'HIGH' | 'MEDIUM' | 'LOW';
export type BetGrade = 'A' | 'B' | 'C' | 'PASS';
export type PredictionRiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
export type PredictionDataQuality = "HIGH" | "MEDIUM" | "LOW";
export type BetGrade = "A" | "B" | "C" | "PASS";
export interface PredictionPickRow {
market: string;
@@ -128,7 +128,7 @@ export class SmartCouponService {
private readonly aiEngineUrl: string;
constructor(private readonly geminiService: GeminiService) {
this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000';
this.aiEngineUrl = process.env.AI_ENGINE_URL || "http://ai-engine:8000";
}
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
@@ -147,7 +147,7 @@ export class SmartCouponService {
);
}
throw new HttpException(
'AI analyze failed',
"AI analyze failed",
HttpStatus.SERVICE_UNAVAILABLE,
);
}
@@ -168,7 +168,7 @@ export class SmartCouponService {
const result = await this.geminiService.generateText(
JSON.stringify(prediction, null, 2),
{
model: 'gemini-2.0-flash',
model: "gemini-2.0-flash",
temperature: 0.7,
maxTokens: 600,
systemPrompt: MATCH_COMMENTARY_SYSTEM_PROMPT,
@@ -176,7 +176,7 @@ export class SmartCouponService {
);
return result.text || null;
} catch (error) {
this.logger.warn('AI commentary generation failed, skipping', error);
this.logger.warn("AI commentary generation failed, skipping", error);
return null;
}
}
@@ -188,7 +188,7 @@ export class SmartCouponService {
return null;
}
return this.getSmartCoupon(matchIds, 'SAFE', {
return this.getSmartCoupon(matchIds, "SAFE", {
maxMatches: 2,
minConfidence: 78,
});
@@ -197,11 +197,11 @@ export class SmartCouponService {
async getSmartCoupon(
matchIds: string[],
strategy:
| 'SAFE'
| 'BALANCED'
| 'AGGRESSIVE'
| 'VALUE'
| 'MIRACLE' = 'BALANCED',
| "SAFE"
| "BALANCED"
| "AGGRESSIVE"
| "VALUE"
| "MIRACLE" = "BALANCED",
options: { maxMatches?: number; minConfidence?: number } = {},
): Promise<SmartCouponResult> {
try {
@@ -216,7 +216,7 @@ export class SmartCouponService {
);
return response.data;
} catch (error) {
this.logger.error('Failed to generate smart coupon', error);
this.logger.error("Failed to generate smart coupon", error);
if (axios.isAxiosError(error)) {
const detail = error.response?.data?.detail || error.message;
throw new HttpException(
@@ -225,7 +225,7 @@ export class SmartCouponService {
);
}
throw new HttpException(
'Coupon generation failed',
"Coupon generation failed",
HttpStatus.SERVICE_UNAVAILABLE,
);
}
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../database/prisma.service';
import { User, UserCoupon, Match } from '@prisma/client';
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../../database/prisma.service";
import { User, UserCoupon, Match } from "@prisma/client";
export class CreateCouponDto {
strategy: string; // 'SAFE', 'VALUE', 'CUSTOM'
@@ -39,7 +39,7 @@ export class UserCouponService {
strategy: dto.strategy,
totalOdds: parseFloat(totalOdds.toFixed(2)),
isPublic: dto.isPublic || false,
status: 'PENDING',
status: "PENDING",
couponItems: {
create: dto.items.map((item) => ({
matchId: item.matchId,
@@ -66,7 +66,7 @@ export class UserCouponService {
async updatePendingCoupons(): Promise<void> {
// Sadece bitmiş (FT) maçları içeren PENDING kuponları çek
const pendingCoupons = await this.prisma.userCoupon.findMany({
where: { status: 'PENDING' },
where: { status: "PENDING" },
include: {
couponItems: {
include: { match: true },
@@ -80,7 +80,7 @@ export class UserCouponService {
let allMatchesFinished = true;
for (const item of coupon.couponItems) {
if (item.match.status !== 'FT') {
if (item.match.status !== "FT") {
allMatchesFinished = false;
break; // Henüz bitmemiş maç var, kuponu güncelleme
}
@@ -104,12 +104,12 @@ export class UserCouponService {
if (isCouponLost) {
await this.prisma.userCoupon.update({
where: { id: coupon.id },
data: { status: 'LOST' },
data: { status: "LOST" },
});
} else if (allMatchesFinished && isCouponWon) {
await this.prisma.userCoupon.update({
where: { id: coupon.id },
data: { status: 'WON' },
data: { status: "WON" },
});
}
}
@@ -125,23 +125,23 @@ export class UserCouponService {
const total = home + away;
switch (selection) {
case 'MS 1':
case "MS 1":
return home > away;
case 'MS X':
case "MS X":
return home === away;
case 'MS 2':
case "MS 2":
return away > home;
case '1.5 UST':
case "1.5 UST":
return total > 1.5;
case '2.5 UST':
case "2.5 UST":
return total > 2.5;
case '3.5 UST':
case "3.5 UST":
return total > 3.5;
case '2.5 ALT':
case "2.5 ALT":
return total < 2.5;
case 'KG VAR':
case "KG VAR":
return home > 0 && away > 0;
case 'KG YOK':
case "KG YOK":
return home === 0 || away === 0;
default:
return false; // Bilinmeyen market
@@ -155,7 +155,7 @@ export class UserCouponService {
const coupons = await this.prisma.userCoupon.findMany({
where: {
userId,
status: { in: ['WON', 'LOST'] },
status: { in: ["WON", "LOST"] },
},
});
@@ -171,7 +171,7 @@ export class UserCouponService {
};
}
const wonCoupons = coupons.filter((c) => c.status === 'WON');
const wonCoupons = coupons.filter((c) => c.status === "WON");
const totalInvested = totalCoupons; // Her kupona 1 birim yatırıldığını varsayıyoruz
const totalReturn = wonCoupons.reduce((acc, c) => acc + c.totalOdds, 0);
const winRate = (wonCoupons.length / totalCoupons) * 100;
@@ -5,8 +5,8 @@
* Database operations using Prisma
*/
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import {
Sport,
MatchSummary,
@@ -20,8 +20,8 @@ import {
DbEventPayload,
DbMarketPayload,
BasketballTeamStats,
} from './feeder.types';
import { ImageUtils } from '../../common/utils/image.util';
} from "./feeder.types";
import { ImageUtils } from "../../common/utils/image.util";
@Injectable()
export class FeederPersistenceService {
@@ -33,7 +33,7 @@ export class FeederPersistenceService {
// HELPER FUNCTIONS
// ============================================
private safeString(value: any): string | null {
return value === null || value === undefined || value === ''
return value === null || value === undefined || value === ""
? null
: String(value);
}
@@ -51,12 +51,12 @@ export class FeederPersistenceService {
private mapPositionToEnum(position: string | null): any {
if (!position) return null;
const pos = position.toLowerCase();
if (pos.includes('kaleci') || pos.includes('goalkeeper'))
return 'goalkeeper';
if (pos.includes('defans') || pos.includes('defender')) return 'defender';
if (pos.includes('orta saha') || pos.includes('midfielder'))
return 'midfielder';
if (pos.includes('forvet') || pos.includes('striker')) return 'striker';
if (pos.includes("kaleci") || pos.includes("goalkeeper"))
return "goalkeeper";
if (pos.includes("defans") || pos.includes("defender")) return "defender";
if (pos.includes("orta saha") || pos.includes("midfielder"))
return "midfielder";
if (pos.includes("forvet") || pos.includes("striker")) return "striker";
return null;
}
@@ -93,7 +93,7 @@ export class FeederPersistenceService {
}
for (const s of market.selectionCollection) {
if (!s || s.odd === '-' || s.odd === '') continue;
if (!s || s.odd === "-" || s.odd === "") continue;
const sName = this.safeString(s.name);
const sValue = this.safeString(s.odd);
@@ -107,7 +107,7 @@ export class FeederPersistenceService {
if (existingSel) {
if (existingSel.oddValue !== sValue) {
const oldVal = parseFloat(existingSel.oddValue || '0');
const oldVal = parseFloat(existingSel.oddValue || "0");
const newVal = parseFloat(sValue);
if (!isNaN(oldVal) && !isNaN(newVal)) {
@@ -182,13 +182,13 @@ export class FeederPersistenceService {
const teamsToUpsert = [
{
id: homeTeamId,
name: matchSummary.homeTeam?.name || 'Unknown',
name: matchSummary.homeTeam?.name || "Unknown",
slug: matchSummary.homeTeam?.slug || homeTeamId,
sport: sport,
},
{
id: awayTeamId,
name: matchSummary.awayTeam?.name || 'Unknown',
name: matchSummary.awayTeam?.name || "Unknown",
slug: matchSummary.awayTeam?.slug || awayTeamId,
sport: sport,
},
@@ -221,18 +221,18 @@ export class FeederPersistenceService {
update: {},
create: {
id: countryId,
name: league.country.name || 'Unknown',
name: league.country.name || "Unknown",
},
});
} catch (error: any) {
if (error.code !== 'P2002') throw error;
if (error.code !== "P2002") throw error;
}
}
// 2. Save League (Handle ID changes by checking unique constraint)
let finalLeagueId = this.safeString(league.id);
if (finalLeagueId && countryId) {
const leagueName = league.name || 'Unknown';
const leagueName = league.name || "Unknown";
// Check if league exists by unique constraint (name + country + sport)
const existingLeague = await tx.league.findUnique({
@@ -311,32 +311,32 @@ export class FeederPersistenceService {
headerData?.htScoreAway ??
this.safeInt(matchSummary.score?.ht?.away);
let status = 'NS';
let status = "NS";
if (headerData?.matchStatus) {
if (
headerData.matchStatus === 'postGame' ||
headerData.matchStatus === 'post'
headerData.matchStatus === "postGame" ||
headerData.matchStatus === "post"
) {
status = 'FT';
status = "FT";
} else if (
headerData.matchStatus === 'live' ||
headerData.matchStatus === 'liveGame'
headerData.matchStatus === "live" ||
headerData.matchStatus === "liveGame"
) {
status = 'LIVE';
status = "LIVE";
}
}
// Handle Postponed Matches (ERT)
if (matchSummary.statusBoxContent === 'ERT') {
status = 'POSTPONED';
if (matchSummary.statusBoxContent === "ERT") {
status = "POSTPONED";
}
if (
status === 'NS' &&
status === "NS" &&
finalScoreHome !== null &&
finalScoreAway !== null
) {
status = 'FT';
status = "FT";
}
await tx.match.upsert({
@@ -455,7 +455,7 @@ export class FeederPersistenceService {
}
// 8. Save Team Stats (Football)
if (stats && sport === 'football') {
if (stats && sport === "football") {
const statsRows = [
{
matchId,
@@ -499,7 +499,7 @@ export class FeederPersistenceService {
}
// 8b. Save Team Stats (Basketball)
if (basketballTeamStats && sport === 'basketball') {
if (basketballTeamStats && sport === "basketball") {
const teams = [
{ id: homeTeamId, data: basketballTeamStats.home },
{ id: awayTeamId, data: basketballTeamStats.away },
@@ -558,7 +558,7 @@ export class FeederPersistenceService {
}
// 8c. Save Player Stats (Basketball)
if (basketballPlayerStats.length > 0 && sport === 'basketball') {
if (basketballPlayerStats.length > 0 && sport === "basketball") {
await tx.basketballPlayerStats.deleteMany({ where: { matchId } });
for (const p of basketballPlayerStats) {
@@ -592,12 +592,12 @@ export class FeederPersistenceService {
await this.saveOddsInTransaction(tx, matchId, oddsArray);
// 10. Save Officials
if (sport === 'football' && officialsData.length > 0) {
if (sport === "football" && officialsData.length > 0) {
await tx.matchOfficial.deleteMany({ where: { matchId } });
const processedOfficials = new Set<string>();
for (const o of officialsData) {
const roleName = o.role || 'Referee';
const roleName = o.role || "Referee";
const uniqueKey = `${o.name}_${roleName}`;
if (processedOfficials.has(uniqueKey)) continue;
@@ -798,10 +798,10 @@ export class FeederPersistenceService {
const history = await this.prisma.match.findMany({
where: {
OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }],
status: 'FT',
status: "FT",
mstUtc: { lt: match.mstUtc },
},
orderBy: { mstUtc: 'desc' },
orderBy: { mstUtc: "desc" },
take: 5,
});
@@ -840,8 +840,8 @@ export class FeederPersistenceService {
return {
match_id: match.id,
home_team: match.homeTeam?.name || 'Unknown',
away_team: match.awayTeam?.name || 'Unknown',
home_team: match.homeTeam?.name || "Unknown",
away_team: match.awayTeam?.name || "Unknown",
home_team_id: match.homeTeamId,
away_team_id: match.awayTeamId,
league_id: match.leagueId,
@@ -934,7 +934,7 @@ export class FeederPersistenceService {
scoreHome: liveMatch.scoreHome,
scoreAway: liveMatch.scoreAway,
mstUtc: liveMatch.mstUtc,
sport: liveMatch.sport || 'football',
sport: liveMatch.sport || "football",
};
}
+105 -105
View File
@@ -3,9 +3,9 @@
* HTTP requests with exact headers from working curl commands
*/
import { Injectable, Logger } from '@nestjs/common';
import axios, { AxiosInstance } from 'axios';
import * as cheerio from 'cheerio';
import { Injectable, Logger } from "@nestjs/common";
import axios, { AxiosInstance } from "axios";
import * as cheerio from "cheerio";
import {
Sport,
SPORTS_CONFIG,
@@ -25,7 +25,7 @@ import {
SidelinedResponse,
SidelinedTeamData,
SidelinedPlayer,
} from './feeder.types';
} from "./feeder.types";
@Injectable()
export class FeederScraperService {
@@ -43,13 +43,13 @@ export class FeederScraperService {
this.axios.interceptors.response.use(
(response) => {
this.logger.debug(
`✅ [${response.config.url?.split('?')[0]}] Status: ${response.status}`,
`✅ [${response.config.url?.split("?")[0]}] Status: ${response.status}`,
);
return response;
},
(error) => {
const status = error.response?.status || 'N/A';
const url = error.config?.url?.split('?')[0] || 'Unknown';
const status = error.response?.status || "N/A";
const url = error.config?.url?.split("?")[0] || "Unknown";
this.logger.error(`❌ [${url}] Status: ${status} - ${error.message}`);
throw error;
},
@@ -72,7 +72,7 @@ export class FeederScraperService {
const response = await this.axios.get(url, {
params: {
'sports[]': sportParam,
"sports[]": sportParam,
matchDate: dateString,
},
});
@@ -80,11 +80,11 @@ export class FeederScraperService {
const payload = response.data as unknown;
if (
!payload ||
typeof payload !== 'object' ||
!('status' in payload) ||
!('data' in payload)
typeof payload !== "object" ||
!("status" in payload) ||
!("data" in payload)
) {
throw new Error('Historical source payload has invalid shape');
throw new Error("Historical source payload has invalid shape");
}
return payload as LivescoresApiResponse;
@@ -101,14 +101,14 @@ export class FeederScraperService {
const response = await this.axios.get(url, {
params: {
matchId,
sdapiLanguageCode: 'tr-mk',
ajaxViewName: 'match-details',
ajaxPartialViewName: 'match-details-status',
displayMode: 'all',
sdapiLanguageCode: "tr-mk",
ajaxViewName: "match-details",
ajaxPartialViewName: "match-details-status",
displayMode: "all",
},
});
return this.parseMatchHeader(response.data.data?.html || '');
return this.parseMatchHeader(response.data.data?.html || "");
}
private parseMatchHeader(html: string): ParsedMatchHeader {
@@ -116,7 +116,7 @@ export class FeederScraperService {
// Extract match-status from data attribute
const matchStatus =
($('[data-match-status]').attr('data-match-status') as any) || 'postGame';
($("[data-match-status]").attr("data-match-status") as any) || "postGame";
// Extract scores
const scoreHome = this.safeInt($('[data-slot="score-home"]').text().trim());
@@ -126,7 +126,7 @@ export class FeederScraperService {
let htScoreHome: number | null = null;
let htScoreAway: number | null = null;
const detailedScore = $('.p0c-soccer-match-details-header__detailed-score')
const detailedScore = $(".p0c-soccer-match-details-header__detailed-score")
.text()
.trim();
const htMatch = detailedScore.match(/\(İY\s*(\d+)\s*-\s*(\d+)\)/);
@@ -143,7 +143,7 @@ export class FeederScraperService {
// ============================================
async fetchKeyEvents(
matchId: string,
): Promise<KeyEventsResponse['data'] | null> {
): Promise<KeyEventsResponse["data"] | null> {
const url = `https://www.mackolik.com/ajax/football/key-events`;
this.logger.debug(`📡 [${matchId}] Fetching key events`);
@@ -151,7 +151,7 @@ export class FeederScraperService {
try {
const response = await this.axios.get<KeyEventsResponse>(url, {
params: {
ajaxViewName: 'events',
ajaxViewName: "events",
matchId,
seasonId: matchId, // Same as matchId
},
@@ -172,7 +172,7 @@ export class FeederScraperService {
// ============================================
async fetchStartingFormation(
matchId: string,
): Promise<MatchStatsResponse['data'] | null> {
): Promise<MatchStatsResponse["data"] | null> {
const url = `https://www.mackolik.com/ajax/football/match-stats`;
this.logger.debug(`📡 [${matchId}] Fetching starting formation`);
@@ -180,7 +180,7 @@ export class FeederScraperService {
try {
const response = await this.axios.get<MatchStatsResponse>(url, {
params: {
ajaxViewName: 'starting-formation',
ajaxViewName: "starting-formation",
matchId,
seasonId: matchId,
},
@@ -201,7 +201,7 @@ export class FeederScraperService {
// ============================================
async fetchSubstitutions(
matchId: string,
): Promise<MatchStatsResponse['data'] | null> {
): Promise<MatchStatsResponse["data"] | null> {
const url = `https://www.mackolik.com/ajax/football/match-stats`;
this.logger.debug(`📡 [${matchId}] Fetching substitutions`);
@@ -209,7 +209,7 @@ export class FeederScraperService {
try {
const response = await this.axios.get<MatchStatsResponse>(url, {
params: {
ajaxViewName: 'substitutions',
ajaxViewName: "substitutions",
matchId,
seasonId: matchId,
},
@@ -230,7 +230,7 @@ export class FeederScraperService {
// ============================================
async fetchGameStats(
matchId: string,
): Promise<GameStatsResponse['data'] | null> {
): Promise<GameStatsResponse["data"] | null> {
const url = `https://www.mackolik.com/ajax/soccer/match/gameStats`;
this.logger.debug(`📡 [${matchId}] Fetching game stats`);
@@ -253,7 +253,7 @@ export class FeederScraperService {
// ============================================
// MANAGER
// ============================================
async fetchManager(matchId: string): Promise<ManagerResponse['data'] | null> {
async fetchManager(matchId: string): Promise<ManagerResponse["data"] | null> {
const url = `https://www.mackolik.com/ajax/football/match-stats`;
this.logger.debug(`📡 [${matchId}] Fetching manager`);
@@ -261,7 +261,7 @@ export class FeederScraperService {
try {
const response = await this.axios.get<ManagerResponse>(url, {
params: {
ajaxViewName: 'manager',
ajaxViewName: "manager",
matchId,
seasonId: matchId,
},
@@ -287,10 +287,10 @@ export class FeederScraperService {
try {
const response = await this.axios.get<IddaaMarketsHtmlResponse>(url, {
params: { template: 'all' },
params: { template: "all" },
});
return this.parseIddaaMarketsHtml(response.data.data?.html || '');
return this.parseIddaaMarketsHtml(response.data.data?.html || "");
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Iddaa markets not found (404)`);
@@ -306,30 +306,30 @@ export class FeederScraperService {
const $ = cheerio.load(html);
const markets: ParsedMarket[] = [];
$('.widget-iddaa-markets__market-item').each((_, marketEl) => {
$(".widget-iddaa-markets__market-item").each((_, marketEl) => {
const $market = $(marketEl);
const marketId = $market.attr('data-market') || '';
const marketId = $market.attr("data-market") || "";
const marketName = $market
.find('.widget-iddaa-markets__header-text')
.find(".widget-iddaa-markets__header-text")
.text()
.trim();
const iddaaCode = $market
.find('.widget-iddaa-markets__iddaa-code')
.find(".widget-iddaa-markets__iddaa-code")
.text()
.trim();
const mbc = $market.find('.widget-iddaa-markets__mbc').text().trim();
const mbc = $market.find(".widget-iddaa-markets__mbc").text().trim();
const selections: ParsedSelection[] = [];
$market.find('.widget-iddaa-markets__option').each((_, optionEl) => {
$market.find(".widget-iddaa-markets__option").each((_, optionEl) => {
const $option = $(optionEl);
selections.push({
shortcode: $option.attr('data-shortcode') || '',
outcomeNo: $option.attr('data-outcome-no') || '',
label: $option.find('.widget-iddaa-markets__label').text().trim(),
value: $option.find('.widget-iddaa-markets__value').text().trim(),
shortcode: $option.attr("data-shortcode") || "",
outcomeNo: $option.attr("data-outcome-no") || "",
label: $option.find(".widget-iddaa-markets__label").text().trim(),
value: $option.find(".widget-iddaa-markets__value").text().trim(),
});
});
@@ -347,7 +347,7 @@ export class FeederScraperService {
// ============================================
async fetchBasketballBoxScore(
matchId: string,
): Promise<BasketballBoxScoreResponse['data'] | null> {
): Promise<BasketballBoxScoreResponse["data"] | null> {
// Updated URL based on user request
const url = `https://www.mackolik.com/ajax/basketball/match/box-score`;
@@ -357,8 +357,8 @@ export class FeederScraperService {
const response = await this.axios.get<BasketballBoxScoreResponse>(url, {
params: { matchId },
headers: {
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': DEFAULT_HEADERS['User-Agent'],
"X-Requested-With": "XMLHttpRequest",
"User-Agent": DEFAULT_HEADERS["User-Agent"],
},
});
@@ -382,25 +382,25 @@ export class FeederScraperService {
const players: Partial<BasketballPlayerStats>[] = [];
// Parse individual players from widget rows
$('.widget-basketball-match-box-score__row').each((_, elem) => {
$(".widget-basketball-match-box-score__row").each((_, elem) => {
const row = $(elem);
// Skip if no player name found
const nameElem = row.find('.widget-basketball-match-box-score__player');
const nameElem = row.find(".widget-basketball-match-box-score__player");
if (!nameElem.length) return;
const name = nameElem.text().trim();
// Indices based on User HTML:
// 0: Name, 1: Min, 2: Pts, 3: Reb, 4: Ast, 5: 2FG, 6: 3FG, 7: FT, 8: Fouls, 9: Blk, 10: Stl, 11: TO
const values = row.find('td');
const values = row.find("td");
// Check if it's a valid player row (should have enough columns)
if (values.length < 10) return;
// Extract ID from link if possible
let playerId = '';
const link = nameElem.find('a').attr('href');
let playerId = "";
const link = nameElem.find("a").attr("href");
if (link) {
playerId = this.extractPlayerIdFromUrl(link) || '';
playerId = this.extractPlayerIdFromUrl(link) || "";
}
players.push({
@@ -410,16 +410,16 @@ export class FeederScraperService {
points: this.safeInt(values.eq(2).text().trim()) || 0,
rebounds: this.safeInt(values.eq(3).text().trim()) || 0,
assists: this.safeInt(values.eq(4).text().trim()) || 0,
fgMade: this.safeInt(values.eq(5).text().trim().split('/')[0]) || 0,
fgMade: this.safeInt(values.eq(5).text().trim().split("/")[0]) || 0,
fgAttempted:
this.safeInt(values.eq(5).text().trim().split('/')[1]) || 0,
this.safeInt(values.eq(5).text().trim().split("/")[1]) || 0,
threePtMade:
this.safeInt(values.eq(6).text().trim().split('/')[0]) || 0,
this.safeInt(values.eq(6).text().trim().split("/")[0]) || 0,
threePtAttempted:
this.safeInt(values.eq(6).text().trim().split('/')[1]) || 0,
ftMade: this.safeInt(values.eq(7).text().trim().split('/')[0]) || 0,
this.safeInt(values.eq(6).text().trim().split("/")[1]) || 0,
ftMade: this.safeInt(values.eq(7).text().trim().split("/")[0]) || 0,
ftAttempted:
this.safeInt(values.eq(7).text().trim().split('/')[1]) || 0,
this.safeInt(values.eq(7).text().trim().split("/")[1]) || 0,
fouls: this.safeInt(values.eq(8).text().trim()) || 0,
blocks: this.safeInt(values.eq(9).text().trim()) || 0,
steals: this.safeInt(values.eq(10).text().trim()) || 0,
@@ -428,7 +428,7 @@ export class FeederScraperService {
});
// Parse Team Totals from Footer
const footerRow = $('.widget-basketball-match-box-score__footer td');
const footerRow = $(".widget-basketball-match-box-score__footer td");
let teamTotals: any = {};
if (footerRow.length > 5) {
@@ -438,16 +438,16 @@ export class FeederScraperService {
points: this.safeInt(footerRow.eq(2).text().trim()) || 0,
rebounds: this.safeInt(footerRow.eq(3).text().trim()) || 0,
assists: this.safeInt(footerRow.eq(4).text().trim()) || 0,
fgMade: this.safeInt(footerRow.eq(5).text().trim().split('/')[0]) || 0,
fgMade: this.safeInt(footerRow.eq(5).text().trim().split("/")[0]) || 0,
fgAttempted:
this.safeInt(footerRow.eq(5).text().trim().split('/')[1]) || 0,
this.safeInt(footerRow.eq(5).text().trim().split("/")[1]) || 0,
threePtMade:
this.safeInt(footerRow.eq(6).text().trim().split('/')[0]) || 0,
this.safeInt(footerRow.eq(6).text().trim().split("/")[0]) || 0,
threePtAttempted:
this.safeInt(footerRow.eq(6).text().trim().split('/')[1]) || 0,
ftMade: this.safeInt(footerRow.eq(7).text().trim().split('/')[0]) || 0,
this.safeInt(footerRow.eq(6).text().trim().split("/")[1]) || 0,
ftMade: this.safeInt(footerRow.eq(7).text().trim().split("/")[0]) || 0,
ftAttempted:
this.safeInt(footerRow.eq(7).text().trim().split('/')[1]) || 0,
this.safeInt(footerRow.eq(7).text().trim().split("/")[1]) || 0,
fouls: this.safeInt(footerRow.eq(8).text().trim()) || 0,
blocks: this.safeInt(footerRow.eq(9).text().trim()) || 0,
steals: this.safeInt(footerRow.eq(10).text().trim()) || 0,
@@ -474,11 +474,11 @@ export class FeederScraperService {
// For HTML pages, we DON'T send X-Requested-With header
const response = await this.axios.get(url, {
headers: {
'User-Agent': DEFAULT_HEADERS['User-Agent'],
Referer: DEFAULT_HEADERS['Referer'],
'Accept-Language': DEFAULT_HEADERS['Accept-Language'],
"User-Agent": DEFAULT_HEADERS["User-Agent"],
Referer: DEFAULT_HEADERS["Referer"],
"Accept-Language": DEFAULT_HEADERS["Accept-Language"],
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
// NO X-Requested-With for HTML pages!
},
});
@@ -507,8 +507,8 @@ export class FeederScraperService {
const response = await this.axios.get(url, {
params: { matchId },
headers: {
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': DEFAULT_HEADERS['User-Agent'],
"X-Requested-With": "XMLHttpRequest",
"User-Agent": DEFAULT_HEADERS["User-Agent"],
},
});
@@ -532,12 +532,12 @@ export class FeederScraperService {
const $ = cheerio.load(html);
const rows = $(
'.widget-basketball-match-details-header__score-details tbody tr',
".widget-basketball-match-details-header__score-details tbody tr",
);
if (rows.length < 2) return null;
const parseRow = (row: any) => {
const cols = $(row).find('td');
const cols = $(row).find("td");
// Format: TeamName, Q1, Q2, Q3, Q4, Final
// Values are inside .widget-basketball-match-details-header__score-part (just the quarter score)
// or direct text if simple table.
@@ -545,7 +545,7 @@ export class FeederScraperService {
const getScore = (index: number) => {
const cell = cols.eq(index);
const part = cell.find(
'.widget-basketball-match-details-header__score-part',
".widget-basketball-match-details-header__score-part",
);
const val = part.length ? part.text() : cell.text();
return this.safeInt(val.trim());
@@ -580,10 +580,10 @@ export class FeederScraperService {
try {
const response = await this.axios.get<IddaaMarketsHtmlResponse>(url, {
params: { template: 'all' },
params: { template: "all" },
headers: {
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': DEFAULT_HEADERS['User-Agent'],
"X-Requested-With": "XMLHttpRequest",
"User-Agent": DEFAULT_HEADERS["User-Agent"],
},
});
@@ -602,7 +602,7 @@ export class FeederScraperService {
extractPlayerIdFromUrl(url: string | undefined): string | null {
if (!url) return null;
const parts = url.split('/');
const parts = url.split("/");
return parts[parts.length - 1] || null;
}
@@ -620,12 +620,12 @@ export class FeederScraperService {
try {
const response = await this.axios.get(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7',
Referer: 'https://www.mackolik.com',
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
Referer: "https://www.mackolik.com",
},
timeout: 10000,
});
@@ -652,24 +652,24 @@ export class FeederScraperService {
$: cheerio.CheerioAPI,
teamIndex: number,
): SidelinedTeamData {
const sidelinedWidgets = $('.widget-sidelined-players');
const sidelinedWidgets = $(".widget-sidelined-players");
if (sidelinedWidgets.length <= teamIndex) {
return { teamName: '', teamId: '', totalSidelined: 0, players: [] };
return { teamName: "", teamId: "", totalSidelined: 0, players: [] };
}
const widget = sidelinedWidgets.eq(teamIndex);
const teamCrest = widget.find('.widget-sidelined-players__header-crest');
const teamCrestSrc = teamCrest.attr('src') || '';
const teamId = teamCrestSrc.split('/').pop() || '';
const teamCrest = widget.find(".widget-sidelined-players__header-crest");
const teamCrestSrc = teamCrest.attr("src") || "";
const teamId = teamCrestSrc.split("/").pop() || "";
const teamName = widget
.find('.widget-sidelined-players__header-text')
.find(".widget-sidelined-players__header-text")
.text()
.trim();
const players: SidelinedPlayer[] = [];
widget.find('.widget-sidelined-players__item').each((_, element) => {
widget.find(".widget-sidelined-players__item").each((_, element) => {
const playerData = this._parsePlayerItem($, $(element));
if (playerData) {
players.push(playerData);
@@ -689,44 +689,44 @@ export class FeederScraperService {
$item: cheerio.Cheerio<any>,
): SidelinedPlayer | null {
try {
const nameElem = $item.find('.widget-sidelined-players__name');
const nameElem = $item.find(".widget-sidelined-players__name");
const playerName = nameElem.text().trim();
const playerUrl = nameElem.attr('href') || '';
const playerId = playerUrl.split('/').pop() || '';
const playerUrl = nameElem.attr("href") || "";
const playerId = playerUrl.split("/").pop() || "";
const positionElem = $item.find('.widget-sidelined-players__position');
const position = positionElem.attr('title') || '';
const positionElem = $item.find(".widget-sidelined-players__position");
const position = positionElem.attr("title") || "";
const positionShort = positionElem.text().trim();
const reasonImg = $item.find('.widget-sidelined-players__reason img');
const reasonIcon = reasonImg.attr('src') || '';
const reasonImg = $item.find(".widget-sidelined-players__reason img");
const reasonIcon = reasonImg.attr("src") || "";
const numbers = $item.find('.widget-sidelined-players__number');
const numbers = $item.find(".widget-sidelined-players__number");
// Use parseInt EXACTLY as in JS script (ignoring potential NaN for now, will handle via helper if needed but safer to stick to script logic first)
const matchesMissedText =
numbers.length > 0 ? numbers.eq(0).text().trim() : '';
numbers.length > 0 ? numbers.eq(0).text().trim() : "";
const matchesMissed = matchesMissedText
? parseInt(matchesMissedText, 10)
: null;
const averageText = numbers.length > 1 ? numbers.eq(1).text().trim() : '';
const averageText = numbers.length > 1 ? numbers.eq(1).text().trim() : "";
const average = averageText ? parseInt(averageText, 10) : null;
const description = $item
.find('.widget-sidelined-players__value')
.find(".widget-sidelined-players__value")
.text()
.trim();
const type = reasonIcon.includes('shortage_1.png')
? 'injury'
: reasonIcon.includes('suspension')
? 'suspension'
: 'other';
const type = reasonIcon.includes("shortage_1.png")
? "injury"
: reasonIcon.includes("suspension")
? "suspension"
: "other";
return {
playerId,
playerName,
playerUrl: playerUrl.startsWith('http')
playerUrl: playerUrl.startsWith("http")
? playerUrl
: `https://www.mackolik.com${playerUrl}`,
position,
@@ -735,7 +735,7 @@ export class FeederScraperService {
description,
matchesMissed: isNaN(matchesMissed as number) ? null : matchesMissed,
average: isNaN(average as number) ? null : average,
reasonIcon: reasonIcon.startsWith('http')
reasonIcon: reasonIcon.startsWith("http")
? reasonIcon
: `https://www.mackolik.com${reasonIcon}`, // Keep safer URL construction but stick closer to logic
};
@@ -3,8 +3,8 @@
* Transforms raw API data into database-ready formats
*/
import { Injectable, Logger } from '@nestjs/common';
import * as cheerio from 'cheerio';
import { Injectable, Logger } from "@nestjs/common";
import * as cheerio from "cheerio";
import {
RawKeyEvent,
TransformedEvent,
@@ -18,7 +18,7 @@ import {
GameStatsResponse,
DbEventPayload,
DbMarketPayload,
} from './feeder.types';
} from "./feeder.types";
@Injectable()
export class FeederTransformerService {
@@ -28,7 +28,7 @@ export class FeederTransformerService {
// HELPER FUNCTIONS
// ============================================
private safeString(value: any): string | null {
return value === null || value === undefined || value === ''
return value === null || value === undefined || value === ""
? null
: String(value);
}
@@ -45,7 +45,7 @@ export class FeederTransformerService {
private extractPlayerIdFromUrl(url: string | undefined): string | null {
if (!url) return null;
const parts = url.split('/');
const parts = url.split("/");
return parts[parts.length - 1] || null;
}
@@ -59,7 +59,7 @@ export class FeederTransformerService {
matchId: string,
): TransformedEvent[] {
return rawEvents.map((e) => {
const playerId = this.extractPlayerIdFromUrl(e.playerUrl) || '';
const playerId = this.extractPlayerIdFromUrl(e.playerUrl) || "";
const assistPlayerId = e.assistPlayerUrl
? this.extractPlayerIdFromUrl(e.assistPlayerUrl)
: null;
@@ -68,16 +68,16 @@ export class FeederTransformerService {
: null;
// Determine event type
let eventType: 'goal' | 'card' | 'substitute' | 'other' = 'other';
if (e.type === 'goal') eventType = 'goal';
else if (e.type === 'card') eventType = 'card';
else if (e.type === 'substitute') eventType = 'substitute';
let eventType: "goal" | "card" | "substitute" | "other" = "other";
if (e.type === "goal") eventType = "goal";
else if (e.type === "card") eventType = "card";
else if (e.type === "substitute") eventType = "substitute";
return {
matchId,
playerId,
playerName: e.playerName,
teamId: e.position === 'home' ? homeTeamId : awayTeamId,
teamId: e.position === "home" ? homeTeamId : awayTeamId,
eventType,
eventSubtype: e.subType || null,
timeMinute: e.timeMin,
@@ -136,7 +136,7 @@ export class FeederTransformerService {
// GAME STATS TRANSFORMER
// ============================================
transformGameStats(
data: GameStatsResponse['data'] | null,
data: GameStatsResponse["data"] | null,
): TransformedMatchStats | null {
if (!data || !data.home) return null;
@@ -173,20 +173,20 @@ export class FeederTransformerService {
// MATCH STATE TO STATUS MAPPER
// ============================================
mapMatchStateToStatus(state: MatchState | undefined): string {
if (!state) return 'NS';
if (!state) return "NS";
switch (state) {
case 'postGame':
case 'post':
return 'FT';
case 'preGame':
case 'pre':
return 'NS';
case 'live':
case 'liveGame':
return 'LIVE';
case "postGame":
case "post":
return "FT";
case "preGame":
case "pre":
return "NS";
case "live":
case "liveGame":
return "LIVE";
default:
return 'NS';
return "NS";
}
}
@@ -200,28 +200,28 @@ export class FeederTransformerService {
const officials: MatchOfficial[] = [];
// Try standard officials component
$('.p0c-match-officials__official-list-item').each((_, elem) => {
$(".p0c-match-officials__official-list-item").each((_, elem) => {
const name = $(elem)
.find('.p0c-match-officials__official-name')
.find(".p0c-match-officials__official-name")
.text()
.trim();
const role = $(elem)
.find('.p0c-match-officials__official-group-title')
.find(".p0c-match-officials__official-group-title")
.text()
.trim();
if (name) {
officials.push({ name, role: role || 'Referee' });
officials.push({ name, role: role || "Referee" });
}
});
// Fallback: look for referee info in match info section
if (officials.length === 0) {
// Try alternative selectors
$('.widget-match-info__referee-name, .referee-name').each((_, elem) => {
$(".widget-match-info__referee-name, .referee-name").each((_, elem) => {
const name = $(elem).text().trim();
if (name) {
officials.push({ name, role: 'Referee' });
officials.push({ name, role: "Referee" });
}
});
}
@@ -331,8 +331,8 @@ export class FeederTransformerService {
(
e,
): e is TransformedEvent & {
eventType: 'goal' | 'card' | 'substitute';
} => e.eventType !== 'other' && !!e.playerId,
eventType: "goal" | "card" | "substitute";
} => e.eventType !== "other" && !!e.playerId,
)
.map((e) => ({
match_id: e.matchId,
@@ -354,6 +354,6 @@ export class FeederTransformerService {
// BASKETBALL PLAYER ID GENERATOR
// ============================================
generateBasketballPlayerId(teamId: string, playerName: string): string {
return `${teamId}-${playerName.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}`;
return `${teamId}-${playerName.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase()}`;
}
}
+6 -6
View File
@@ -2,12 +2,12 @@
* Feeder Module - Senior Level Implementation
*/
import { Module } from '@nestjs/common';
import { FeederService } from './feeder.service';
import { FeederScraperService } from './feeder-scraper.service';
import { FeederTransformerService } from './feeder-transformer.service';
import { FeederPersistenceService } from './feeder-persistence.service';
import { DatabaseModule } from '../../database/database.module';
import { Module } from "@nestjs/common";
import { FeederService } from "./feeder.service";
import { FeederScraperService } from "./feeder-scraper.service";
import { FeederTransformerService } from "./feeder-transformer.service";
import { FeederPersistenceService } from "./feeder-persistence.service";
import { DatabaseModule } from "../../database/database.module";
@Module({
imports: [DatabaseModule],
+100 -102
View File
@@ -3,10 +3,10 @@
* Main orchestration service for historical data scanning
*/
import { Injectable, Logger } from '@nestjs/common';
import { FeederScraperService } from './feeder-scraper.service';
import { FeederTransformerService } from './feeder-transformer.service';
import { FeederPersistenceService } from './feeder-persistence.service';
import { Injectable, Logger } from "@nestjs/common";
import { FeederScraperService } from "./feeder-scraper.service";
import { FeederTransformerService } from "./feeder-transformer.service";
import { FeederPersistenceService } from "./feeder-persistence.service";
import {
Sport,
MatchSummary,
@@ -23,7 +23,7 @@ import {
ParsedMarket,
DbEventPayload,
DbMarketPayload,
} from './feeder.types';
} from "./feeder.types";
interface ProcessDateOptions {
onlyCompletedMatches?: boolean;
@@ -37,10 +37,10 @@ export class FeederService {
// Configuration - Adjust these based on rate limiting behavior
private readonly CONCURRENCY_LIMIT = 20; // Increased for maximum speed on EC2
private readonly REQUEST_DELAY_MS = 50; // Minimal delay to respect basics
private readonly HISTORICAL_START_DATE = '2023-06-01'; // 2 years of data
private readonly SPORTS: Sport[] = ['football', 'basketball'];
private readonly HISTORICAL_START_DATE = "2023-06-01"; // 2 years of data
private readonly SPORTS: Sport[] = ["football", "basketball"];
private readonly MAX_RETRIES = 50;
private readonly DAILY_SYNC_TIME_ZONE = 'Europe/Istanbul';
private readonly DAILY_SYNC_TIME_ZONE = "Europe/Istanbul";
constructor(
private readonly scraperService: FeederScraperService,
@@ -56,38 +56,38 @@ export class FeederService {
}
private getYesterdayDateString(timeZone: string): string {
const formatter = new Intl.DateTimeFormat('en-CA', {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const parts = formatter.formatToParts(new Date());
const year = Number(parts.find((part) => part.type === 'year')?.value);
const month = Number(parts.find((part) => part.type === 'month')?.value);
const day = Number(parts.find((part) => part.type === 'day')?.value);
const year = Number(parts.find((part) => part.type === "year")?.value);
const month = Number(parts.find((part) => part.type === "month")?.value);
const day = Number(parts.find((part) => part.type === "day")?.value);
const tzMidnightUtc = new Date(Date.UTC(year, month - 1, day));
tzMidnightUtc.setUTCDate(tzMidnightUtc.getUTCDate() - 1);
return tzMidnightUtc.toISOString().split('T')[0];
return tzMidnightUtc.toISOString().split("T")[0];
}
private getTimeZoneOffsetMs(date: Date, timeZone: string): number {
const formatter = new Intl.DateTimeFormat('en-US', {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: 'shortOffset',
timeZoneName: "shortOffset",
});
const offsetLabel =
formatter.formatToParts(date).find((part) => part.type === 'timeZoneName')
?.value || 'GMT+0';
formatter.formatToParts(date).find((part) => part.type === "timeZoneName")
?.value || "GMT+0";
const match = offsetLabel.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/);
if (!match) return 0;
const sign = match[1] === '-' ? -1 : 1;
const hours = Number(match[2] || '0');
const minutes = Number(match[3] || '0');
const sign = match[1] === "-" ? -1 : 1;
const hours = Number(match[2] || "0");
const minutes = Number(match[3] || "0");
return sign * (hours * 60 + minutes) * 60 * 1000;
}
@@ -96,17 +96,14 @@ export class FeederService {
dateString: string,
timeZone: string,
): { startTs: number; endTs: number } {
const [year, month, day] = dateString.split('-').map(Number);
const [year, month, day] = dateString.split("-").map(Number);
const startGuess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
const nextDayGuess = new Date(
Date.UTC(year, month - 1, day + 1, 0, 0, 0),
);
const nextDayGuess = new Date(Date.UTC(year, month - 1, day + 1, 0, 0, 0));
const startOffsetMs = this.getTimeZoneOffsetMs(startGuess, timeZone);
const nextDayOffsetMs = this.getTimeZoneOffsetMs(nextDayGuess, timeZone);
const startMs =
Date.UTC(year, month - 1, day, 0, 0, 0) - startOffsetMs;
const startMs = Date.UTC(year, month - 1, day, 0, 0, 0) - startOffsetMs;
const nextDayStartMs =
Date.UTC(year, month - 1, day + 1, 0, 0, 0) - nextDayOffsetMs;
@@ -117,35 +114,39 @@ export class FeederService {
}
private parseScoreValue(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
if (value === null || value === undefined || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
private isCompletedMatchSummary(match: MatchSummary): boolean {
if (match.statusBoxContent === 'ERT') return false;
if (match.statusBoxContent === "ERT") return false;
const normalizedState = String(match.state || '')
const normalizedState = String(match.state || "")
.trim()
.toLowerCase();
const normalizedStatus = String(match.status || '')
const normalizedStatus = String(match.status || "")
.trim()
.toLowerCase();
const normalizedSubstate = String(match.substate || '')
const normalizedSubstate = String(match.substate || "")
.trim()
.toLowerCase();
if (['postgame', 'post'].includes(normalizedState)) return true;
if (["postgame", "post"].includes(normalizedState)) return true;
if (
['played', 'finished', 'ft', 'afterpenalties', 'penalties'].includes(
["played", "finished", "ft", "afterpenalties", "penalties"].includes(
normalizedStatus,
)
) {
return true;
}
if (['postgame', 'post', 'played', 'finished', 'ft'].includes(normalizedSubstate)) {
if (
["postgame", "post", "played", "finished", "ft"].includes(
normalizedSubstate,
)
) {
return true;
}
@@ -167,7 +168,7 @@ export class FeederService {
targetLeagueIds: string[] = [],
): Promise<void> {
this.logger.log(
`🗓️ STARTING DAILY COMPLETED MATCH SYNC [Date: ${targetDateStr}] [Sports: ${sports.join(', ')}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ''}`,
`🗓️ STARTING DAILY COMPLETED MATCH SYNC [Date: ${targetDateStr}] [Sports: ${sports.join(", ")}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ""}`,
);
for (const sport of sports) {
@@ -191,7 +192,7 @@ export class FeederService {
targetLeagueIds: string[] = [], // NEW: Optional league filter
): Promise<void> {
this.logger.log(
`🚀 STARTING HISTORICAL SCAN [Target: ${sports.join(', ')}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ''}`,
`🚀 STARTING HISTORICAL SCAN [Target: ${sports.join(", ")}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ""}`,
);
const startDate = new Date(startDateStr);
@@ -201,7 +202,7 @@ export class FeederService {
// writing to live_matches. Historical scan should only fill matches table.
endDate.setDate(endDate.getDate() - 2);
const stateKey = `historical_scan_state_${sports.join('_')}${targetLeagueIds.length > 0 ? '_filtered' : ''}_desc`;
const stateKey = `historical_scan_state_${sports.join("_")}${targetLeagueIds.length > 0 ? "_filtered" : ""}_desc`;
let currentDate: Date | null = null;
// Resume from saved state
@@ -215,12 +216,12 @@ export class FeederService {
// For reverse scan, we resume from the *next* day backwards, i.e., resumeDate - 1 day
currentDate.setDate(currentDate.getDate() - 1);
this.logger.log(
`📍 Resuming from: ${currentDate.toISOString().split('T')[0]}`,
`📍 Resuming from: ${currentDate.toISOString().split("T")[0]}`,
);
}
}
} catch {
this.logger.warn('Could not read state, starting from beginning');
this.logger.warn("Could not read state, starting from beginning");
}
// Initialize currentDate to endDate if not resuming (or if resume failed)
@@ -231,7 +232,7 @@ export class FeederService {
}
this.logger.log(
`📊 Scanning (Reverse): ${currentDate.toISOString().split('T')[0]}${startDate.toISOString().split('T')[0]}`,
`📊 Scanning (Reverse): ${currentDate.toISOString().split("T")[0]}${startDate.toISOString().split("T")[0]}`,
);
let processedDays = 0;
@@ -239,7 +240,7 @@ export class FeederService {
// REVERSE LOOP: Iterate while currentDate is greater than or equal to startDate
while (currentDate >= startDate) {
const dateString = currentDate.toISOString().split('T')[0];
const dateString = currentDate.toISOString().split("T")[0];
for (const sport of sports) {
await this.processDate(dateString, sport, targetLeagueIds);
@@ -278,7 +279,7 @@ export class FeederService {
currentDate.setDate(currentDate.getDate() - 1);
}
this.logger.log('🎉 HISTORICAL SCAN COMPLETED');
this.logger.log("🎉 HISTORICAL SCAN COMPLETED");
}
// ============================================
@@ -308,9 +309,9 @@ export class FeederService {
break; // Success, exit loop
} catch (e: any) {
const is502 =
e.message?.includes('502') ||
e.message?.includes("502") ||
e.response?.status === 502 ||
e.message?.includes('Bad Gateway');
e.message?.includes("Bad Gateway");
if (is502 && i < 2) {
this.logger.warn(
@@ -341,10 +342,7 @@ export class FeederService {
// regardless of the matchDate query parameter. We must filter by mstUtc
// to ensure we only process matches that actually belong to the target date.
const { startTs: targetDateStartTs, endTs: targetDateEndTs } =
this.getDayBoundsForTimeZone(
dateString,
this.DAILY_SYNC_TIME_ZONE,
);
this.getDayBoundsForTimeZone(dateString, this.DAILY_SYNC_TIME_ZONE);
const dateFilteredMatches = allMatches.filter((m) => {
const matchTs = m.mstUtc;
@@ -518,14 +516,14 @@ export class FeederService {
// ============================================
async refreshMatch(
matchId: string,
scope: 'all' | 'lineups' | 'odds' = 'all',
scope: "all" | "lineups" | "odds" = "all",
): Promise<ProcessResult> {
this.logger.log(`🔄 Refreshing match (${scope}) for ${matchId}`);
const matchRecord = await this.persistenceService.getMatch(matchId);
if (!matchRecord) {
this.logger.warn(`[${matchId}] Refresh failed: Match not in DB`);
return { success: false, retryable: false, error: 'Match not found' };
return { success: false, retryable: false, error: "Match not found" };
}
// Construct MatchSummary from DB record
@@ -538,13 +536,13 @@ export class FeederService {
iddaaCode: matchRecord.iddaaCode,
homeTeam: {
id: matchRecord.homeTeamId,
name: matchRecord.homeTeam?.name || '',
slug: matchRecord.homeTeam?.slug || '',
name: matchRecord.homeTeam?.name || "",
slug: matchRecord.homeTeam?.slug || "",
},
awayTeam: {
id: matchRecord.awayTeamId,
name: matchRecord.awayTeam?.name || '',
slug: matchRecord.awayTeam?.slug || '',
name: matchRecord.awayTeam?.name || "",
slug: matchRecord.awayTeam?.slug || "",
},
score: {
home: matchRecord.scoreHome,
@@ -555,9 +553,9 @@ export class FeederService {
const dummyCompetitions: Record<string, Competition> = {
[summary.competitionId]: {
id: summary.competitionId,
name: 'Unknown',
competitionSlug: '',
country: { id: '', name: '' },
name: "Unknown",
competitionSlug: "",
country: { id: "", name: "" },
},
};
@@ -583,7 +581,7 @@ export class FeederService {
competitions: Record<string, Competition>,
sport: Sport,
force: boolean = false,
scope: 'all' | 'lineups' | 'odds' = 'all', // Add scope flag
scope: "all" | "lineups" | "odds" = "all", // Add scope flag
): Promise<ProcessResult> {
const matchId = matchSummary.id;
const homeTeamId = matchSummary.homeTeam?.id;
@@ -595,7 +593,7 @@ export class FeederService {
}
// Skip postponed matches (ERT = Erteledendi)
if (matchSummary.statusBoxContent === 'ERT') {
if (matchSummary.statusBoxContent === "ERT") {
this.logger.debug(`[${matchId}] Skipped: Postponed match (ERT)`);
return { success: false, retryable: false };
}
@@ -615,9 +613,9 @@ export class FeederService {
return await fn();
} catch (e: any) {
const is502 =
e.message?.includes('502') ||
e.message?.includes("502") ||
e.response?.status === 502 ||
e.message?.includes('Bad Gateway');
e.message?.includes("Bad Gateway");
if (i === retries - 1) throw e; // Last attempt failed
@@ -661,44 +659,44 @@ export class FeederService {
// 1. Fetch Match Header (score, status)
let headerData: ParsedMatchHeader | null = null;
if (scope === 'all') {
if (scope === "all") {
try {
headerData = await fetchResilient('Header', () =>
headerData = await fetchResilient("Header", () =>
this.scraperService.fetchMatchHeader(matchId),
);
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(`[${matchId}] Header fetch failed: ${e.message}`);
}
}
// 2. Sport-specific data fetching
if (sport === 'basketball') {
if (sport === "basketball") {
// Basketball: Box Score (Always if all or lineups)
if (scope === 'all' || scope === 'lineups') {
if (scope === "all" || scope === "lineups") {
try {
const boxData = await fetchResilient('BoxScore', () =>
const boxData = await fetchResilient("BoxScore", () =>
this.scraperService.fetchBasketballBoxScore(matchId),
);
if (boxData) {
const homeParsed = this.scraperService.parseBasketballBoxScore(
boxData.views?.home?.html || '',
boxData.views?.home?.html || "",
);
const awayParsed = this.scraperService.parseBasketballBoxScore(
boxData.views?.away?.html || '',
boxData.views?.away?.html || "",
);
basketballTeamStats =
scope === 'all'
scope === "all"
? {
home: homeParsed.teamTotals,
away: awayParsed.teamTotals,
}
: null;
if (scope === 'all') {
if (scope === "all") {
try {
const details = await fetchResilient('QuarterScores', () =>
const details = await fetchResilient("QuarterScores", () =>
this.scraperService.fetchBasketballDetailsHeader(matchId),
);
if (details && basketballTeamStats) {
@@ -712,7 +710,7 @@ export class FeederService {
};
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(
`[${matchId}] Quarter scores fetch failed: ${e.message}`,
);
@@ -748,7 +746,7 @@ export class FeederService {
processPlayers(awayParsed, awayTeamId);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(`[${matchId}] Box score failed: ${e.message}`);
}
}
@@ -756,9 +754,9 @@ export class FeederService {
// Football: Events, Lineups, Stats, Officials
// Key Events
if (scope === 'all') {
if (scope === "all") {
try {
const eventsData = await fetchResilient('Events', () =>
const eventsData = await fetchResilient("Events", () =>
this.scraperService.fetchKeyEvents(matchId),
);
if (eventsData?.keyEvents) {
@@ -781,7 +779,7 @@ export class FeederService {
);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(`[${matchId}] Events failed: ${e.message}`);
}
@@ -850,20 +848,20 @@ export class FeederService {
*/
// Game Stats & Officials
if (scope === 'all') {
if (scope === "all") {
try {
const gameStats = await fetchResilient('Stats', () =>
const gameStats = await fetchResilient("Stats", () =>
this.scraperService.fetchGameStats(matchId),
);
stats = this.transformerService.transformGameStats(gameStats);
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(`[${matchId}] Stats failed: ${e.message}`);
}
// Officials (from match page)
try {
const matchPageHtml = await fetchResilient('Officials', () =>
const matchPageHtml = await fetchResilient("Officials", () =>
this.scraperService.fetchMatchPage(
matchId,
matchSummary.matchSlug,
@@ -875,7 +873,7 @@ export class FeederService {
this.transformerService.parseOfficials(matchPageHtml);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(`[${matchId}] Officials failed: ${e.message}`);
}
}
@@ -883,31 +881,31 @@ export class FeederService {
// 3. Fetch Iddaa Odds (Always if all or odds)
let oddsArray: DbMarketPayload[] = [];
if (scope === 'all' || scope === 'odds') {
if (scope === "all" || scope === "odds") {
try {
let markets: ParsedMarket[] = [];
if (sport === 'basketball') {
if (sport === "basketball") {
markets =
((await fetchResilient('BucketOdds', () =>
((await fetchResilient("BucketOdds", () =>
this.scraperService.fetchBasketballMarkets(matchId),
)) as ParsedMarket[]) || [];
} else {
markets =
((await fetchResilient('IddaaOdds', () =>
((await fetchResilient("IddaaOdds", () =>
this.scraperService.fetchIddaaMarkets(matchId),
)) as ParsedMarket[]) || [];
}
// Logic is same since structure is ParsedMarket[]
oddsArray = this.transformerService.transformIddaaMarkets(markets);
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
if (e.message?.includes("502")) hasCriticalError = true;
this.logger.warn(`[${matchId}] Odds failed: ${e.message}`);
}
}
// 4. Persist to Database
let saved = false;
if (scope === 'lineups') {
if (scope === "lineups") {
saved = await this.persistenceService.saveLineups(
matchId,
playersMap,
@@ -915,7 +913,7 @@ export class FeederService {
homeTeamId,
awayTeamId,
);
} else if (scope === 'odds') {
} else if (scope === "odds") {
saved = await this.persistenceService.saveOdds(matchId, oddsArray);
} else {
// Full Update
@@ -962,12 +960,12 @@ export class FeederService {
if (saved && hasCriticalError) {
// Collect missing components
const missingParts: string[] = [];
if (!stats) missingParts.push('Stats');
if (oddsArray.length === 0) missingParts.push('Odds');
if (officialsData.length === 0) missingParts.push('Officials');
if (!stats) missingParts.push("Stats");
if (oddsArray.length === 0) missingParts.push("Odds");
if (officialsData.length === 0) missingParts.push("Officials");
this.logger.warn(
`[${matchId}] Saved with MISSING DATA (502). Missing: [${missingParts.join(', ')}]. Scheduled for retry.`,
`[${matchId}] Saved with MISSING DATA (502). Missing: [${missingParts.join(", ")}]. Scheduled for retry.`,
);
return { success: false, retryable: true };
}
@@ -975,12 +973,12 @@ export class FeederService {
return { success: saved, retryable: !saved };
} catch (error: any) {
const isRetryable =
error.message.includes('502') ||
error.message.includes('504') ||
error.message.includes('ECONNABORTED') ||
error.message.includes('timeout') ||
error.message.includes('ETIMEDOUT') ||
error.message.includes('Unique constraint'); // Concurrency retry
error.message.includes("502") ||
error.message.includes("504") ||
error.message.includes("ECONNABORTED") ||
error.message.includes("timeout") ||
error.message.includes("ETIMEDOUT") ||
error.message.includes("Unique constraint"); // Concurrency retry
if (isRetryable) {
this.logger.warn(`[${matchId}] ${error.message} - Will retry`);
+88 -88
View File
@@ -6,27 +6,27 @@
// ============================================
// SPORT TYPES
// ============================================
export type Sport = 'football' | 'basketball';
export type Sport = "football" | "basketball";
export const SPORTS_CONFIG: Record<
Sport,
{ sportParam: string; iddaaUrlPath: string }
> = {
football: { sportParam: 'Soccer', iddaaUrlPath: 'mac' },
basketball: { sportParam: 'Basketball', iddaaUrlPath: 'basketbol/mac' },
football: { sportParam: "Soccer", iddaaUrlPath: "mac" },
basketball: { sportParam: "Basketball", iddaaUrlPath: "basketbol/mac" },
};
// ============================================
// MATCH STATUS TYPES
// ============================================
export type MatchStatus = 'Cancelled' | 'Played' | 'Playing' | 'Scheduled';
export type MatchStatus = "Cancelled" | "Played" | "Playing" | "Scheduled";
export type MatchState =
| 'preGame'
| 'postGame'
| 'live'
| 'liveGame'
| 'pre'
| 'post';
| "preGame"
| "postGame"
| "live"
| "liveGame"
| "pre"
| "post";
// ============================================
// LIVESCORES API RESPONSE
@@ -115,9 +115,9 @@ export interface KeyEventsResponse {
}
export interface RawKeyEvent {
type: 'goal' | 'card' | 'substitute' | 'penalty-missed';
subType: 'goal' | 'penalty-goal' | 'yc' | 'rc' | 'pm' | 'ps' | null;
position: 'home' | 'away';
type: "goal" | "card" | "substitute" | "penalty-missed";
subType: "goal" | "penalty-goal" | "yc" | "rc" | "pm" | "ps" | null;
position: "home" | "away";
periodId: number; // 1 = 1st half, 2 = 2nd half
timeMin: string;
seconds: number | null;
@@ -135,7 +135,7 @@ export interface TransformedEvent {
playerId: string;
playerName: string;
teamId: string;
eventType: 'goal' | 'card' | 'substitute' | 'other';
eventType: "goal" | "card" | "substitute" | "other";
eventSubtype: string | null;
timeMinute: string;
timeSeconds: number | null;
@@ -145,7 +145,7 @@ export interface TransformedEvent {
scoreAfter: string | null;
playerOutId: string | null;
playerOutName: string | null;
position: 'home' | 'away';
position: "home" | "away";
}
// ============================================
@@ -170,18 +170,18 @@ export interface RawPlayerStats {
personId: string;
matchName: string;
shirtNumber: number | null;
position: 'goalkeeper' | 'defender' | 'midfielder' | 'striker' | 'Coach' | '';
position: "goalkeeper" | "defender" | "midfielder" | "striker" | "Coach" | "";
events: PlayerEvent[] | null;
}
export interface PlayerEvent {
name:
| 'goal'
| 'yellow-card'
| 'red-card'
| 'sub-off'
| 'sub-on'
| 'penalty-missed';
| "goal"
| "yellow-card"
| "red-card"
| "sub-off"
| "sub-on"
| "penalty-missed";
timeMin: string;
count: number;
}
@@ -270,7 +270,7 @@ export interface IddaaMarket {
export interface IddaaOutcome {
outcome: string; // The odds value (e.g., "1.78")
handicap: string | null;
state: 'active' | 'suspended';
state: "active" | "suspended";
label: string; // "1", "X", "2", "Alt", "Üst", etc.
}
@@ -371,7 +371,7 @@ export interface DbEventPayload {
match_id: string;
player_id: string;
team_id: string;
event_type: 'goal' | 'card' | 'substitute';
event_type: "goal" | "card" | "substitute";
event_subtype: string | null;
time_minute: string;
time_seconds: number | null;
@@ -379,7 +379,7 @@ export interface DbEventPayload {
assist_player_id: string | null;
score_after: string | null;
player_out_id: string | null;
position: 'home' | 'away';
position: "home" | "away";
}
export interface DbMarketSelectionPayload {
@@ -402,74 +402,74 @@ export interface DbMarketPayload {
// ============================================
export const MARKET_MAPPING: Record<string, string> = {
// Ana Bahisler
'1': 'Maç Sonucu',
'3': 'Çifte Şans',
'6-11': 'Handikaplı MS (0:1)',
'6-22': 'Handikaplı MS (0:2)',
'611': 'Handikaplı MS (1:0)',
'622': 'Handikaplı MS (2:0)',
'14': 'İlk Yarı / Maç Sonucu',
'15': 'Maç Skoru',
"1": "Maç Sonucu",
"3": "Çifte Şans",
"6-11": "Handikaplı MS (0:1)",
"6-22": "Handikaplı MS (0:2)",
"611": "Handikaplı MS (1:0)",
"622": "Handikaplı MS (2:0)",
"14": "İlk Yarı / Maç Sonucu",
"15": "Maç Skoru",
// Gol Alt/Üst
'180.5': '0.5 Alt/Üst',
'181.5': '1.5 Alt/Üst',
'182.5': '2.5 Alt/Üst',
'183.5': '3.5 Alt/Üst',
'184.5': '4.5 Alt/Üst',
'185.5': '5.5 Alt/Üst',
"180.5": "0.5 Alt/Üst",
"181.5": "1.5 Alt/Üst",
"182.5": "2.5 Alt/Üst",
"183.5": "3.5 Alt/Üst",
"184.5": "4.5 Alt/Üst",
"185.5": "5.5 Alt/Üst",
// Diğer Gol Bahisleri
'11': 'Karşılıklı Gol',
'12': 'Tek / Çift',
'24': 'İlk Golü Kim Atar',
'26': 'Toplam Gol Aralığı',
'32': 'En Çok Gol Olacak Yarı',
"11": "Karşılıklı Gol",
"12": "Tek / Çift",
"24": "İlk Golü Kim Atar",
"26": "Toplam Gol Aralığı",
"32": "En Çok Gol Olacak Yarı",
// Yarı Bahisleri
'4': '1. Yarı Sonucu',
'5': '1. Yarı Çifte Şans',
'54': '2. Yarı Sonucu',
'190.5': '1. Yarı 0.5 Alt/Üst',
'191.5': '1. Yarı 1.5 Alt/Üst',
'192.5': '1. Yarı 2.5 Alt/Üst',
'39': '1. Yarı Karşılıklı Gol',
"4": "1. Yarı Sonucu",
"5": "1. Yarı Çifte Şans",
"54": "2. Yarı Sonucu",
"190.5": "1. Yarı 0.5 Alt/Üst",
"191.5": "1. Yarı 1.5 Alt/Üst",
"192.5": "1. Yarı 2.5 Alt/Üst",
"39": "1. Yarı Karşılıklı Gol",
// Takım Bahisleri
'280.5': 'Ev Sahibi 0.5 Alt/Üst',
'281.5': 'Ev Sahibi 1.5 Alt/Üst',
'282.5': 'Ev Sahibi 2.5 Alt/Üst',
'283.5': 'Ev Sahibi 3.5 Alt/Üst',
'290.5': 'Deplasman 0.5 Alt/Üst',
'291.5': 'Deplasman 1.5 Alt/Üst',
'292.5': 'Deplasman 2.5 Alt/Üst',
'400.5': '1. Yarı Ev Sahibi 0.5 Alt/Üst',
'430.5': '1. Yarı Deplasman 0.5 Alt/Üst',
'37': 'Ev Sahibi Gol Yemeden Kazanır',
'38': 'Deplasman Gol Yemeden Kazanır',
"280.5": "Ev Sahibi 0.5 Alt/Üst",
"281.5": "Ev Sahibi 1.5 Alt/Üst",
"282.5": "Ev Sahibi 2.5 Alt/Üst",
"283.5": "Ev Sahibi 3.5 Alt/Üst",
"290.5": "Deplasman 0.5 Alt/Üst",
"291.5": "Deplasman 1.5 Alt/Üst",
"292.5": "Deplasman 2.5 Alt/Üst",
"400.5": "1. Yarı Ev Sahibi 0.5 Alt/Üst",
"430.5": "1. Yarı Deplasman 0.5 Alt/Üst",
"37": "Ev Sahibi Gol Yemeden Kazanır",
"38": "Deplasman Gol Yemeden Kazanır",
// Korner & Kart
'47': 'En Çok Korner',
'48': '1. Yarı En Çok Korner',
'49': 'İlk Korner',
'43': 'Toplam Korner Aralığı',
'44': '1. Yarı Korner Aralığı',
'463.5': '1. Yarı 3.5 Korner Alt/Üst',
'464.5': '1. Yarı 4.5 Korner Alt/Üst',
'465.5': '1. Yarı 5.5 Korner Alt/Üst',
'53': 'Kırmızı Kart Olur mu?',
'384.5': '4.5 Kart Puanı Alt/Üst',
'385.5': '5.5 Kart Puanı Alt/Üst',
'386.5': '6.5 Kart Puanı Alt/Üst',
"47": "En Çok Korner",
"48": "1. Yarı En Çok Korner",
"49": "İlk Korner",
"43": "Toplam Korner Aralığı",
"44": "1. Yarı Korner Aralığı",
"463.5": "1. Yarı 3.5 Korner Alt/Üst",
"464.5": "1. Yarı 4.5 Korner Alt/Üst",
"465.5": "1. Yarı 5.5 Korner Alt/Üst",
"53": "Kırmızı Kart Olur mu?",
"384.5": "4.5 Kart Puanı Alt/Üst",
"385.5": "5.5 Kart Puanı Alt/Üst",
"386.5": "6.5 Kart Puanı Alt/Üst",
// Kombine
'301.5': 'MS ve 1.5 Alt/Üst',
'302.5': 'MS ve 2.5 Alt/Üst',
'303.5': 'MS ve 3.5 Alt/Üst',
'304.5': 'MS ve 4.5 Alt/Üst',
"301.5": "MS ve 1.5 Alt/Üst",
"302.5": "MS ve 2.5 Alt/Üst",
"303.5": "MS ve 3.5 Alt/Üst",
"304.5": "MS ve 4.5 Alt/Üst",
// İki Yarıyı da Kazanır (39 conflicts with 1. Yarı Karşılıklı Gol, keep that one)
'40': 'Deplasman İki Yarıyı da Kazanır',
"40": "Deplasman İki Yarıyı da Kazanır",
};
// ============================================
@@ -477,20 +477,20 @@ export const MARKET_MAPPING: Record<string, string> = {
// ============================================
export interface AxiosRequestConfig {
headers: {
'User-Agent': string;
"User-Agent": string;
Referer: string;
'X-Requested-With': string;
'Accept-Language'?: string;
"X-Requested-With": string;
"Accept-Language"?: string;
};
timeout: number;
}
export const DEFAULT_HEADERS = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Referer: 'https://www.mackolik.com/',
'X-Requested-With': 'XMLHttpRequest',
'Accept-Language': 'tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7',
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Referer: "https://www.mackolik.com/",
"X-Requested-With": "XMLHttpRequest",
"Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
};
export const DEFAULT_TIMEOUT = 30000;
@@ -516,7 +516,7 @@ export interface SidelinedPlayer {
playerUrl: string;
position: string;
positionShort: string;
type: 'injury' | 'suspension' | 'other';
type: "injury" | "suspension" | "other";
description: string;
matchesMissed: number | null;
average: number | null;
+4 -4
View File
@@ -1,7 +1,7 @@
import { registerAs } from '@nestjs/config';
import { registerAs } from "@nestjs/config";
export const geminiConfig = registerAs('gemini', () => ({
enabled: process.env.ENABLE_GEMINI === 'true',
export const geminiConfig = registerAs("gemini", () => ({
enabled: process.env.ENABLE_GEMINI === "true",
apiKey: process.env.GOOGLE_API_KEY,
defaultModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
defaultModel: process.env.GEMINI_MODEL || "gemini-2.5-flash",
}));
+4 -4
View File
@@ -1,7 +1,7 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { GeminiService } from './gemini.service';
import { geminiConfig } from './gemini.config';
import { Module, Global } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { GeminiService } from "./gemini.service";
import { geminiConfig } from "./gemini.config";
/**
* Gemini AI Module
+27 -27
View File
@@ -1,6 +1,6 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenAI } from '@google/genai';
import { Injectable, OnModuleInit, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { GoogleGenAI } from "@google/genai";
export interface GeminiGenerateOptions {
model?: string;
@@ -10,7 +10,7 @@ export interface GeminiGenerateOptions {
}
export interface GeminiChatMessage {
role: 'user' | 'model';
role: "user" | "model";
content: string;
}
@@ -48,34 +48,34 @@ export class GeminiService implements OnModuleInit {
private defaultModel: string;
constructor(private readonly configService: ConfigService) {
this.isEnabled = this.configService.get<boolean>('gemini.enabled', false);
this.isEnabled = this.configService.get<boolean>("gemini.enabled", false);
this.defaultModel = this.configService.get<string>(
'gemini.defaultModel',
'gemini-2.5-flash',
"gemini.defaultModel",
"gemini-2.5-flash",
);
}
onModuleInit() {
if (!this.isEnabled) {
this.logger.log(
'Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.',
"Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.",
);
return;
}
const apiKey = this.configService.get<string>('gemini.apiKey');
const apiKey = this.configService.get<string>("gemini.apiKey");
if (!apiKey) {
this.logger.warn(
'GOOGLE_API_KEY is not set. Gemini features will not work.',
"GOOGLE_API_KEY is not set. Gemini features will not work.",
);
return;
}
try {
this.client = new GoogleGenAI({ apiKey });
this.logger.log('✅ Gemini AI initialized successfully');
this.logger.log("✅ Gemini AI initialized successfully");
} catch (error) {
this.logger.error('Failed to initialize Gemini AI', error);
this.logger.error("Failed to initialize Gemini AI", error);
}
}
@@ -98,7 +98,7 @@ export class GeminiService implements OnModuleInit {
options: GeminiGenerateOptions = {},
): Promise<{ text: string; usage?: any }> {
if (!this.isAvailable()) {
throw new Error('Gemini AI is not available. Check your configuration.');
throw new Error("Gemini AI is not available. Check your configuration.");
}
const model = options.model || this.defaultModel;
@@ -109,17 +109,17 @@ export class GeminiService implements OnModuleInit {
// Add system prompt if provided
if (options.systemPrompt) {
contents.push({
role: 'user',
role: "user",
parts: [{ text: options.systemPrompt }],
});
contents.push({
role: 'model',
parts: [{ text: 'Understood. I will follow these instructions.' }],
role: "model",
parts: [{ text: "Understood. I will follow these instructions." }],
});
}
contents.push({
role: 'user',
role: "user",
parts: [{ text: prompt }],
});
@@ -133,11 +133,11 @@ export class GeminiService implements OnModuleInit {
});
return {
text: (response.text || '').trim(),
text: (response.text || "").trim(),
usage: response.usageMetadata,
};
} catch (error) {
this.logger.error('Gemini generation failed', error);
this.logger.error("Gemini generation failed", error);
throw error;
}
}
@@ -154,7 +154,7 @@ export class GeminiService implements OnModuleInit {
options: GeminiGenerateOptions = {},
): Promise<{ text: string; usage?: any }> {
if (!this.isAvailable()) {
throw new Error('Gemini AI is not available. Check your configuration.');
throw new Error("Gemini AI is not available. Check your configuration.");
}
const model = options.model || this.defaultModel;
@@ -169,12 +169,12 @@ export class GeminiService implements OnModuleInit {
if (options.systemPrompt) {
contents.unshift(
{
role: 'user',
role: "user",
parts: [{ text: options.systemPrompt }],
},
{
role: 'model',
parts: [{ text: 'Understood. I will follow these instructions.' }],
role: "model",
parts: [{ text: "Understood. I will follow these instructions." }],
},
);
}
@@ -189,11 +189,11 @@ export class GeminiService implements OnModuleInit {
});
return {
text: (response.text || '').trim(),
text: (response.text || "").trim(),
usage: response.usageMetadata,
};
} catch (error) {
this.logger.error('Gemini chat failed', error);
this.logger.error("Gemini chat failed", error);
throw error;
}
}
@@ -233,8 +233,8 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
const data = JSON.parse(jsonStr) as T;
return { data, usage: response.usage };
} catch (error) {
this.logger.error('Failed to parse JSON response', error);
throw new Error('Failed to parse AI response as JSON');
this.logger.error("Failed to parse JSON response", error);
throw new Error("Failed to parse AI response as JSON");
}
}
}
+3 -3
View File
@@ -1,3 +1,3 @@
export * from './gemini.module';
export * from './gemini.service';
export * from './gemini.config';
export * from "./gemini.module";
export * from "./gemini.service";
export * from "./gemini.config";
+14 -14
View File
@@ -1,15 +1,15 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Controller, Get } from "@nestjs/common";
import { ApiTags, ApiOperation } from "@nestjs/swagger";
import {
HealthCheck,
HealthCheckService,
PrismaHealthIndicator,
} from '@nestjs/terminus';
import { Public } from '../../common/decorators';
import { PrismaService } from '../../database/prisma.service';
} from "@nestjs/terminus";
import { Public } from "../../common/decorators";
import { PrismaService } from "../../database/prisma.service";
@ApiTags('Health')
@Controller('health')
@ApiTags("Health")
@Controller("health")
export class HealthController {
constructor(
private health: HealthCheckService,
@@ -20,25 +20,25 @@ export class HealthController {
@Get()
@Public()
@HealthCheck()
@ApiOperation({ summary: 'Basic health check' })
@ApiOperation({ summary: "Basic health check" })
check() {
return this.health.check([]);
}
@Get('ready')
@Get("ready")
@Public()
@HealthCheck()
@ApiOperation({ summary: 'Readiness check (includes database)' })
@ApiOperation({ summary: "Readiness check (includes database)" })
readiness() {
return this.health.check([
() => this.prismaHealth.pingCheck('database', this.prisma),
() => this.prismaHealth.pingCheck("database", this.prisma),
]);
}
@Get('live')
@Get("live")
@Public()
@ApiOperation({ summary: 'Liveness check' })
@ApiOperation({ summary: "Liveness check" })
liveness() {
return { status: 'ok', timestamp: new Date().toISOString() };
return { status: "ok", timestamp: new Date().toISOString() };
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { PrismaHealthIndicator } from '@nestjs/terminus';
import { HealthController } from './health.controller';
import { Module } from "@nestjs/common";
import { TerminusModule } from "@nestjs/terminus";
import { PrismaHealthIndicator } from "@nestjs/terminus";
import { HealthController } from "./health.controller";
@Module({
imports: [TerminusModule],
+50 -50
View File
@@ -4,20 +4,20 @@ import {
Param,
Query,
NotFoundException,
} from '@nestjs/common';
} from "@nestjs/common";
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiQuery,
ApiParam,
} from '@nestjs/swagger';
import { LeaguesService } from './leagues.service';
import { Sport } from '@prisma/client';
import { Public } from '../../common/decorators';
} from "@nestjs/swagger";
import { LeaguesService } from "./leagues.service";
import { Sport } from "@prisma/client";
import { Public } from "../../common/decorators";
@ApiTags('Leagues')
@Controller('leagues')
@ApiTags("Leagues")
@Controller("leagues")
export class LeaguesController {
constructor(private readonly leaguesService: LeaguesService) {}
@@ -25,10 +25,10 @@ export class LeaguesController {
* GET /leagues/countries
* Get all countries
*/
@Get('countries')
@Get("countries")
@Public()
@ApiOperation({ summary: 'Get all countries' })
@ApiResponse({ status: 200, description: 'List of countries' })
@ApiOperation({ summary: "Get all countries" })
@ApiResponse({ status: 200, description: "List of countries" })
async getCountries() {
return this.leaguesService.findAllCountries();
}
@@ -37,13 +37,13 @@ export class LeaguesController {
* GET /leagues/countries/:id
* Get country by ID with leagues
*/
@Get('countries/:id')
@Get("countries/:id")
@Public()
@ApiOperation({ summary: 'Get country by ID with leagues' })
@ApiParam({ name: 'id', description: 'Country ID' })
async getCountryById(@Param('id') id: string) {
@ApiOperation({ summary: "Get country by ID with leagues" })
@ApiParam({ name: "id", description: "Country ID" })
async getCountryById(@Param("id") id: string) {
const country = await this.leaguesService.findCountryById(id);
if (!country) throw new NotFoundException('Country not found');
if (!country) throw new NotFoundException("Country not found");
return country;
}
@@ -53,13 +53,13 @@ export class LeaguesController {
*/
@Get()
@Public()
@ApiOperation({ summary: 'Get all leagues' })
@ApiOperation({ summary: "Get all leagues" })
@ApiQuery({
name: 'sport',
name: "sport",
required: false,
enum: ['football', 'basketball'],
enum: ["football", "basketball"],
})
async getLeagues(@Query('sport') sport?: string) {
async getLeagues(@Query("sport") sport?: string) {
return this.leaguesService.findAllLeagues(sport as Sport);
}
@@ -68,21 +68,21 @@ export class LeaguesController {
* Get head-to-head matches between two teams
* NOTE: Must come before /teams/:id to avoid route conflict
*/
@Get('teams/h2h')
@Get("teams/h2h")
@Public()
@ApiOperation({ summary: 'Get head-to-head matches between two teams' })
@ApiQuery({ name: 'team1', required: true })
@ApiQuery({ name: 'team2', required: true })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiOperation({ summary: "Get head-to-head matches between two teams" })
@ApiQuery({ name: "team1", required: true })
@ApiQuery({ name: "team2", required: true })
@ApiQuery({ name: "limit", required: false, type: Number })
async getHeadToHead(
@Query('team1') team1: string,
@Query('team2') team2: string,
@Query('limit') limit?: string,
@Query("team1") team1: string,
@Query("team2") team2: string,
@Query("limit") limit?: string,
) {
return this.leaguesService.getHeadToHead(
team1,
team2,
parseInt(limit || '10', 10),
parseInt(limit || "10", 10),
);
}
@@ -90,16 +90,16 @@ export class LeaguesController {
* GET /leagues/teams/search
* Search teams by name
*/
@Get('teams/search')
@Get("teams/search")
@Public()
@ApiOperation({ summary: 'Search teams by name' })
@ApiQuery({ name: 'q', required: true, description: 'Search query' })
@ApiOperation({ summary: "Search teams by name" })
@ApiQuery({ name: "q", required: true, description: "Search query" })
@ApiQuery({
name: 'sport',
name: "sport",
required: false,
enum: ['football', 'basketball'],
enum: ["football", "basketball"],
})
async searchTeams(@Query('q') query: string, @Query('sport') sport?: string) {
async searchTeams(@Query("q") query: string, @Query("sport") sport?: string) {
return this.leaguesService.searchTeams(query, sport as Sport);
}
@@ -107,13 +107,13 @@ export class LeaguesController {
* GET /leagues/teams/:id
* Get team by ID
*/
@Get('teams/:id')
@Get("teams/:id")
@Public()
@ApiOperation({ summary: 'Get team by ID' })
@ApiParam({ name: 'id', description: 'Team ID' })
async getTeamById(@Param('id') id: string) {
@ApiOperation({ summary: "Get team by ID" })
@ApiParam({ name: "id", description: "Team ID" })
async getTeamById(@Param("id") id: string) {
const team = await this.leaguesService.findTeamById(id);
if (!team) throw new NotFoundException('Team not found');
if (!team) throw new NotFoundException("Team not found");
return team;
}
@@ -121,18 +121,18 @@ export class LeaguesController {
* GET /leagues/teams/:id/matches
* Get team's recent matches
*/
@Get('teams/:id/matches')
@Get("teams/:id/matches")
@Public()
@ApiOperation({ summary: "Get team's recent matches" })
@ApiParam({ name: 'id', description: 'Team ID' })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiParam({ name: "id", description: "Team ID" })
@ApiQuery({ name: "limit", required: false, type: Number })
async getTeamMatches(
@Param('id') id: string,
@Query('limit') limit?: string,
@Param("id") id: string,
@Query("limit") limit?: string,
) {
return this.leaguesService.getTeamRecentMatches(
id,
parseInt(limit || '10', 10),
parseInt(limit || "10", 10),
);
}
@@ -140,13 +140,13 @@ export class LeaguesController {
* GET /leagues/:id
* Get league by ID
*/
@Get(':id')
@Get(":id")
@Public()
@ApiOperation({ summary: 'Get league by ID' })
@ApiParam({ name: 'id', description: 'League ID' })
async getLeagueById(@Param('id') id: string) {
@ApiOperation({ summary: "Get league by ID" })
@ApiParam({ name: "id", description: "League ID" })
async getLeagueById(@Param("id") id: string) {
const league = await this.leaguesService.findLeagueById(id);
if (!league) throw new NotFoundException('League not found');
if (!league) throw new NotFoundException("League not found");
return league;
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { LeaguesController } from './leagues.controller';
import { LeaguesService } from './leagues.service';
import { DatabaseModule } from '../../database/database.module';
import { Module } from "@nestjs/common";
import { LeaguesController } from "./leagues.controller";
import { LeaguesService } from "./leagues.service";
import { DatabaseModule } from "../../database/database.module";
@Module({
imports: [DatabaseModule],
+12 -12
View File
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { Sport } from '@prisma/client';
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { Sport } from "@prisma/client";
@Injectable()
export class LeaguesService {
@@ -13,7 +13,7 @@ export class LeaguesService {
*/
async findAllCountries() {
return this.prisma.country.findMany({
orderBy: { name: 'asc' },
orderBy: { name: "asc" },
});
}
@@ -34,7 +34,7 @@ export class LeaguesService {
return this.prisma.league.findMany({
where: sport ? { sport } : undefined,
include: { country: true },
orderBy: { name: 'asc' },
orderBy: { name: "asc" },
});
}
@@ -58,7 +58,7 @@ export class LeaguesService {
...(sport ? { sport } : {}),
},
include: { country: true },
orderBy: { name: 'asc' },
orderBy: { name: "asc" },
});
}
@@ -69,9 +69,9 @@ export class LeaguesService {
return this.prisma.team.findMany({
where: {
...(sport ? { sport } : {}),
...(search ? { name: { contains: search, mode: 'insensitive' } } : {}),
...(search ? { name: { contains: search, mode: "insensitive" } } : {}),
},
orderBy: { name: 'asc' },
orderBy: { name: "asc" },
take: 100,
});
}
@@ -91,7 +91,7 @@ export class LeaguesService {
async searchTeams(name: string, sport?: Sport) {
return this.prisma.team.findMany({
where: {
name: { contains: name, mode: 'insensitive' },
name: { contains: name, mode: "insensitive" },
...(sport ? { sport } : {}),
},
take: 20,
@@ -111,7 +111,7 @@ export class LeaguesService {
awayTeam: true,
league: { include: { country: true } },
},
orderBy: { mstUtc: 'desc' },
orderBy: { mstUtc: "desc" },
take: limit,
});
}
@@ -126,14 +126,14 @@ export class LeaguesService {
{ homeTeamId: teamId1, awayTeamId: teamId2 },
{ homeTeamId: teamId2, awayTeamId: teamId1 },
],
state: 'postGame', // Finished matches are stored as "postGame"
state: "postGame", // Finished matches are stored as "postGame"
},
include: {
homeTeam: true,
awayTeam: true,
league: true,
},
orderBy: { mstUtc: 'desc' },
orderBy: { mstUtc: "desc" },
take: limit,
});
+12 -12
View File
@@ -8,21 +8,21 @@ import {
Max,
IsArray,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
} from "class-validator";
import { Type } from "class-transformer";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export enum Sport {
FOOTBALL = 'football',
BASKETBALL = 'basketball',
FOOTBALL = "football",
BASKETBALL = "basketball",
}
export class OddFilterDto {
@ApiProperty({ example: 'Maç Sonucu' })
@ApiProperty({ example: "Maç Sonucu" })
@IsString()
categoryName: string;
@ApiProperty({ example: '1' })
@ApiProperty({ example: "1" })
@IsString()
selectionName: string;
@@ -39,10 +39,10 @@ export class TeamFilterDto {
@IsString()
id: string;
@ApiPropertyOptional({ enum: ['home', 'away', 'any'] })
@ApiPropertyOptional({ enum: ["home", "away", "any"] })
@IsOptional()
@IsString()
role?: 'home' | 'away' | 'any';
role?: "home" | "away" | "any";
}
export class DateRangeDto {
@@ -73,13 +73,13 @@ export class MatchQueryDto {
leagueId?: string;
@ApiPropertyOptional({
description: 'Filter by status: LIVE, Finished, etc.',
description: "Filter by status: LIVE, Finished, etc.",
})
@IsOptional()
@IsString()
status?: string;
@ApiPropertyOptional({ description: 'Single date filter (YYYY-MM-DD)' })
@ApiPropertyOptional({ description: "Single date filter (YYYY-MM-DD)" })
@IsOptional()
@IsDateString()
date?: string;
@@ -153,7 +153,7 @@ export class MatchResponseDto {
@ApiPropertyOptional()
countryName?: string;
@ApiPropertyOptional({ type: 'array' })
@ApiPropertyOptional({ type: "array" })
odds?: any[];
}
+32 -32
View File
@@ -10,26 +10,26 @@ import {
NotFoundException,
BadRequestException,
UseInterceptors,
} from '@nestjs/common';
import { Public } from '../../common/decorators';
} from "@nestjs/common";
import { Public } from "../../common/decorators";
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiQuery,
ApiParam,
} from '@nestjs/swagger';
import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager';
import { MatchesService } from './matches.service';
} from "@nestjs/swagger";
import { CacheInterceptor, CacheTTL } from "@nestjs/cache-manager";
import { MatchesService } from "./matches.service";
import {
MatchQueryDto,
Sport,
LeagueWithMatchesDto,
ActiveLeagueDto,
} from './dto';
} from "./dto";
@ApiTags('Matches')
@Controller('matches')
@ApiTags("Matches")
@Controller("matches")
export class MatchesController {
constructor(private readonly matchesService: MatchesService) {}
@@ -38,9 +38,9 @@ export class MatchesController {
* Advanced match query with filters
*/
@Public()
@Post('query')
@Post("query")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Advanced match query with filters' })
@ApiOperation({ summary: "Advanced match query with filters" })
@ApiResponse({ status: 200, type: [LeagueWithMatchesDto] })
async queryMatches(
@Body() queryDto: MatchQueryDto,
@@ -67,18 +67,18 @@ export class MatchesController {
*/
@Public()
@Get()
@ApiOperation({ summary: 'List matches with pagination' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'sport', required: false, enum: Sport })
@ApiResponse({ status: 200, description: 'Paginated list of matches' })
@ApiOperation({ summary: "List matches with pagination" })
@ApiQuery({ name: "page", required: false, type: Number })
@ApiQuery({ name: "limit", required: false, type: Number })
@ApiQuery({ name: "sport", required: false, enum: Sport })
@ApiResponse({ status: 200, description: "Paginated list of matches" })
async listMatches(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('sport') sport?: Sport,
@Query("page") page?: string,
@Query("limit") limit?: string,
@Query("sport") sport?: Sport,
) {
const pageNum = parseInt(page || '1', 10);
const limitNum = parseInt(limit || '20', 10);
const pageNum = parseInt(page || "1", 10);
const limitNum = parseInt(limit || "20", 10);
const sportType = sport || Sport.FOOTBALL;
return this.matchesService.listMatches(sportType, pageNum, limitNum);
@@ -89,14 +89,14 @@ export class MatchesController {
* Get active leagues with match counts
*/
@Public()
@Get('leagues/active')
@Get("leagues/active")
@UseInterceptors(CacheInterceptor)
@CacheTTL(60000) // 1 minute cache
@ApiOperation({ summary: 'Get active leagues with upcoming/live matches' })
@ApiQuery({ name: 'sport', required: false, enum: Sport })
@ApiOperation({ summary: "Get active leagues with upcoming/live matches" })
@ApiQuery({ name: "sport", required: false, enum: Sport })
@ApiResponse({ status: 200, type: [ActiveLeagueDto] })
async getActiveLeagues(
@Query('sport') sport?: Sport,
@Query("sport") sport?: Sport,
): Promise<ActiveLeagueDto[]> {
return this.matchesService.getActiveLeagues(sport || Sport.FOOTBALL);
}
@@ -106,23 +106,23 @@ export class MatchesController {
* Get full match details
*/
@Public()
@Get(':id')
@ApiOperation({ summary: 'Get full match details by ID' })
@ApiParam({ name: 'id', description: 'Match ID' })
@Get(":id")
@ApiOperation({ summary: "Get full match details by ID" })
@ApiParam({ name: "id", description: "Match ID" })
@ApiResponse({
status: 200,
description: 'Match details with lineups, stats, odds, events',
description: "Match details with lineups, stats, odds, events",
})
@ApiResponse({ status: 404, description: 'Match not found' })
async getMatchDetails(@Param('id') id: string) {
@ApiResponse({ status: 404, description: "Match not found" })
async getMatchDetails(@Param("id") id: string) {
if (!id) {
throw new BadRequestException('Match ID is required');
throw new BadRequestException("Match ID is required");
}
const match = await this.matchesService.getMatchDetailsById(id);
if (!match) {
throw new NotFoundException('Match not found');
throw new NotFoundException("Match not found");
}
return match;
+4 -4
View File
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { MatchesController } from './matches.controller';
import { MatchesService } from './matches.service';
import { DatabaseModule } from '../../database/database.module';
import { Module } from "@nestjs/common";
import { MatchesController } from "./matches.controller";
import { MatchesService } from "./matches.service";
import { DatabaseModule } from "../../database/database.module";
@Module({
imports: [DatabaseModule],
+70 -70
View File
@@ -1,14 +1,14 @@
import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import { PrismaService } from '../../database/prisma.service';
import { Injectable, Logger } from "@nestjs/common";
import * as fs from "fs";
import * as path from "path";
import { PrismaService } from "../../database/prisma.service";
import {
Sport,
MatchQueryDto,
LeagueWithMatchesDto,
ActiveLeagueDto,
} from './dto';
import { Prisma } from '@prisma/client';
} from "./dto";
import { Prisma } from "@prisma/client";
@Injectable()
export class MatchesService {
@@ -21,9 +21,9 @@ export class MatchesService {
private loadTopLeagues() {
try {
const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json');
const topLeaguesPath = path.join(process.cwd(), "top_leagues.json");
if (fs.existsSync(topLeaguesPath)) {
this.topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8'));
this.topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
this.logger.log(
`Loaded ${this.topLeagueIds.length} top leagues for filtering.`,
);
@@ -39,22 +39,22 @@ export class MatchesService {
{
status: {
in: [
'LIVE',
'1H',
'2H',
'HT',
'1Q',
'2Q',
'3Q',
'4Q',
'Playing',
'Half Time',
"LIVE",
"1H",
"2H",
"HT",
"1Q",
"2Q",
"3Q",
"4Q",
"Playing",
"Half Time",
],
},
},
{
state: {
in: ['live', 'firsthalf', 'secondhalf'],
in: ["live", "firsthalf", "secondhalf"],
},
},
],
@@ -66,12 +66,12 @@ export class MatchesService {
OR: [
{
status: {
in: ['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'],
in: ["Finished", "Played", "FT", "AET", "PEN", "Ended"],
},
},
{
state: {
in: ['Finished', 'post', 'FT', 'postGame'],
in: ["Finished", "post", "FT", "postGame"],
},
},
],
@@ -134,16 +134,16 @@ export class MatchesService {
if (leagueId) {
where.leagueId = leagueId;
} else if (status === 'LIVE' && this.topLeagueIds.length > 0) {
} else if (status === "LIVE" && this.topLeagueIds.length > 0) {
// Filter live matches by top leagues by default if no leagueId is provided
where.leagueId = { in: this.topLeagueIds };
}
if (status === 'LIVE') {
if (status === "LIVE") {
andConditions.push(this.getLiveFilter());
} else if (status === 'UPCOMING' || status === 'NOT_STARTED') {
} else if (status === "UPCOMING" || status === "NOT_STARTED") {
andConditions.push(this.getUpcomingFilter(Date.now()));
} else if (status === 'FINISHED') {
} else if (status === "FINISHED") {
andConditions.push(this.getFinishedFilter());
} else if (status) {
where.status = status;
@@ -170,9 +170,9 @@ export class MatchesService {
// Team filter
if (team) {
if (team.role === 'home') {
if (team.role === "home") {
where.homeTeamId = team.id;
} else if (team.role === 'away') {
} else if (team.role === "away") {
where.awayTeamId = team.id;
} else {
andConditions.push({
@@ -197,7 +197,7 @@ export class MatchesService {
const matches = await this.prisma.liveMatch.findMany({
where,
select: { id: true },
orderBy: { mstUtc: 'asc' }, // Sort by nearest match first
orderBy: { mstUtc: "asc" }, // Sort by nearest match first
take: limit,
});
@@ -220,7 +220,7 @@ export class MatchesService {
AND: [this.getUpcomingFilter(Date.now())],
},
select: { id: true },
orderBy: { mstUtc: 'asc' },
orderBy: { mstUtc: "asc" },
take: limit,
});
console.log(
@@ -283,16 +283,16 @@ export class MatchesService {
const leaguesMap = new Map<string, LeagueWithMatchesDto>();
for (const match of matches) {
const leagueId = match.leagueId || 'unknown';
const leagueId = match.leagueId || "unknown";
if (!leaguesMap.has(leagueId)) {
leaguesMap.set(leagueId, {
id: leagueId,
name: match.league?.name || 'Unknown League',
name: match.league?.name || "Unknown League",
code: match.league?.code || undefined,
country: {
id: match.league?.country?.id || '',
name: match.league?.country?.name || '',
id: match.league?.country?.id || "",
name: match.league?.country?.name || "",
flagUrl: match.league?.country?.flagUrl || undefined,
},
sport: sport,
@@ -306,13 +306,13 @@ export class MatchesService {
const structuredOdds: any[] = [];
if (
match.odds &&
typeof match.odds === 'object' &&
typeof match.odds === "object" &&
!Array.isArray(match.odds)
) {
const oddsObj = match.odds as Record<string, Record<string, number>>;
for (const [marketName, selections] of Object.entries(oddsObj)) {
const structuredSelections: Record<string, { odd: string }> = {};
if (selections && typeof selections === 'object') {
if (selections && typeof selections === "object") {
for (const [selName, selOdd] of Object.entries(selections)) {
structuredSelections[selName] = { odd: String(selOdd) };
}
@@ -325,15 +325,15 @@ export class MatchesService {
}
// Map status for frontend
let displayStatus = match.status || 'NS';
if (match.state === 'live') {
displayStatus = 'LIVE';
let displayStatus = match.status || "NS";
if (match.state === "live") {
displayStatus = "LIVE";
} else if (
match.state === 'post' ||
match.state === 'FT' ||
match.status === 'Finished'
match.state === "post" ||
match.state === "FT" ||
match.status === "Finished"
) {
displayStatus = 'Finished';
displayStatus = "Finished";
}
league.matches.push({
@@ -349,11 +349,11 @@ export class MatchesService {
scoreAway: match.scoreAway ?? undefined,
htScoreHome: undefined, // LiveMatch table doesn't have HT scores separately usually
htScoreAway: undefined,
homeTeamName: match.homeTeam?.name || 'Unknown',
homeTeamName: match.homeTeam?.name || "Unknown",
homeTeamLogo: match.homeTeamId
? `https://file.mackolikfeeds.com/teams/${match.homeTeamId}`
: undefined,
awayTeamName: match.awayTeam?.name || 'Unknown',
awayTeamName: match.awayTeam?.name || "Unknown",
awayTeamLogo: match.awayTeamId
? `https://file.mackolikfeeds.com/teams/${match.awayTeamId}`
: undefined,
@@ -390,15 +390,15 @@ export class MatchesService {
// Priority sorting (Mackolik style)
const PRIORITY = [
'Trendyol Süper Lig',
'Süper Lig',
'Trendyol 1. Lig',
'1. Lig',
'Premier Lig',
'LaLiga',
'Serie A',
'Bundesliga',
'Ligue 1',
"Trendyol Süper Lig",
"Süper Lig",
"Trendyol 1. Lig",
"1. Lig",
"Premier Lig",
"LaLiga",
"Serie A",
"Bundesliga",
"Ligue 1",
];
return leagues
@@ -410,7 +410,7 @@ export class MatchesService {
const bPriority = bIdx === -1 ? 999 : bIdx;
if (aPriority !== bPriority) return aPriority - bPriority;
return (a.name || '').localeCompare(b.name || '');
return (a.name || "").localeCompare(b.name || "");
})
.map((l) => ({
id: l.id,
@@ -439,7 +439,7 @@ export class MatchesService {
include: { country: true },
},
},
orderBy: { mstUtc: 'desc' },
orderBy: { mstUtc: "desc" },
skip,
take: limit,
}),
@@ -482,7 +482,7 @@ export class MatchesService {
createdAt: stat.createdAt,
};
if ((sport || '').toLowerCase() === 'basketball') {
if ((sport || "").toLowerCase() === "basketball") {
return {
...base,
points: stat.points,
@@ -532,7 +532,7 @@ export class MatchesService {
basketballTeamStats: true,
playerParticipations: {
include: { player: true },
orderBy: [{ isStarting: 'desc' }, { position: 'asc' }],
orderBy: [{ isStarting: "desc" }, { position: "asc" }],
},
playerEvents: {
include: {
@@ -540,7 +540,7 @@ export class MatchesService {
assistPlayer: true,
substitutedOut: true,
},
orderBy: [{ periodId: 'asc' }, { timeMinute: 'asc' }],
orderBy: [{ periodId: "asc" }, { timeMinute: "asc" }],
},
oddCategories: {
include: { selections: true },
@@ -562,15 +562,15 @@ export class MatchesService {
if (liveMatch) {
// Map liveMatch status
let displayStatus = liveMatch.status || 'NS';
if (liveMatch.state === 'live') {
displayStatus = 'LIVE';
let displayStatus = liveMatch.status || "NS";
if (liveMatch.state === "live") {
displayStatus = "LIVE";
} else if (
liveMatch.state === 'post' ||
liveMatch.state === 'FT' ||
liveMatch.status === 'Finished'
liveMatch.state === "post" ||
liveMatch.state === "FT" ||
liveMatch.status === "Finished"
) {
displayStatus = 'Finished';
displayStatus = "Finished";
}
match = {
@@ -607,14 +607,14 @@ export class MatchesService {
if (
match.isLiveSource &&
match.odds &&
typeof match.odds === 'object' &&
typeof match.odds === "object" &&
!Array.isArray(match.odds)
) {
// Parse JSON odds from LiveMatch
const oddsObj = match.odds as Record<string, Record<string, number>>;
for (const [marketName, selections] of Object.entries(oddsObj)) {
odds[marketName] = {};
if (selections && typeof selections === 'object') {
if (selections && typeof selections === "object") {
for (const [selName, selOdd] of Object.entries(selections)) {
odds[marketName][selName] = { odd: String(selOdd) };
}
@@ -628,7 +628,7 @@ export class MatchesService {
for (const sel of cat.selections) {
if (sel.name) {
odds[cat.name][sel.name] = {
odd: sel.oddValue || '',
odd: sel.oddValue || "",
sov: sel.sov ?? undefined,
};
}
@@ -637,7 +637,7 @@ export class MatchesService {
}
const sportStats =
match.sport === 'basketball'
match.sport === "basketball"
? match.basketballTeamStats || []
: match.footballTeamStats || [];
const normalizedTeamStats = sportStats.map((s: any) =>
@@ -692,7 +692,7 @@ export class MatchesService {
// Fuzzy search
team = await this.prisma.team.findFirst({
where: {
name: { contains: trimmedName, mode: 'insensitive' },
name: { contains: trimmedName, mode: "insensitive" },
sport: sport as any,
},
select: { id: true },
+32 -37
View File
@@ -1,11 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty } from "@nestjs/swagger";
export type SignalTier =
| 'CORE'
| 'VALUE'
| 'LEAN'
| 'LONGSHOT'
| 'PASS';
export type SignalTier = "CORE" | "VALUE" | "LEAN" | "LONGSHOT" | "PASS";
export class MatchInfoDto {
@ApiProperty()
@@ -34,14 +29,14 @@ export class MatchInfoDto {
@ApiProperty({
required: false,
enum: ['football', 'basketball'],
enum: ["football", "basketball"],
})
sport?: 'football' | 'basketball';
sport?: "football" | "basketball";
}
export class DataQualityDto {
@ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] })
label: 'HIGH' | 'MEDIUM' | 'LOW';
@ApiProperty({ enum: ["HIGH", "MEDIUM", "LOW"] })
label: "HIGH" | "MEDIUM" | "LOW";
@ApiProperty()
score: number;
@@ -52,7 +47,7 @@ export class DataQualityDto {
@ApiProperty()
away_lineup_count: number;
@ApiProperty({ required: false, default: 'none' })
@ApiProperty({ required: false, default: "none" })
lineup_source?: string;
@ApiProperty({ type: [String] })
@@ -69,16 +64,16 @@ export class ConfidenceIntervalDto {
@ApiProperty()
width: number;
@ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] })
band: 'HIGH' | 'MEDIUM' | 'LOW';
@ApiProperty({ enum: ["HIGH", "MEDIUM", "LOW"] })
band: "HIGH" | "MEDIUM" | "LOW";
@ApiProperty()
threshold_met: boolean;
}
export class RiskDto {
@ApiProperty({ enum: ['LOW', 'MEDIUM', 'HIGH', 'EXTREME'] })
level: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
@ApiProperty({ enum: ["LOW", "MEDIUM", "HIGH", "EXTREME"] })
level: "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
@ApiProperty()
score: number;
@@ -156,8 +151,8 @@ export class MatchPickDto {
@ApiProperty()
playable: boolean;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
bet_grade: 'A' | 'B' | 'C' | 'PASS';
@ApiProperty({ enum: ["A", "B", "C", "PASS"] })
bet_grade: "A" | "B" | "C" | "PASS";
@ApiProperty()
stake_units: number;
@@ -170,7 +165,7 @@ export class MatchPickDto {
@ApiProperty({
required: false,
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"],
})
signal_tier?: SignalTier;
}
@@ -185,15 +180,15 @@ export class MatchBetAdviceDto {
@ApiProperty()
reason: string;
@ApiProperty({ required: false, enum: ['HIGH', 'MEDIUM', 'LOW'] })
confidence_band?: 'HIGH' | 'MEDIUM' | 'LOW';
@ApiProperty({ required: false, enum: ["HIGH", "MEDIUM", "LOW"] })
confidence_band?: "HIGH" | "MEDIUM" | "LOW";
@ApiProperty({ required: false })
min_confidence_for_play?: number;
@ApiProperty({
required: false,
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"],
})
signal_tier?: SignalTier;
}
@@ -211,8 +206,8 @@ export class MatchBetSummaryItemDto {
@ApiProperty()
calibrated_confidence: number;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
bet_grade: 'A' | 'B' | 'C' | 'PASS';
@ApiProperty({ enum: ["A", "B", "C", "PASS"] })
bet_grade: "A" | "B" | "C" | "PASS";
@ApiProperty()
playable: boolean;
@@ -240,30 +235,30 @@ export class MatchBetSummaryItemDto {
@ApiProperty({
required: false,
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"],
})
signal_tier?: SignalTier;
}
export class HtFtPredictionDto {
@ApiProperty()
'1/1': number;
"1/1": number;
@ApiProperty()
'1/X': number;
"1/X": number;
@ApiProperty()
'1/2': number;
"1/2": number;
@ApiProperty()
'X/1': number;
"X/1": number;
@ApiProperty()
'X/X': number;
"X/X": number;
@ApiProperty()
'X/2': number;
"X/2": number;
@ApiProperty()
'2/1': number;
"2/1": number;
@ApiProperty()
'2/X': number;
"2/X": number;
@ApiProperty()
'2/2': number;
"2/2": number;
@ApiProperty()
pick: string;
@ApiProperty()
@@ -310,8 +305,8 @@ export class AggressivePickDto {
@ApiProperty()
playable: boolean;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
bet_grade: 'A' | 'B' | 'C' | 'PASS';
@ApiProperty({ enum: ["A", "B", "C", "PASS"] })
bet_grade: "A" | "B" | "C" | "PASS";
@ApiProperty()
stake_units: number;
@@ -468,4 +463,4 @@ export class AIHealthDto {
predictionServiceReady: boolean;
}
export * from './smart-coupon.dto';
export * from "./smart-coupon.dto";
@@ -8,28 +8,28 @@ import {
ArrayMaxSize,
Min,
Max,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
} from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export class GeneratePredictionDto {
@ApiProperty({ description: 'Match ID to generate prediction for' })
@ApiProperty({ description: "Match ID to generate prediction for" })
@IsString()
@IsNotEmpty()
matchId: string;
}
export enum CouponStrategy {
SAFE = 'SAFE',
BALANCED = 'BALANCED',
AGGRESSIVE = 'AGGRESSIVE',
VALUE = 'VALUE',
MIRACLE = 'MIRACLE',
SAFE = "SAFE",
BALANCED = "BALANCED",
AGGRESSIVE = "AGGRESSIVE",
VALUE = "VALUE",
MIRACLE = "MIRACLE",
}
export class SmartCouponRequestDto {
@ApiProperty({
description: 'List of match IDs for coupon',
example: ['match-1', 'match-2'],
description: "List of match IDs for coupon",
example: ["match-1", "match-2"],
})
@IsArray()
@IsString({ each: true })
@@ -44,7 +44,7 @@ export class SmartCouponRequestDto {
@IsEnum(CouponStrategy)
strategy?: CouponStrategy;
@ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 })
@ApiPropertyOptional({ description: "Maximum matches in coupon", example: 5 })
@IsOptional()
@IsNumber()
@Min(1)
@@ -52,7 +52,7 @@ export class SmartCouponRequestDto {
maxMatches?: number;
@ApiPropertyOptional({
description: 'Minimum confidence threshold (0-100)',
description: "Minimum confidence threshold (0-100)",
example: 60,
})
@IsOptional()
@@ -3,14 +3,14 @@
*/
export type CouponStrategy =
| 'SAFE'
| 'BALANCED'
| 'AGGRESSIVE'
| 'VALUE'
| 'MIRACLE';
| "SAFE"
| "BALANCED"
| "AGGRESSIVE"
| "VALUE"
| "MIRACLE";
export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
export type DataQualityLabel = 'HIGH' | 'MEDIUM' | 'LOW';
export type RiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
export type DataQualityLabel = "HIGH" | "MEDIUM" | "LOW";
export interface SmartCouponRequestDto {
match_ids: string[];
@@ -7,24 +7,24 @@ import {
HttpCode,
HttpStatus,
NotFoundException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
import { PredictionsService } from './predictions.service';
} from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from "@nestjs/swagger";
import { PredictionsService } from "./predictions.service";
import {
MatchPredictionDto,
PredictionHistoryResponseDto,
UpcomingPredictionsDto,
ValueBetDto,
AIHealthDto,
} from './dto';
} from "./dto";
import {
GeneratePredictionDto,
SmartCouponRequestDto,
} from './dto/predictions-request.dto';
import { Public } from 'src/common/decorators';
} from "./dto/predictions-request.dto";
import { Public } from "src/common/decorators";
@ApiTags('Predictions')
@Controller('predictions')
@ApiTags("Predictions")
@Controller("predictions")
export class PredictionsController {
constructor(private readonly predictionsService: PredictionsService) {}
@@ -32,8 +32,8 @@ export class PredictionsController {
* GET /predictions/health
* Check AI Engine health status
*/
@Get('health')
@ApiOperation({ summary: 'Check AI Engine health status' })
@Get("health")
@ApiOperation({ summary: "Check AI Engine health status" })
@ApiResponse({ status: 200, type: AIHealthDto })
async checkHealth(): Promise<AIHealthDto> {
return this.predictionsService.checkHealth();
@@ -43,8 +43,8 @@ export class PredictionsController {
* GET /predictions/upcoming
* Get predictions for upcoming matches
*/
@Get('upcoming')
@ApiOperation({ summary: 'Get predictions for upcoming matches' })
@Get("upcoming")
@ApiOperation({ summary: "Get predictions for upcoming matches" })
@ApiResponse({ status: 200, type: UpcomingPredictionsDto })
async getUpcoming(): Promise<UpcomingPredictionsDto> {
return this.predictionsService.getUpcomingPredictions();
@@ -54,10 +54,10 @@ export class PredictionsController {
* GET /predictions/test/:id
* Refetch match data and get prediction
*/
@Get('test/:id')
@ApiOperation({ summary: 'Refetch match data and get prediction' })
@ApiParam({ name: 'id', description: 'Match ID' })
async getTestPrediction(@Param('id') id: string) {
@Get("test/:id")
@ApiOperation({ summary: "Refetch match data and get prediction" })
@ApiParam({ name: "id", description: "Match ID" })
async getTestPrediction(@Param("id") id: string) {
return this.predictionsService.testPrediction(id);
}
@@ -65,8 +65,8 @@ export class PredictionsController {
* GET /predictions/value-bets
* Get EV+ betting opportunities
*/
@Get('value-bets')
@ApiOperation({ summary: 'Get value betting opportunities (EV+)' })
@Get("value-bets")
@ApiOperation({ summary: "Get value betting opportunities (EV+)" })
@ApiResponse({ status: 200, type: [ValueBetDto] })
async getValueBets(): Promise<ValueBetDto[]> {
return this.predictionsService.getValueBets();
@@ -76,8 +76,8 @@ export class PredictionsController {
* GET /predictions/history
* Get prediction history and accuracy stats
*/
@Get('history')
@ApiOperation({ summary: 'Get prediction history and accuracy statistics' })
@Get("history")
@ApiOperation({ summary: "Get prediction history and accuracy statistics" })
@ApiResponse({ status: 200, type: PredictionHistoryResponseDto })
async getHistory(): Promise<PredictionHistoryResponseDto> {
return this.predictionsService.getPredictionHistory();
@@ -87,14 +87,14 @@ export class PredictionsController {
* GET /predictions/:matchId
* Get prediction for a specific match
*/
@Get(':matchId')
@Get(":matchId")
@Public()
@ApiOperation({ summary: 'Get prediction for a specific match' })
@ApiParam({ name: 'matchId', description: 'Match ID' })
@ApiOperation({ summary: "Get prediction for a specific match" })
@ApiParam({ name: "matchId", description: "Match ID" })
@ApiResponse({ status: 200, type: MatchPredictionDto })
@ApiResponse({ status: 404, description: 'Match not found' })
@ApiResponse({ status: 404, description: "Match not found" })
async getPrediction(
@Param('matchId') matchId: string,
@Param("matchId") matchId: string,
): Promise<MatchPredictionDto> {
// Check cache first
const cached = await this.predictionsService.getCachedPrediction(matchId);
@@ -119,9 +119,9 @@ export class PredictionsController {
* POST /predictions/generate
* Generate prediction with provided match data
*/
@Post('generate')
@Post("generate")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Generate prediction with provided match data' })
@ApiOperation({ summary: "Generate prediction with provided match data" })
@ApiResponse({ status: 200, type: MatchPredictionDto })
async generatePrediction(
@Body() dto: GeneratePredictionDto,
@@ -131,7 +131,7 @@ export class PredictionsController {
});
if (!prediction) {
throw new NotFoundException('Failed to generate prediction');
throw new NotFoundException("Failed to generate prediction");
}
return prediction;
@@ -141,19 +141,19 @@ export class PredictionsController {
* POST /predictions/smart-coupon
* Generate Smart Coupon using AI Engine V20
*/
@Post('smart-coupon')
@Post("smart-coupon")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Generate Smart Coupon with V20 AI recommendations',
summary: "Generate Smart Coupon with V20 AI recommendations",
})
@ApiResponse({
status: 200,
description: 'Smart coupon generated successfully',
description: "Smart coupon generated successfully",
})
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> {
const coupon = await this.predictionsService.getSmartCoupon(
dto.matchIds,
dto.strategy || 'BALANCED',
dto.strategy || "BALANCED",
{
maxMatches: dto.maxMatches,
minConfidence: dto.minConfidence,
@@ -161,7 +161,7 @@ export class PredictionsController {
);
if (!coupon) {
throw new NotFoundException('Failed to generate Smart Coupon');
throw new NotFoundException("Failed to generate Smart Coupon");
}
return coupon;
+13 -13
View File
@@ -1,17 +1,17 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { BullModule } from '@nestjs/bullmq';
import { PredictionsController } from './predictions.controller';
import { PredictionsService } from './predictions.service';
import { AiFeatureStoreService } from './services/ai-feature-store.service';
import { DatabaseModule } from '../../database/database.module';
import { MatchesModule } from '../matches/matches.module';
import { PredictionsQueue } from './queues/predictions.queue';
import { PredictionsProcessor } from './queues/predictions.processor';
import { PREDICTIONS_QUEUE } from './queues/predictions.types';
import { FeederModule } from '../feeder/feeder.module';
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { BullModule } from "@nestjs/bullmq";
import { PredictionsController } from "./predictions.controller";
import { PredictionsService } from "./predictions.service";
import { AiFeatureStoreService } from "./services/ai-feature-store.service";
import { DatabaseModule } from "../../database/database.module";
import { MatchesModule } from "../matches/matches.module";
import { PredictionsQueue } from "./queues/predictions.queue";
import { PredictionsProcessor } from "./queues/predictions.processor";
import { PREDICTIONS_QUEUE } from "./queues/predictions.types";
import { FeederModule } from "../feeder/feeder.module";
const redisEnabled = process.env.REDIS_ENABLED === 'true';
const redisEnabled = process.env.REDIS_ENABLED === "true";
@Module({
imports: [
+188 -182
View File
@@ -6,26 +6,26 @@ import {
OnModuleDestroy,
OnModuleInit,
Optional,
} from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { ConfigService } from '@nestjs/config';
import { QueueEvents } from 'bullmq';
import { PredictionsQueue } from './queues/predictions.queue';
import { PREDICTIONS_QUEUE } from './queues/predictions.types';
} from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { ConfigService } from "@nestjs/config";
import { QueueEvents } from "bullmq";
import { PredictionsQueue } from "./queues/predictions.queue";
import { PREDICTIONS_QUEUE } from "./queues/predictions.types";
import {
MatchPredictionDto,
PredictionHistoryResponseDto,
UpcomingPredictionsDto,
ValueBetDto,
AIHealthDto,
} from './dto';
import axios, { AxiosError } from 'axios';
import { Prisma } from '@prisma/client';
import { FeederService } from '../feeder/feeder.service';
import * as fs from 'node:fs';
import * as path from 'node:path';
} from "./dto";
import axios, { AxiosError } from "axios";
import { Prisma } from "@prisma/client";
import { FeederService } from "../feeder/feeder.service";
import * as fs from "node:fs";
import * as path from "node:path";
type ConfidenceBand = 'HIGH' | 'MEDIUM' | 'LOW';
type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW";
interface ConfidenceInterval {
lower: number;
@@ -47,73 +47,72 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private readonly aiEngineUrl: string;
private readonly topLeagueIds = new Set<string>();
private readonly reasonTranslations: Record<string, string> = {
confidence_below_threshold: 'Güven eşiğin altında',
confidence_interval_too_wide: 'Güven aralığı çok geniş',
confidence_below_threshold: "Güven eşiğin altında",
confidence_interval_too_wide: "Güven aralığı çok geniş",
confidence_interval_too_wide_for_main_pick:
'Ana seçim için güven aralığı çok geniş',
confidence_band_low: 'Güven bandı düşük',
playable_edge_found: 'Oynanabilir avantaj bulundu',
market_signal_dominant: 'Piyasa sinyali baskın',
team_form_signal_dominant: 'Takım formuna dayalı sinyaller çok baskın',
lineup_signal_strong: 'İlk on bir sinyali güçlü',
lineup_signal_weak: 'İlk on bir sinyali zayıf',
lineup_probable_xi_used: 'Muhtemel ilk on bir kullanıldı',
lineup_probable_not_confirmed: 'Muhtemel ilk on bir henüz doğrulanmadı',
lineup_unavailable: 'İlk on bir bilgisi mevcut değil',
lineup_incomplete: 'İlk on bir bilgisi eksik',
missing_referee: 'Hakem verisi eksik',
draw_probability_elevated: 'Beraberlik olasılığı yükselmiş görünüyor',
balanced_match_risk: 'Maç dengeli, sürpriz riski yükseliyor',
draw_pressure: 'Beraberlik baskısı yüksek',
upset_risk_detected: 'Sürpriz riski tespit edildi',
limited_data_confidence: 'Veri kısıtlı olduğu için güven sınırlı',
data_quality_issue: 'Veri kalitesi sorunu var',
high_risk_low_data_quality: 'Risk yüksek, veri kalitesi düşük',
insufficient_play_score: 'Oynanabilirlik puanı yetersiz',
no_bet_conditions_met: 'Bahis koşulları oluşmadı',
market_passed_all_gates: 'Market tüm güvenlik kontrollerini geçti',
"Ana seçim için güven aralığı çok geniş",
confidence_band_low: "Güven bandı düşük",
playable_edge_found: "Oynanabilir avantaj bulundu",
market_signal_dominant: "Piyasa sinyali baskın",
team_form_signal_dominant: "Takım formuna dayalı sinyaller çok baskın",
lineup_signal_strong: "İlk on bir sinyali güçlü",
lineup_signal_weak: "İlk on bir sinyali zayıf",
lineup_probable_xi_used: "Muhtemel ilk on bir kullanıldı",
lineup_probable_not_confirmed: "Muhtemel ilk on bir henüz doğrulanmadı",
lineup_unavailable: "İlk on bir bilgisi mevcut değil",
lineup_incomplete: "İlk on bir bilgisi eksik",
missing_referee: "Hakem verisi eksik",
draw_probability_elevated: "Beraberlik olasılığı yükselmiş görünüyor",
balanced_match_risk: "Maç dengeli, sürpriz riski yükseliyor",
draw_pressure: "Beraberlik baskısı yüksek",
upset_risk_detected: "Sürpriz riski tespit edildi",
limited_data_confidence: "Veri kısıtlı olduğu için güven sınırlı",
data_quality_issue: "Veri kalitesi sorunu var",
high_risk_low_data_quality: "Risk yüksek, veri kalitesi düşük",
insufficient_play_score: "Oynanabilirlik puanı yetersiz",
no_bet_conditions_met: "Bahis koşulları oluşmadı",
market_passed_all_gates: "Market tüm güvenlik kontrollerini geçti",
no_ev_edge_minimum_stake:
'Beklenen avantaj oluşmadı, minimum bahis önerildi',
player_form_signal_strong: 'Oyuncu formu sinyali güçlü',
player_form_signal_limited: 'Oyuncu formu sinyali sınırlı',
live_state_impossible_market: 'Canlı maç durumu bu marketi geçersiz kılıyor',
live_score_exceeds_under_line:
'Mevcut skor bu alt seçeneğiyle çelişiyor',
"Beklenen avantaj oluşmadı, minimum bahis önerildi",
player_form_signal_strong: "Oyuncu formu sinyali güçlü",
player_form_signal_limited: "Oyuncu formu sinyali sınırlı",
live_state_impossible_market:
"Canlı maç durumu bu marketi geçersiz kılıyor",
live_score_exceeds_under_line: "Mevcut skor bu alt seçeneğiyle çelişiyor",
score_model_conflicts_with_under_pick:
'Skor modeli alt seçeneğiyle çelişiyor',
"Skor modeli alt seçeneğiyle çelişiyor",
score_model_conflicts_with_over_pick:
'Skor modeli üst seçeneğiyle çelişiyor',
"Skor modeli üst seçeneğiyle çelişiyor",
market_stack_conflict_over25:
'Üst 2.5 sinyaliyle çeliştiği için zayıflatıldı',
"Üst 2.5 sinyaliyle çeliştiği için zayıflatıldı",
market_stack_conflict_btts:
'Karşılıklı gol sinyaliyle çeliştiği için zayıflatıldı',
"Karşılıklı gol sinyaliyle çeliştiği için zayıflatıldı",
first_half_result_conflicts_with_goalless_half:
'İlk yarı sonucu beklentisi golsüz ilk yarıyla çelişiyor',
"İlk yarı sonucu beklentisi golsüz ilk yarıyla çelişiyor",
first_half_htft_conflicts_with_goalless_half:
'İlk yarı/maç sonu beklentisi golsüz ilk yarıyla çelişiyor',
"İlk yarı/maç sonu beklentisi golsüz ilk yarıyla çelişiyor",
first_half_draw_conflicts_with_goal_pick:
'İlk yarı beraberlik baskısı erken gol beklentisiyle çelişiyor',
"İlk yarı beraberlik baskısı erken gol beklentisiyle çelişiyor",
first_half_goalless_conflicts_with_result_pick:
'Golsüz ilk yarı beklentisi ilk yarı sonuç seçimiyle çelişiyor',
"Golsüz ilk yarı beklentisi ilk yarı sonuç seçimiyle çelişiyor",
first_half_goalless_conflicts_with_htft_pick:
'Golsüz ilk yarı beklentisi ilk yarı/maç sonu seçimiyle çelişiyor',
"Golsüz ilk yarı beklentisi ilk yarı/maç sonu seçimiyle çelişiyor",
first_half_goal_pressure_conflicts_with_htft_draw:
'İlk yarı gol baskısı ilk yarı beraberlik kurgusuyla çelişiyor',
live_total_goals_close_to_line:
'Canlı toplam gol çizgisine çok yakın',
"İlk yarı gol baskısı ilk yarı beraberlik kurgusuyla çelişiyor",
live_total_goals_close_to_line: "Canlı toplam gol çizgisine çok yakın",
score_model_conflicts_with_btts_no:
'Skor modeli KG Yok seçeneğiyle çelişiyor',
"Skor modeli KG Yok seçeneğiyle çelişiyor",
score_model_conflicts_with_draw_pick:
'Skor modeli beraberlik seçeneğiyle çelişiyor',
"Skor modeli beraberlik seçeneğiyle çelişiyor",
score_model_conflicts_with_home_pick:
'Skor modeli ev sahibi seçeneğiyle çelişiyor',
"Skor modeli ev sahibi seçeneğiyle çelişiyor",
score_model_conflicts_with_away_pick:
'Skor modeli deplasman seçeneğiyle çelişiyor',
high_total_goal_volatility: 'Toplam gol volatilitesi yüksek',
mutual_goal_pressure: 'İki takımın da gol baskısı yüksek',
late_goal_swing_risk: 'Geç gol kaynaklı kırılma riski var',
live_match_open_state: 'Canlı maç açık oyuna dönmüş durumda',
live_match_active_state: 'Canlı maç aktif ve dalgalı ilerliyor',
"Skor modeli deplasman seçeneğiyle çelişiyor",
high_total_goal_volatility: "Toplam gol volatilitesi yüksek",
mutual_goal_pressure: "İki takımın da gol baskısı yüksek",
late_goal_swing_risk: "Geç gol kaynaklı kırılma riski var",
live_match_open_state: "Canlı maç açık oyuna dönmüş durumda",
live_match_active_state: "Canlı maç aktif ve dalgalı ilerliyor",
};
constructor(
@@ -123,8 +122,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
@Optional() private readonly predictionsQueue?: PredictionsQueue,
) {
this.aiEngineUrl = this.configService.get(
'AI_ENGINE_URL',
'http://localhost:8000',
"AI_ENGINE_URL",
"http://localhost:8000",
);
this.topLeagueIds = this.loadTopLeagueIds();
}
@@ -133,14 +132,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (this.predictionsQueue) {
this.queueEvents = new QueueEvents(PREDICTIONS_QUEUE, {
connection: {
host: this.configService.get('redis.host', 'localhost'),
port: this.configService.get('redis.port', 6379),
password: this.configService.get('redis.password'),
host: this.configService.get("redis.host", "localhost"),
port: this.configService.get("redis.port", 6379),
password: this.configService.get("redis.password"),
},
});
this.logger.log('Queue mode enabled for predictions');
this.logger.log("Queue mode enabled for predictions");
} else {
this.logger.log('Direct HTTP mode enabled for predictions (no Redis)');
this.logger.log("Direct HTTP mode enabled for predictions (no Redis)");
}
}
@@ -152,7 +151,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
checkHealth(): Promise<AIHealthDto> {
return Promise.resolve({
status: 'healthy',
status: "healthy",
modelLoaded: true,
predictionServiceReady: true,
});
@@ -212,12 +211,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
if (status === 422) {
throw new HttpException(
`AI Engine: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`,
`AI Engine: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`,
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
throw new HttpException(
`AI Engine error: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`,
`AI Engine error: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`,
status || HttpStatus.SERVICE_UNAVAILABLE,
);
}
@@ -232,7 +231,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async testPrediction(matchId: string): Promise<MatchPredictionDto | null> {
this.logger.log(`[TEST PREDICTION] Syncing match data for ${matchId}...`);
// refreshMatch triggers the feeder scraper to get all match info, odds, and lineups and write to DB
const refreshResult = await this.feederService.refreshMatch(matchId, 'all');
const refreshResult = await this.feederService.refreshMatch(matchId, "all");
if (!refreshResult.success) {
this.logger.warn(
@@ -251,7 +250,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const upcoming = await this.prisma.prediction.findMany({
where: {
match: {
status: 'NS',
status: "NS",
mstUtc: { gte: Math.floor(Date.now() / 1000) },
},
},
@@ -260,13 +259,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
include: { homeTeam: true, awayTeam: true, league: true },
},
},
orderBy: { match: { mstUtc: 'asc' } },
orderBy: { match: { mstUtc: "asc" } },
take: 50,
});
return {
count: upcoming.length,
modelVersion: 'v25-v30-ensemble',
modelVersion: "v25-v30-ensemble",
matches: upcoming.map((p) => {
const out = p.predictionJson as Record<string, unknown>;
const matchInfo = (out?.match_info || {}) as Record<string, unknown>;
@@ -276,9 +275,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
...matchInfo,
match_name: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`,
match_date_ms: Number(p.match.mstUtc) * 1000,
league: p.match.league?.name || '',
league: p.match.league?.name || "",
league_id: p.match.leagueId,
is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ''),
is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ""),
},
} as unknown as MatchPredictionDto;
}),
@@ -287,12 +286,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private loadTopLeagueIds(): Set<string> {
try {
const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json');
const topLeaguesPath = path.join(process.cwd(), "top_leagues.json");
if (!fs.existsSync(topLeaguesPath)) {
return new Set<string>();
}
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8'));
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
if (!Array.isArray(raw)) {
return new Set<string>();
}
@@ -318,7 +317,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (match) {
return {
leagueId: match.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(match.leagueId ?? ''),
isTopLeague: this.topLeagueIds.has(match.leagueId ?? ""),
};
}
@@ -329,7 +328,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return {
leagueId: liveMatch?.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ''),
isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ""),
};
}
@@ -346,7 +345,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
league_id:
this.asRecord(response.match_info).league_id ?? matchContext.leagueId,
is_top_league:
this.asRecord(response.match_info).is_top_league ?? matchContext.isTopLeague,
this.asRecord(response.match_info).is_top_league ??
matchContext.isTopLeague,
};
const mainPick = this.enrichPick(
@@ -369,9 +369,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
);
const supportingPicks = Array.isArray(response.supporting_picks)
? response.supporting_picks.map((pick) =>
this.enrichPick(pick, response, matchContext, marketBoard),
).filter((pick): pick is NonNullable<typeof pick> => pick !== null)
? response.supporting_picks
.map((pick) =>
this.enrichPick(pick, response, matchContext, marketBoard),
)
.filter((pick): pick is NonNullable<typeof pick> => pick !== null)
: [];
const betSummary = Array.isArray(response.bet_summary)
@@ -380,8 +382,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
)
: [];
const mainBand =
this.asRecord(mainPick?.confidence_interval).band ?? 'LOW';
const mainBand = this.asRecord(mainPick?.confidence_interval).band ?? "LOW";
const minConfidenceForPlay = this.getMinConfidenceForPlay(
this.asRecord(mainPick).market,
matchContext.isTopLeague,
@@ -402,7 +403,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (mainPick && !isMainPlayable) {
reasoningFactors.unshift(
this.translateReason('confidence_interval_too_wide_for_main_pick'),
this.translateReason("confidence_interval_too_wide_for_main_pick"),
);
}
@@ -416,9 +417,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
isMainPlayable
? String(
this.asRecord(response.bet_advice).reason ||
'playable_edge_found',
"playable_edge_found",
)
: 'confidence_below_threshold',
: "confidence_below_threshold",
),
suggested_stake_units: isMainPlayable
? Number(this.asRecord(response.bet_advice).suggested_stake_units ?? 0)
@@ -428,15 +429,18 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const enrichedMarketBoard = Object.fromEntries(
Object.entries(marketBoard).map(([market, entry]) => {
const record = this.asRecord(entry);
const pickName = String(record.pick ?? '');
if (!pickName || !record.probs || typeof record.probs !== 'object') {
const pickName = String(record.pick ?? "");
if (!pickName || !record.probs || typeof record.probs !== "object") {
return [market, record];
}
const syntheticPick = {
market,
pick: pickName,
probability: this.lookupProbability(record.probs as Record<string, unknown>, pickName),
probability: this.lookupProbability(
record.probs as Record<string, unknown>,
pickName,
),
confidence: Number(record.confidence ?? 0),
calibrated_confidence: Number(record.confidence ?? 0),
raw_confidence: Number(record.confidence ?? 0),
@@ -447,7 +451,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
implied_prob: 0,
play_score: 0,
playable: false,
bet_grade: 'PASS',
bet_grade: "PASS",
stake_units: 0,
decision_reasons: [],
};
@@ -464,7 +468,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
{
...record,
confidence_interval: this.asRecord(enriched?.confidence_interval),
confidence_band: this.asRecord(enriched?.confidence_interval).band ?? 'LOW',
confidence_band:
this.asRecord(enriched?.confidence_interval).band ?? "LOW",
},
];
}),
@@ -472,14 +477,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return {
...response,
match_info: matchInfo as MatchPredictionDto['match_info'],
match_info: matchInfo as MatchPredictionDto["match_info"],
data_quality: {
...dataQuality,
lineup_source: String(dataQuality.lineup_source ?? 'none'),
} as MatchPredictionDto['data_quality'],
lineup_source: String(dataQuality.lineup_source ?? "none"),
} as MatchPredictionDto["data_quality"],
risk: {
...risk,
surprise_type: this.translateReason(String(risk.surprise_type ?? '')),
surprise_type: this.translateReason(String(risk.surprise_type ?? "")),
surprise_reasons: Array.isArray(risk.surprise_reasons)
? risk.surprise_reasons.map((reason) =>
this.translateReason(String(reason)),
@@ -490,13 +495,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
this.translateReason(String(warning)),
)
: [],
} as MatchPredictionDto['risk'],
} as MatchPredictionDto["risk"],
main_pick: mainPick,
value_pick: valuePick,
aggressive_pick: aggressivePick,
supporting_picks: supportingPicks,
bet_summary: betSummary,
bet_advice: betAdvice as MatchPredictionDto['bet_advice'],
bet_advice: betAdvice as MatchPredictionDto["bet_advice"],
market_board: enrichedMarketBoard,
reasoning_factors: reasoningFactors,
};
@@ -507,14 +512,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
prediction: Record<string, unknown>,
matchContext: MatchContext,
marketBoard: Record<string, unknown>,
): MatchPredictionDto['main_pick'] {
if (!pick || typeof pick !== 'object') {
): MatchPredictionDto["main_pick"] {
if (!pick || typeof pick !== "object") {
return null;
}
const record = this.asRecord(pick);
const market = String(record.market ?? '');
const pickName = String(record.pick ?? '');
const market = String(record.market ?? "");
const pickName = String(record.pick ?? "");
const probs = this.resolveMarketProbabilities(marketBoard, market);
const probability =
this.asNumber(record.probability) ||
@@ -538,7 +543,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
),
riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
lineupSource: String(
this.asRecord(prediction.data_quality).lineup_source ?? 'none',
this.asRecord(prediction.data_quality).lineup_source ?? "none",
),
isTopLeague: matchContext.isTopLeague,
});
@@ -547,10 +552,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
? [...record.decision_reasons]
: [];
if (!interval.threshold_met) {
nextReasons.push('confidence_interval_too_wide');
nextReasons.push("confidence_interval_too_wide");
}
if (interval.band === 'LOW') {
nextReasons.push('confidence_band_low');
if (interval.band === "LOW") {
nextReasons.push("confidence_band_low");
}
const displayOdds = this.normalizeDisplayOdds(
@@ -559,7 +564,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
);
return {
...(record as MatchPredictionDto['main_pick']),
...(record as MatchPredictionDto["main_pick"]),
market,
pick: pickName,
probability,
@@ -573,7 +578,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
odds: displayOdds,
edge: this.asNumber(record.edge),
play_score: this.asNumber(record.play_score),
bet_grade: String(record.bet_grade || 'PASS') as 'A' | 'B' | 'C' | 'PASS',
bet_grade: String(record.bet_grade || "PASS") as "A" | "B" | "C" | "PASS",
implied_prob: impliedProb,
ev_edge: evEdge,
playable: Boolean(record.playable) && interval.threshold_met,
@@ -594,10 +599,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
prediction: Record<string, unknown>,
matchContext: MatchContext,
marketBoard: Record<string, unknown>,
): MatchPredictionDto['bet_summary'][number] {
): MatchPredictionDto["bet_summary"][number] {
const record = this.asRecord(item);
const market = String(record.market ?? '');
const pickName = String(record.pick ?? '');
const market = String(record.market ?? "");
const pickName = String(record.pick ?? "");
const probs = this.resolveMarketProbabilities(marketBoard, market);
const probability = this.lookupProbability(probs, pickName);
const calibratedConfidence =
@@ -621,13 +626,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
),
riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
lineupSource: String(
this.asRecord(prediction.data_quality).lineup_source ?? 'none',
this.asRecord(prediction.data_quality).lineup_source ?? "none",
),
isTopLeague: matchContext.isTopLeague,
});
return {
...(record as MatchPredictionDto['bet_summary'][number]),
...(record as MatchPredictionDto["bet_summary"][number]),
odds: this.normalizeDisplayOdds(odds, impliedProb),
implied_prob: impliedProb,
ev_edge: evEdge,
@@ -658,12 +663,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private translateReason(reason: string): string {
if (!reason) {
return '';
return "";
}
const normalized = reason.startsWith('risk:')
? reason.slice(5)
: reason;
const normalized = reason.startsWith("risk:") ? reason.slice(5) : reason;
if (this.reasonTranslations[normalized]) {
return this.reasonTranslations[normalized];
@@ -674,7 +677,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`;
}
const negativeEdgeMatch = normalized.match(/^negative_model_edge_([+\-]?[\d.]+)$/);
const negativeEdgeMatch = normalized.match(
/^negative_model_edge_([+\-]?[\d.]+)$/,
);
if (negativeEdgeMatch) {
return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
}
@@ -692,47 +697,44 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private classifySignalTier(
record: Record<string, unknown>,
interval: {
band?: 'HIGH' | 'MEDIUM' | 'LOW';
band?: "HIGH" | "MEDIUM" | "LOW";
threshold_met?: boolean;
},
): 'CORE' | 'VALUE' | 'LEAN' | 'LONGSHOT' | 'PASS' {
const playable = Boolean(record.playable) && Boolean(interval.threshold_met);
): "CORE" | "VALUE" | "LEAN" | "LONGSHOT" | "PASS" {
const playable =
Boolean(record.playable) && Boolean(interval.threshold_met);
const calibratedConfidence = this.asNumber(record.calibrated_confidence);
const odds = this.asNumber(record.odds);
const evEdge = this.asNumber(record.ev_edge) || this.asNumber(record.edge);
const playScore = this.asNumber(record.play_score);
const band = String(interval.band ?? 'LOW').toUpperCase();
const band = String(interval.band ?? "LOW").toUpperCase();
if (
playable &&
band === 'HIGH' &&
band === "HIGH" &&
calibratedConfidence >= 72 &&
evEdge >= 0.02 &&
playScore >= 68
) {
return 'CORE';
return "CORE";
}
if (
calibratedConfidence >= 52 &&
odds >= 1.75 &&
evEdge >= 0.04
) {
return playable ? 'VALUE' : 'LONGSHOT';
if (calibratedConfidence >= 52 && odds >= 1.75 && evEdge >= 0.04) {
return playable ? "VALUE" : "LONGSHOT";
}
if (
calibratedConfidence >= 46 &&
(band === 'HIGH' || band === 'MEDIUM' || evEdge > 0)
(band === "HIGH" || band === "MEDIUM" || evEdge > 0)
) {
return 'LEAN';
return "LEAN";
}
if (odds >= 2.2 && calibratedConfidence >= 38) {
return 'LONGSHOT';
return "LONGSHOT";
}
return 'PASS';
return "PASS";
}
private estimateConfidenceInterval(input: {
@@ -755,7 +757,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const secondProb = sortedProbs[1] ?? 0;
const topProb = sortedProbs[0] ?? probability;
const margin = Math.max(0, topProb - secondProb);
const normalizedConfidence = this.normalizePercent(input.calibratedConfidence);
const normalizedConfidence = this.normalizePercent(
input.calibratedConfidence,
);
const baseWidthByMarket: Record<string, number> = {
MS: 0.18,
@@ -767,19 +771,19 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
};
const baseWidth = baseWidthByMarket[input.market] ?? 0.19;
const lineupPenalty =
input.lineupSource === 'confirmed_live'
input.lineupSource === "confirmed_live"
? -0.015
: input.lineupSource === 'probable_xi'
: input.lineupSource === "probable_xi"
? 0
: 0.02;
const width = this.clamp(
baseWidth
- margin * 0.22
- normalizedConfidence * 0.05
+ (1 - input.dataQualityScore) * 0.09
+ input.riskScore * 0.08
- (input.isTopLeague ? 0.012 : 0)
+ lineupPenalty,
baseWidth -
margin * 0.22 -
normalizedConfidence * 0.05 +
(1 - input.dataQualityScore) * 0.09 +
input.riskScore * 0.08 -
(input.isTopLeague ? 0.012 : 0) +
lineupPenalty,
0.08,
0.34,
);
@@ -795,17 +799,17 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
width <= this.getMaxAllowedWidth(input.market) &&
input.dataQualityScore >= 0.58 &&
input.evEdge >= this.getMinEdge(input.market) &&
(upper - input.impliedProb) >= 0.03;
upper - input.impliedProb >= 0.03;
let band: ConfidenceBand = 'LOW';
let band: ConfidenceBand = "LOW";
if (input.calibratedConfidence >= 69 && width <= 0.12 && margin >= 0.07) {
band = 'HIGH';
band = "HIGH";
} else if (
input.calibratedConfidence >= 58 &&
width <= 0.18 &&
margin >= 0.035
) {
band = 'MEDIUM';
band = "MEDIUM";
}
return {
@@ -864,7 +868,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
): Record<string, unknown> {
const entry = this.asRecord(marketBoard[market]);
const probs = entry.probs;
return probs && typeof probs === 'object'
return probs && typeof probs === "object"
? (probs as Record<string, unknown>)
: {};
}
@@ -895,7 +899,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private normalizeScore(value: unknown): number {
const numeric = this.asNumber(value);
return numeric > 1 ? this.clamp(numeric / 100, 0, 1) : this.clamp(numeric, 0, 1);
return numeric > 1
? this.clamp(numeric / 100, 0, 1)
: this.clamp(numeric, 0, 1);
}
private normalizePercent(value: number): number {
@@ -903,15 +909,15 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
private asRecord(value: unknown): Record<string, any> {
return value && typeof value === 'object'
return value && typeof value === "object"
? (value as Record<string, any>)
: {};
}
private asNumber(value: unknown): number {
return typeof value === 'number'
return typeof value === "number"
? value
: typeof value === 'string'
: typeof value === "string"
? Number(value) || 0
: 0;
}
@@ -922,7 +928,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async getValueBets(): Promise<ValueBetDto[]> {
const predictions = await this.prisma.prediction.findMany({
where: { match: { status: 'NS' } },
where: { match: { status: "NS" } },
include: { match: { include: { homeTeam: true, awayTeam: true } } },
});
@@ -937,14 +943,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
valueBets.push({
matchId: p.matchId,
matchName: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`,
betType: (vb.market || vb.betType || '') as string,
prediction: (vb.pick || vb.prediction || '') as string,
confidence: typeof vb.confidence === 'number' ? vb.confidence : 0,
odd: typeof vb.odd === 'number' ? vb.odd : 0,
betType: (vb.market || vb.betType || "") as string,
prediction: (vb.pick || vb.prediction || "") as string,
confidence: typeof vb.confidence === "number" ? vb.confidence : 0,
odd: typeof vb.odd === "number" ? vb.odd : 0,
expectedValue:
typeof vb.edge === 'number'
typeof vb.edge === "number"
? vb.edge
: typeof vb.expectedValue === 'number'
: typeof vb.expectedValue === "number"
? vb.expectedValue
: 0,
});
@@ -959,7 +965,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async getSmartCoupon(
matchIds: string[],
strategy: string = 'BALANCED',
strategy: string = "BALANCED",
options: { maxMatches?: number; minConfidence?: number } = {},
): Promise<any> {
await this.ensureSmartCouponDataReady(matchIds);
@@ -997,23 +1003,23 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private throwAiError(message: string): never {
if (
message.includes('timed out') ||
message.includes('AI_ENGINE_TIMEOUT') ||
message.includes('AI_ENGINE_504')
message.includes("timed out") ||
message.includes("AI_ENGINE_TIMEOUT") ||
message.includes("AI_ENGINE_504")
) {
throw new HttpException(
'Prediction request timed out',
"Prediction request timed out",
HttpStatus.GATEWAY_TIMEOUT,
);
}
if (message.includes('AI_ENGINE_502')) {
if (message.includes("AI_ENGINE_502")) {
throw new HttpException(
'AI Engine upstream returned 502',
"AI Engine upstream returned 502",
HttpStatus.BAD_GATEWAY,
);
}
throw new HttpException(
'Failed to get prediction from AI Engine',
"Failed to get prediction from AI Engine",
HttpStatus.SERVICE_UNAVAILABLE,
);
}
@@ -1066,12 +1072,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
const cached = prediction.predictionJson as Record<string, unknown>;
const modelVersion = cached['model_version'];
if (typeof modelVersion !== 'string') {
const modelVersion = cached["model_version"];
if (typeof modelVersion !== "string") {
return null;
}
if (!modelVersion.startsWith('v25')) {
if (!modelVersion.startsWith("v25")) {
return null;
}
@@ -1082,7 +1088,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const uniqueMatchIds = [...new Set(matchIds.filter((id) => !!id))];
if (uniqueMatchIds.length === 0) {
throw new HttpException(
'No matchIds provided for smart coupon generation',
"No matchIds provided for smart coupon generation",
HttpStatus.BAD_REQUEST,
);
}
@@ -1122,7 +1128,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const hasLiveOdds =
!!liveMatch?.odds &&
typeof liveMatch.odds === 'object' &&
typeof liveMatch.odds === "object" &&
!Array.isArray(liveMatch.odds) &&
Object.keys(liveMatch.odds as Record<string, unknown>).length > 0;
const matchExists = !!liveMatch?.id || !!persistedMatch?.id;
@@ -1146,9 +1152,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const isFinished =
hasScores ||
state === 'MS' ||
state === 'postGame' ||
['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'].includes(
state === "MS" ||
state === "postGame" ||
["Finished", "Played", "FT", "AET", "PEN", "Ended"].includes(
status as string,
);
@@ -1,18 +1,18 @@
/* eslint-disable @typescript-eslint/unbound-method */
import axios from 'axios';
import { PredictionJobType } from './predictions.types';
import { PredictionsProcessor } from './predictions.processor';
import axios from "axios";
import { PredictionJobType } from "./predictions.types";
import { PredictionsProcessor } from "./predictions.processor";
jest.mock('axios');
jest.mock("axios");
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('PredictionsProcessor', () => {
describe("PredictionsProcessor", () => {
let processor: PredictionsProcessor;
beforeEach(() => {
jest.clearAllMocks();
process.env.AI_ENGINE_URL = 'http://unit-ai:8000';
process.env.AI_ENGINE_URL = "http://unit-ai:8000";
processor = new PredictionsProcessor();
});
@@ -20,34 +20,34 @@ describe('PredictionsProcessor', () => {
delete process.env.AI_ENGINE_URL;
});
it('posts to analyze endpoint for predict-match jobs', async () => {
it("posts to analyze endpoint for predict-match jobs", async () => {
mockedAxios.post.mockResolvedValueOnce({ data: { ok: true } } as any);
const job = {
id: 'j1',
id: "j1",
name: PredictionJobType.PREDICT_MATCH,
data: { matchId: 'match-123' },
data: { matchId: "match-123" },
} as any;
const result = await processor.process(job);
expect(result).toEqual({ ok: true });
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://unit-ai:8000/v20plus/analyze/match-123',
"http://unit-ai:8000/v20plus/analyze/match-123",
{},
{ timeout: 30000 },
);
});
it('posts mapped payload to coupon endpoint for smart-coupon jobs', async () => {
it("posts mapped payload to coupon endpoint for smart-coupon jobs", async () => {
mockedAxios.post.mockResolvedValueOnce({ data: { bets: [] } } as any);
const job = {
id: 'j2',
id: "j2",
name: PredictionJobType.SMART_COUPON,
data: {
matchIds: ['m1', 'm2'],
strategy: 'BALANCED',
matchIds: ["m1", "m2"],
strategy: "BALANCED",
options: { maxMatches: 4, minConfidence: 65 },
},
} as any;
@@ -56,10 +56,10 @@ describe('PredictionsProcessor', () => {
expect(result).toEqual({ bets: [] });
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://unit-ai:8000/v20plus/coupon',
"http://unit-ai:8000/v20plus/coupon",
{
match_ids: ['m1', 'm2'],
strategy: 'BALANCED',
match_ids: ["m1", "m2"],
strategy: "BALANCED",
max_matches: 4,
min_confidence: 65,
},
@@ -67,15 +67,15 @@ describe('PredictionsProcessor', () => {
);
});
it('throws for unknown job type', async () => {
it("throws for unknown job type", async () => {
const job = {
id: 'j3',
name: 'unknown-job',
id: "j3",
name: "unknown-job",
data: {},
} as any;
await expect(processor.process(job)).rejects.toThrow(
'Unknown job type: unknown-job',
"Unknown job type: unknown-job",
);
});
});
@@ -1,14 +1,14 @@
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { Processor, WorkerHost } from "@nestjs/bullmq";
import { Logger } from "@nestjs/common";
import { Job } from "bullmq";
import {
PREDICTIONS_QUEUE,
PredictionJobType,
PredictMatchJobData,
SmartCouponJobData,
} from './predictions.types';
import axios from 'axios';
} from "./predictions.types";
import axios from "axios";
/**
* Predictions Processor
@@ -22,7 +22,7 @@ export class PredictionsProcessor extends WorkerHost {
constructor() {
super();
// Default to container service URL
this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000';
this.aiEngineUrl = process.env.AI_ENGINE_URL || "http://ai-engine:8000";
}
async process(job: Job<any, any, string>): Promise<any> {
@@ -56,7 +56,7 @@ export class PredictionsProcessor extends WorkerHost {
);
return response.data;
} catch (error) {
throw this.mapAxiosError(error, matchId, 'predict');
throw this.mapAxiosError(error, matchId, "predict");
}
}
@@ -81,14 +81,14 @@ export class PredictionsProcessor extends WorkerHost {
);
return response.data;
} catch (error) {
throw this.mapAxiosError(error, matchIds.join(','), 'smart-coupon');
throw this.mapAxiosError(error, matchIds.join(","), "smart-coupon");
}
}
private mapAxiosError(
error: unknown,
identifier: string,
flow: 'predict' | 'smart-coupon',
flow: "predict" | "smart-coupon",
): Error {
if (!axios.isAxiosError(error)) {
return error instanceof Error
@@ -98,7 +98,7 @@ export class PredictionsProcessor extends WorkerHost {
const status = error.response?.status;
const detail = error.response?.data?.detail || error.message;
const code = error.code || '';
const code = error.code || "";
if (status === 502) {
this.logger.error(`AI Engine 502 (${flow}:${identifier}): ${detail}`);
@@ -110,13 +110,13 @@ export class PredictionsProcessor extends WorkerHost {
return new Error(`AI_ENGINE_504|${flow}|${detail}`);
}
if (code === 'ECONNABORTED' || code === 'ETIMEDOUT') {
if (code === "ECONNABORTED" || code === "ETIMEDOUT") {
this.logger.error(`AI Engine timeout (${flow}:${identifier}): ${detail}`);
return new Error(`AI_ENGINE_TIMEOUT|${flow}|${detail}`);
}
this.logger.error(
`AI Engine error (${flow}:${identifier}) [${status ?? 'N/A'}]: ${detail}`,
`AI Engine error (${flow}:${identifier}) [${status ?? "N/A"}]: ${detail}`,
);
return new Error(`AI_ENGINE_ERROR|${flow}|${detail}`);
}
@@ -1,12 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { Injectable, Logger } from "@nestjs/common";
import { InjectQueue } from "@nestjs/bullmq";
import { Queue } from "bullmq";
import {
PREDICTIONS_QUEUE,
PredictionJobType,
PredictMatchJobData,
SmartCouponJobData,
} from './predictions.types';
} from "./predictions.types";
@Injectable()
export class PredictionsQueue {
@@ -3,11 +3,11 @@
* Senior Level Strict Typing
*/
export const PREDICTIONS_QUEUE = 'predictions-queue';
export const PREDICTIONS_QUEUE = "predictions-queue";
export enum PredictionJobType {
PREDICT_MATCH = 'predict-match',
SMART_COUPON = 'smart-coupon',
PREDICT_MATCH = "predict-match",
SMART_COUPON = "smart-coupon",
}
export interface PredictMatchJobData {
@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../database/prisma.service';
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../../database/prisma.service";
@Injectable()
export class AiFeatureStoreService {
@@ -16,10 +16,10 @@ export class AiFeatureStoreService {
where: { id: matchId },
include: {
homeTeam: {
include: { homeMatches: { take: 5, orderBy: { mstUtc: 'desc' } } },
include: { homeMatches: { take: 5, orderBy: { mstUtc: "desc" } } },
},
awayTeam: {
include: { awayMatches: { take: 5, orderBy: { mstUtc: 'desc' } } },
include: { awayMatches: { take: 5, orderBy: { mstUtc: "desc" } } },
},
},
});
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { GeminiService } from '../gemini/gemini.service';
import { PredictionCardDto } from './dto/prediction-card.dto';
import { Injectable, Logger } from "@nestjs/common";
import { GeminiService } from "../gemini/gemini.service";
import { PredictionCardDto } from "./dto/prediction-card.dto";
const SYSTEM_PROMPT = `Sen profesyonel bir spor analisti ve sosyal medya içerik üreticisisin.
Verilen maç tahmin verisini kullanarak kısa, etkili ve ilgi çekici sosyal medya postları yazıyorsun.
@@ -28,7 +28,7 @@ export class CaptionGeneratorService {
*/
async generateCaption(card: PredictionCardDto): Promise<string> {
if (!this.geminiService.isAvailable()) {
this.logger.warn('Gemini not available, using template caption');
this.logger.warn("Gemini not available, using template caption");
return this.generateFallbackCaption(card);
}
@@ -48,7 +48,7 @@ export class CaptionGeneratorService {
);
return caption;
} catch (error) {
this.logger.error('Gemini caption generation failed', error);
this.logger.error("Gemini caption generation failed", error);
return this.generateFallbackCaption(card);
}
}
@@ -59,7 +59,7 @@ export class CaptionGeneratorService {
(p, i) =>
`${i + 1}. ${p.market} (${p.marketEn}) — ${p.pick} — Güven: %${p.confidence} — Oran: ${p.odds}`,
)
.join('\n');
.join("\n");
return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur:
@@ -79,12 +79,12 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`;
private ensureHashtags(text: string, card: PredictionCardDto): string {
// If no hashtags in text, add them
if (!text.includes('#')) {
if (!text.includes("#")) {
const leagueTag = card.leagueName
.replace(/\s+/g, '')
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, '');
const homeTag = card.homeTeam.replace(/\s+/g, '');
const awayTag = card.awayTeam.replace(/\s+/g, '');
.replace(/\s+/g, "")
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
const homeTag = card.homeTeam.replace(/\s+/g, "");
const awayTag = card.awayTeam.replace(/\s+/g, "");
text += `\n\n#${leagueTag} #${homeTag} #${awayTag}`;
}
return text.trim();
@@ -96,13 +96,13 @@ Sadece post metnini yaz, başka hiçbir şey ekleme.`;
private generateFallbackCaption(card: PredictionCardDto): string {
const topPick = card.topPicks[0];
const leagueTag = card.leagueName
.replace(/\s+/g, '')
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, '');
.replace(/\s+/g, "")
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
return `${card.homeTeam} vs ${card.awayTeam}
🎯 Tahminimiz: ${card.ftScore} (İY: ${card.htScore})
📊 Güven: %${card.scoreConfidence}
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ''}
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ""}
#${leagueTag} #SuggestBet #Bahis`.trim();
}
@@ -42,7 +42,7 @@ export interface PredictionCardDto {
topPicks: TopPick[];
// ─── Risk ───
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
riskLevel: "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
// ─── Raw prediction JSON (for Gemini caption) ───
rawPrediction?: Record<string, any>;
@@ -1,17 +1,17 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { createCanvas, loadImage } from 'canvas';
import { PredictionCardDto } from './dto/prediction-card.dto';
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import * as fs from "fs";
import * as path from "path";
import axios from "axios";
import { createCanvas, loadImage } from "canvas";
import { PredictionCardDto } from "./dto/prediction-card.dto";
@Injectable()
export class ImageRendererService implements OnModuleInit {
private readonly logger = new Logger(ImageRendererService.name);
private readonly outputDir = path.join(
process.cwd(),
'public',
'predictions',
"public",
"predictions",
);
onModuleInit() {
@@ -53,8 +53,8 @@ export class ImageRendererService implements OnModuleInit {
try {
// Case 1: Local relative path → read from public/ directory
if (url.startsWith('/')) {
const localPath = path.join(process.cwd(), 'public', url);
if (url.startsWith("/")) {
const localPath = path.join(process.cwd(), "public", url);
if (fs.existsSync(localPath)) {
this.logger.debug(`Loading logo from local file: ${localPath}`);
return await loadImage(localPath);
@@ -66,9 +66,9 @@ export class ImageRendererService implements OnModuleInit {
}
// Case 2: Full HTTP/HTTPS URL → fetch directly
if (url.startsWith('http')) {
if (url.startsWith("http")) {
const response = await axios.get(url, {
responseType: 'arraybuffer',
responseType: "arraybuffer",
timeout: 5000,
});
return await loadImage(response.data);
@@ -133,14 +133,14 @@ export class ImageRendererService implements OnModuleInit {
const width = 1080;
const height = 1920;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
// Background Gradient
const bgGrad = ctx.createLinearGradient(0, 0, width, height);
bgGrad.addColorStop(0, '#0a0e27');
bgGrad.addColorStop(0.35, '#1a1040');
bgGrad.addColorStop(0.7, '#0d1b2a');
bgGrad.addColorStop(1, '#0a0e27');
bgGrad.addColorStop(0, "#0a0e27");
bgGrad.addColorStop(0.35, "#1a1040");
bgGrad.addColorStop(0.7, "#0d1b2a");
bgGrad.addColorStop(1, "#0a0e27");
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
@@ -148,12 +148,12 @@ export class ImageRendererService implements OnModuleInit {
ctx.save();
ctx.translate(width / 2, height / 2);
ctx.rotate((-35 * Math.PI) / 180);
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)';
ctx.font = '900 100px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = "rgba(255, 255, 255, 0.05)";
ctx.font = "900 100px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const wmLine =
'iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com';
"iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com";
for (let i = -15; i <= 15; i++) {
ctx.fillText(wmLine, 0, i * 180);
}
@@ -163,14 +163,14 @@ export class ImageRendererService implements OnModuleInit {
const paddingX = 80;
// Header
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.font = '600 28px sans-serif';
ctx.textAlign = 'left';
ctx.fillStyle = "rgba(255, 255, 255, 0.7)";
ctx.font = "600 28px sans-serif";
ctx.textAlign = "left";
ctx.fillText(data.leagueName.toUpperCase(), paddingX, 120);
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
ctx.font = '400 22px sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = "rgba(255, 255, 255, 0.45)";
ctx.font = "400 22px sans-serif";
ctx.textAlign = "right";
ctx.fillText(data.matchDate, width - paddingX, 120);
// Teams Section
@@ -184,31 +184,31 @@ export class ImageRendererService implements OnModuleInit {
if (awayImg)
ctx.drawImage(awayImg, (width / 4) * 3 - 100, currentY, 200, 200);
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
ctx.font = '900 56px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('VS', width / 2, currentY + 110);
ctx.fillStyle = "rgba(255, 255, 255, 0.15)";
ctx.font = "900 56px sans-serif";
ctx.textAlign = "center";
ctx.fillText("VS", width / 2, currentY + 110);
currentY += 250;
ctx.fillStyle = '#ffffff';
ctx.font = '700 36px sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = "#ffffff";
ctx.font = "700 36px sans-serif";
ctx.textAlign = "center";
ctx.fillText(data.homeTeam, width / 4, currentY);
ctx.fillText(data.awayTeam, (width / 4) * 3, currentY);
// Divider: Skore Prediction
currentY += 140;
const drawSectionTitle = (y: number, text: string) => {
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.font = '600 22px sans-serif';
ctx.textAlign = "center";
ctx.fillStyle = "rgba(255, 255, 255, 0.5)";
ctx.font = "600 22px sans-serif";
ctx.fillText(text, width / 2, y + 8);
const txtWidth = ctx.measureText(text).width;
const grad = ctx.createLinearGradient(paddingX, y, width - paddingX, y);
grad.addColorStop(0, 'rgba(120, 80, 255, 0)');
grad.addColorStop(0.5, 'rgba(120, 80, 255, 0.6)');
grad.addColorStop(1, 'rgba(120, 80, 255, 0)');
grad.addColorStop(0, "rgba(120, 80, 255, 0)");
grad.addColorStop(0.5, "rgba(120, 80, 255, 0.6)");
grad.addColorStop(1, "rgba(120, 80, 255, 0)");
ctx.fillStyle = grad;
ctx.fillRect(
@@ -225,7 +225,7 @@ export class ImageRendererService implements OnModuleInit {
);
};
drawSectionTitle(currentY, 'SKOR TAHMİNİ / SCORE PREDICTION');
drawSectionTitle(currentY, "SKOR TAHMİNİ / SCORE PREDICTION");
// Scores
currentY += 80;
@@ -235,20 +235,20 @@ export class ImageRendererService implements OnModuleInit {
const ftX = width / 2 + 24;
// HT Box
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
ctx.fillStyle = "rgba(255, 255, 255, 0.04)";
ctx.strokeStyle = "rgba(255, 255, 255, 0.08)";
ctx.lineWidth = 2;
this.fillRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
this.strokeRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
ctx.font = '600 20px sans-serif';
ctx.fillText('İLK YARI', htX + scoreBoxWidth / 2, currentY + 40);
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
ctx.font = '400 16px sans-serif';
ctx.fillText('Half Time', htX + scoreBoxWidth / 2, currentY + 65);
ctx.fillStyle = '#ffffff';
ctx.font = '900 80px sans-serif';
ctx.fillStyle = "rgba(255, 255, 255, 0.45)";
ctx.font = "600 20px sans-serif";
ctx.fillText("İLK YARI", htX + scoreBoxWidth / 2, currentY + 40);
ctx.fillStyle = "rgba(255, 255, 255, 0.25)";
ctx.font = "400 16px sans-serif";
ctx.fillText("Half Time", htX + scoreBoxWidth / 2, currentY + 65);
ctx.fillStyle = "#ffffff";
ctx.font = "900 80px sans-serif";
ctx.fillText(data.htScore, htX + scoreBoxWidth / 2, currentY + 160);
// FT Box
@@ -258,19 +258,19 @@ export class ImageRendererService implements OnModuleInit {
ftX + scoreBoxWidth,
currentY + scoreBoxHeight,
);
ftGrad.addColorStop(0, 'rgba(120, 80, 255, 0.15)');
ftGrad.addColorStop(1, 'rgba(0, 200, 255, 0.1)');
ftGrad.addColorStop(0, "rgba(120, 80, 255, 0.15)");
ftGrad.addColorStop(1, "rgba(0, 200, 255, 0.1)");
ctx.fillStyle = ftGrad;
ctx.strokeStyle = 'rgba(120, 80, 255, 0.3)';
ctx.strokeStyle = "rgba(120, 80, 255, 0.3)";
this.fillRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
this.strokeRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20);
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
ctx.font = '600 20px sans-serif';
ctx.fillText('MAÇ SONU', ftX + scoreBoxWidth / 2, currentY + 40);
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
ctx.font = '400 16px sans-serif';
ctx.fillText('Full Time', ftX + scoreBoxWidth / 2, currentY + 65);
ctx.fillStyle = "rgba(255, 255, 255, 0.45)";
ctx.font = "600 20px sans-serif";
ctx.fillText("MAÇ SONU", ftX + scoreBoxWidth / 2, currentY + 40);
ctx.fillStyle = "rgba(255, 255, 255, 0.25)";
ctx.font = "400 16px sans-serif";
ctx.fillText("Full Time", ftX + scoreBoxWidth / 2, currentY + 65);
// Score text gradient
const txtGrad = ctx.createLinearGradient(
@@ -279,15 +279,15 @@ export class ImageRendererService implements OnModuleInit {
ftX,
currentY + 160,
);
txtGrad.addColorStop(0, '#9b6fff');
txtGrad.addColorStop(1, '#00c8ff');
txtGrad.addColorStop(0, "#9b6fff");
txtGrad.addColorStop(1, "#00c8ff");
ctx.fillStyle = txtGrad;
ctx.font = '900 80px sans-serif';
ctx.font = "900 80px sans-serif";
ctx.fillText(data.ftScore, ftX + scoreBoxWidth / 2, currentY + 160);
// Confidence badge
ctx.fillStyle = '#0a0e27';
ctx.strokeStyle = 'rgba(120, 80, 255, 0.6)';
ctx.fillStyle = "#0a0e27";
ctx.strokeStyle = "rgba(120, 80, 255, 0.6)";
this.fillRoundRect(
ctx,
ftX + scoreBoxWidth / 2 - 80,
@@ -304,8 +304,8 @@ export class ImageRendererService implements OnModuleInit {
40,
20,
);
ctx.fillStyle = '#b89dff';
ctx.font = '800 20px sans-serif';
ctx.fillStyle = "#b89dff";
ctx.font = "800 20px sans-serif";
ctx.fillText(
`🎯 %${data.scoreConfidence}`,
ftX + scoreBoxWidth / 2,
@@ -314,13 +314,13 @@ export class ImageRendererService implements OnModuleInit {
// Divider: Picks
currentY += scoreBoxHeight + 100;
drawSectionTitle(currentY, 'EN İYİ TAHMİNLER / BEST PICKS');
drawSectionTitle(currentY, "EN İYİ TAHMİNLER / BEST PICKS");
// Picks rendering
currentY += 80;
data.topPicks.forEach((pick, index) => {
ctx.fillStyle = 'rgba(255, 255, 255, 0.03)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
ctx.fillStyle = "rgba(255, 255, 255, 0.03)";
ctx.strokeStyle = "rgba(255, 255, 255, 0.06)";
this.fillRoundRect(
ctx,
paddingX,
@@ -338,18 +338,18 @@ export class ImageRendererService implements OnModuleInit {
16,
);
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.font = '700 28px sans-serif';
ctx.textAlign = 'left';
ctx.fillStyle = "rgba(255, 255, 255, 0.3)";
ctx.font = "700 28px sans-serif";
ctx.textAlign = "left";
ctx.fillText(String(index + 1), paddingX + 30, currentY + 58);
ctx.fillStyle = '#ffffff';
ctx.font = '600 26px sans-serif';
ctx.fillStyle = "#ffffff";
ctx.font = "600 26px sans-serif";
ctx.fillText(pick.market, paddingX + 80, currentY + 45);
const marketWidth = ctx.measureText(pick.market).width;
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
ctx.font = '400 18px sans-serif';
ctx.fillStyle = "rgba(255, 255, 255, 0.35)";
ctx.font = "400 18px sans-serif";
ctx.fillText(
`(${pick.marketEn})`,
paddingX + 80 + marketWidth + 10,
@@ -357,7 +357,7 @@ export class ImageRendererService implements OnModuleInit {
);
// Pick Bar bg
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
ctx.fillStyle = "rgba(255, 255, 255, 0.06)";
const barMaxWidth = width - 2 * paddingX - 220;
this.fillRoundRect(ctx, paddingX + 80, currentY + 65, barMaxWidth, 12, 6);
@@ -369,15 +369,15 @@ export class ImageRendererService implements OnModuleInit {
paddingX + 80 + barMaxWidth,
0,
);
barGrad.addColorStop(0, '#7850ff');
barGrad.addColorStop(1, '#00c8ff');
barGrad.addColorStop(0, "#7850ff");
barGrad.addColorStop(1, "#00c8ff");
ctx.fillStyle = barGrad;
this.fillRoundRect(ctx, paddingX + 80, currentY + 65, fillWidth, 12, 6);
// Confidence text
ctx.fillStyle = '#b89dff';
ctx.font = '900 32px sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = "#b89dff";
ctx.font = "900 32px sans-serif";
ctx.textAlign = "right";
ctx.fillText(`%${pick.confidence}`, width - paddingX - 30, currentY + 58);
currentY += 124;
@@ -385,41 +385,41 @@ export class ImageRendererService implements OnModuleInit {
// Footer
currentY = height - 80;
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
ctx.font = '700 26px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('⚡ AI Powered by SuggestBet', paddingX, currentY);
ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
ctx.font = "700 26px sans-serif";
ctx.textAlign = "left";
ctx.fillText("⚡ AI Powered by SuggestBet", paddingX, currentY);
let riskBg, riskColor, riskBorder;
switch (data.riskLevel) {
case 'LOW':
riskBg = 'rgba(0, 200, 100, 0.15)';
riskColor = '#4ade80';
riskBorder = 'rgba(0, 200, 100, 0.3)';
case "LOW":
riskBg = "rgba(0, 200, 100, 0.15)";
riskColor = "#4ade80";
riskBorder = "rgba(0, 200, 100, 0.3)";
break;
case 'MEDIUM':
riskBg = 'rgba(255, 200, 0, 0.12)';
riskColor = '#fbbf24';
riskBorder = 'rgba(255, 200, 0, 0.25)';
case "MEDIUM":
riskBg = "rgba(255, 200, 0, 0.12)";
riskColor = "#fbbf24";
riskBorder = "rgba(255, 200, 0, 0.25)";
break;
case 'HIGH':
riskBg = 'rgba(255, 100, 50, 0.12)';
riskColor = '#f97316';
riskBorder = 'rgba(255, 100, 50, 0.25)';
case "HIGH":
riskBg = "rgba(255, 100, 50, 0.12)";
riskColor = "#f97316";
riskBorder = "rgba(255, 100, 50, 0.25)";
break;
case 'EXTREME':
riskBg = 'rgba(255, 50, 50, 0.15)';
riskColor = '#ef4444';
riskBorder = 'rgba(255, 50, 50, 0.3)';
case "EXTREME":
riskBg = "rgba(255, 50, 50, 0.15)";
riskColor = "#ef4444";
riskBorder = "rgba(255, 50, 50, 0.3)";
break;
default:
riskBg = 'rgba(255, 255, 255, 0.1)';
riskColor = '#ffffff';
riskBorder = 'rgba(255, 255, 255, 0.3)';
riskBg = "rgba(255, 255, 255, 0.1)";
riskColor = "#ffffff";
riskBorder = "rgba(255, 255, 255, 0.3)";
}
const riskText = `RISK: ${data.riskLevel}`;
ctx.font = '800 20px sans-serif';
ctx.font = "800 20px sans-serif";
const riskWidth = ctx.measureText(riskText).width;
ctx.fillStyle = riskBg;
ctx.strokeStyle = riskBorder;
@@ -441,11 +441,11 @@ export class ImageRendererService implements OnModuleInit {
);
ctx.fillStyle = riskColor;
ctx.textAlign = 'center';
ctx.textAlign = "center";
ctx.fillText(riskText, width - paddingX - riskWidth / 2 - 24, currentY + 3);
// Save Output directly using the buffer
const buffer = canvas.toBuffer('image/png');
const buffer = canvas.toBuffer("image/png");
fs.writeFileSync(outPath, buffer);
}
@@ -454,9 +454,9 @@ export class ImageRendererService implements OnModuleInit {
*/
getImageUrl(filePath: string): string {
const relativePath = path.relative(
path.join(process.cwd(), 'public'),
path.join(process.cwd(), "public"),
filePath,
);
return `/${relativePath.replace(/\\/g, '/')}`;
return `/${relativePath.replace(/\\/g, "/")}`;
}
}
+18 -18
View File
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import axios from "axios";
@Injectable()
export class MetaService {
@@ -10,21 +10,21 @@ export class MetaService {
private readonly pageId: string;
private readonly igUserId: string;
private readonly isEnabled: boolean;
private readonly graphApiBase = 'https://graph.facebook.com/v21.0';
private readonly graphApiBase = "https://graph.facebook.com/v21.0";
constructor(private readonly configService: ConfigService) {
this.pageAccessToken =
this.configService.get<string>('META_PAGE_ACCESS_TOKEN') || '';
this.pageId = this.configService.get<string>('META_PAGE_ID') || '';
this.igUserId = this.configService.get<string>('META_IG_USER_ID') || '';
this.configService.get<string>("META_PAGE_ACCESS_TOKEN") || "";
this.pageId = this.configService.get<string>("META_PAGE_ID") || "";
this.igUserId = this.configService.get<string>("META_IG_USER_ID") || "";
this.isEnabled = !!(this.pageAccessToken && this.pageId);
if (this.isEnabled) {
this.logger.log('✅ Meta API client initialized');
this.logger.log("✅ Meta API client initialized");
} else {
this.logger.warn(
'⚠️ Meta API not configured. Set META_PAGE_ACCESS_TOKEN, META_PAGE_ID, META_IG_USER_ID',
"⚠️ Meta API not configured. Set META_PAGE_ACCESS_TOKEN, META_PAGE_ID, META_IG_USER_ID",
);
}
}
@@ -53,7 +53,7 @@ export class MetaService {
imageUrl: string,
): Promise<string | null> {
if (!this.facebookAvailable) {
this.logger.warn('Facebook not available, skipping post');
this.logger.warn("Facebook not available, skipping post");
return null;
}
@@ -98,7 +98,7 @@ export class MetaService {
imageUrl: string,
): Promise<string | null> {
if (!this.instagramAvailable) {
this.logger.warn('Instagram not available, skipping post');
this.logger.warn("Instagram not available, skipping post");
return null;
}
@@ -115,7 +115,7 @@ export class MetaService {
const containerId = containerResponse.data?.id;
if (!containerId) {
throw new Error('No container ID returned');
throw new Error("No container ID returned");
}
// Wait for container processing (IG needs a few seconds)
@@ -156,25 +156,25 @@ export class MetaService {
`${this.graphApiBase}/${containerId}`,
{
params: {
fields: 'status_code',
fields: "status_code",
access_token: this.pageAccessToken,
},
},
);
const status = response.data?.status_code;
if (status === 'FINISHED') return;
if (status === 'ERROR') {
throw new Error('Container processing failed');
if (status === "FINISHED") return;
if (status === "ERROR") {
throw new Error("Container processing failed");
}
} catch (error) {
if (error.message === 'Container processing failed') throw error;
if (error.message === "Container processing failed") throw error;
}
// Wait 2 seconds before checking again
await new Promise((resolve) => setTimeout(resolve, 2000));
}
this.logger.warn('Container wait timed out, attempting publish anyway');
this.logger.warn("Container wait timed out, attempting publish anyway");
}
}
@@ -1,25 +1,25 @@
import { Controller, Post, Param, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Controller, Post, Param, Get, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { SocialPosterService } from './social-poster.service';
import { Roles } from '../../common/decorators';
import { RolesGuard } from '../auth/guards/auth.guards';
import { SocialPosterService } from "./social-poster.service";
import { Roles } from "../../common/decorators";
import { RolesGuard } from "../auth/guards/auth.guards";
@ApiTags('Social Poster')
@ApiTags("Social Poster")
@ApiBearerAuth()
@UseGuards(RolesGuard)
@Roles('admin')
@Controller('social-poster')
@Roles("admin")
@Controller("social-poster")
export class SocialPosterController {
constructor(private readonly socialPosterService: SocialPosterService) {}
@Get('preview/:matchId')
async previewCard(@Param('matchId') matchId: string) {
@Get("preview/:matchId")
async previewCard(@Param("matchId") matchId: string) {
return this.socialPosterService.renderPreview(matchId);
}
@Post('post/:matchId')
async postMatch(@Param('matchId') matchId: string) {
@Post("post/:matchId")
async postMatch(@Param("matchId") matchId: string) {
return this.socialPosterService.manualPost(matchId);
}
}
@@ -1,14 +1,14 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule";
import { SocialPosterService } from './social-poster.service';
import { ImageRendererService } from './image-renderer.service';
import { CaptionGeneratorService } from './caption-generator.service';
import { TwitterService } from './twitter.service';
import { MetaService } from './meta.service';
import { SocialPosterService } from "./social-poster.service";
import { ImageRendererService } from "./image-renderer.service";
import { CaptionGeneratorService } from "./caption-generator.service";
import { TwitterService } from "./twitter.service";
import { MetaService } from "./meta.service";
import { SocialPosterController } from './social-poster.controller';
import { SocialPosterController } from "./social-poster.controller";
/**
* Social Poster Module
@@ -1,24 +1,24 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../database/prisma.service';
import axios from 'axios';
import * as fs from 'fs';
import * as path from 'path';
import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { ConfigService } from "@nestjs/config";
import { PrismaService } from "../../database/prisma.service";
import axios from "axios";
import * as fs from "fs";
import * as path from "path";
import { ImageRendererService } from './image-renderer.service';
import { CaptionGeneratorService } from './caption-generator.service';
import { TwitterService } from './twitter.service';
import { MetaService } from './meta.service';
import { ImageRendererService } from "./image-renderer.service";
import { CaptionGeneratorService } from "./caption-generator.service";
import { TwitterService } from "./twitter.service";
import { MetaService } from "./meta.service";
import {
PredictionCardDto,
TopPick,
SocialPostResult,
} from './dto/prediction-card.dto';
} from "./dto/prediction-card.dto";
// Top leagues loaded once
const TOP_LEAGUES_PATH = path.join(process.cwd(), 'top_leagues.json');
const TOP_LEAGUES_PATH = path.join(process.cwd(), "top_leagues.json");
@Injectable()
export class SocialPosterService {
@@ -38,24 +38,24 @@ export class SocialPosterService {
private readonly metaService: MetaService,
) {
this.aiEngineUrl =
this.configService.get<string>('AI_ENGINE_URL') ||
'http://localhost:8000';
this.configService.get<string>("AI_ENGINE_URL") ||
"http://localhost:8000";
this.appBaseUrl =
this.configService.get<string>('APP_BASE_URL') || 'http://localhost:3000';
this.configService.get<string>("APP_BASE_URL") || "http://localhost:3000";
this.isEnabled =
this.configService.get<string>('SOCIAL_POSTER_ENABLED') === 'true';
this.configService.get<string>("SOCIAL_POSTER_ENABLED") === "true";
this.loadTopLeagues();
}
private loadTopLeagues() {
try {
const data = fs.readFileSync(TOP_LEAGUES_PATH, 'utf-8');
const data = fs.readFileSync(TOP_LEAGUES_PATH, "utf-8");
const ids = JSON.parse(data);
this.topLeagueIds = new Set(ids);
this.logger.log(`✅ Loaded ${this.topLeagueIds.size} top league IDs`);
} catch {
this.logger.warn('⚠️ Could not load top_leagues.json');
this.logger.warn("⚠️ Could not load top_leagues.json");
}
}
@@ -63,7 +63,7 @@ export class SocialPosterService {
* Cron: Every 10 minutes, check for upcoming matches.
* Posts predictions 30 minutes before kickoff.
*/
@Cron('*/10 * * * *')
@Cron("*/10 * * * *")
async checkAndPostUpcomingMatches() {
if (!this.isEnabled) return;
@@ -115,7 +115,7 @@ export class SocialPosterService {
const matches = await this.prisma.liveMatch.findMany({
where: {
sport: 'football',
sport: "football",
leagueId: { in: Array.from(this.topLeagueIds) },
mstUtc: {
gte: minTime,
@@ -144,7 +144,7 @@ export class SocialPosterService {
// Step 1: Get prediction from AI Engine
const prediction = await this.getPrediction(matchId);
if (!prediction) {
throw new Error('No prediction returned from AI Engine');
throw new Error("No prediction returned from AI Engine");
}
// Step 2: Build prediction card data
@@ -194,9 +194,9 @@ export class SocialPosterService {
this.logger.log(
`✅ Posted: ${match.homeTeam?.name} vs ${match.awayTeam?.name} ` +
`[TW: ${result.twitterPostId ? '✅' : '❌'}, ` +
`FB: ${result.facebookPostId ? '✅' : '❌'}, ` +
`IG: ${result.instagramPostId ? '✅' : '❌'}]`,
`[TW: ${result.twitterPostId ? "✅" : "❌"}, ` +
`FB: ${result.facebookPostId ? "✅" : "❌"}, ` +
`IG: ${result.instagramPostId ? "✅" : "❌"}]`,
);
return result;
@@ -229,8 +229,8 @@ export class SocialPosterService {
): PredictionCardDto {
// V20+ returns score_prediction.ft / .ht
const score = prediction.score_prediction || {};
const htScore = score.ht || '0-0';
const ftScore = score.ft || '1-1';
const htScore = score.ht || "0-0";
const ftScore = score.ft || "1-1";
// Extract best bets from bet_summary array
const topPicks = this.extractTopPicks(prediction);
@@ -247,18 +247,18 @@ export class SocialPosterService {
return {
matchId: match.id,
homeTeam:
match.homeTeam?.name || prediction.match_info?.home_team || 'Home',
match.homeTeam?.name || prediction.match_info?.home_team || "Home",
awayTeam:
match.awayTeam?.name || prediction.match_info?.away_team || 'Away',
homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ''),
awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ''),
leagueName: match.league?.name || prediction.match_info?.league || '',
match.awayTeam?.name || prediction.match_info?.away_team || "Away",
homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ""),
awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ""),
leagueName: match.league?.name || prediction.match_info?.league || "",
matchDate,
htScore,
ftScore,
scoreConfidence,
topPicks,
riskLevel: prediction.risk?.level || 'MEDIUM',
riskLevel: prediction.risk?.level || "MEDIUM",
rawPrediction: prediction,
};
}
@@ -271,16 +271,16 @@ export class SocialPosterService {
// Market code to Turkish/English label mapping
const marketLabels: Record<string, { tr: string; en: string }> = {
MS: { tr: 'Maç Sonucu', en: 'Match Result' },
OU15: { tr: 'Üst 1.5 Gol', en: 'Over 1.5' },
OU25: { tr: 'Üst 2.5 Gol', en: 'Over 2.5' },
OU35: { tr: 'Üst 3.5 Gol', en: 'Over 3.5' },
BTTS: { tr: 'Karşılıklı Gol', en: 'Both Teams Score' },
DC: { tr: 'Çifte Şans', en: 'Double Chance' },
HT: { tr: 'İlk Yarı Sonucu', en: 'Half Time Result' },
HT_OU05: { tr: 'İY 0.5 Üst/Alt', en: 'HT Over/Under 0.5' },
OE: { tr: 'Tek/Çift', en: 'Odd/Even' },
HTFT: { tr: 'İY/MS', en: 'HT/FT' },
MS: { tr: "Maç Sonucu", en: "Match Result" },
OU15: { tr: "Üst 1.5 Gol", en: "Over 1.5" },
OU25: { tr: "Üst 2.5 Gol", en: "Over 2.5" },
OU35: { tr: "Üst 3.5 Gol", en: "Over 3.5" },
BTTS: { tr: "Karşılıklı Gol", en: "Both Teams Score" },
DC: { tr: "Çifte Şans", en: "Double Chance" },
HT: { tr: "İlk Yarı Sonucu", en: "Half Time Result" },
HT_OU05: { tr: "İY 0.5 Üst/Alt", en: "HT Over/Under 0.5" },
OE: { tr: "Tek/Çift", en: "Odd/Even" },
HTFT: { tr: "İY/MS", en: "HT/FT" },
};
const candidates: TopPick[] = betSummary.map((bet) => {
@@ -308,11 +308,11 @@ export class SocialPosterService {
* Locally during dev, we fetch them from the deployed server via APP_BASE_URL.
*/
private resolveLogoUrl(logoUrl: string): string {
if (!logoUrl) return '';
if (!logoUrl) return "";
// Already a full URL
if (logoUrl.startsWith('http')) return logoUrl;
if (logoUrl.startsWith("http")) return logoUrl;
// Relative path → check local first, otherwise make full URL
const localPath = path.join(process.cwd(), 'public', logoUrl);
const localPath = path.join(process.cwd(), "public", logoUrl);
if (fs.existsSync(localPath)) return logoUrl; // Keep relative, renderer reads local
// Not local → prepend base URL for remote fetch
return `${this.appBaseUrl}${logoUrl}`;
@@ -321,24 +321,24 @@ export class SocialPosterService {
private formatMatchDate(mstUtc: number | bigint): string {
const d = new Date(Number(mstUtc));
const months = [
'Oca',
'Şub',
'Mar',
'Nis',
'May',
'Haz',
'Tem',
'Ağu',
'Eyl',
'Eki',
'Kas',
'Ara',
"Oca",
"Şub",
"Mar",
"Nis",
"May",
"Haz",
"Tem",
"Ağu",
"Eyl",
"Eki",
"Kas",
"Ara",
];
const day = String(d.getDate()).padStart(2, '0');
const day = String(d.getDate()).padStart(2, "0");
const month = months[d.getMonth()];
const year = d.getFullYear();
const hour = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
const hour = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
return `${day} ${month} ${year} - ${hour}:${min}`;
}
@@ -383,7 +383,7 @@ export class SocialPosterService {
const prediction = await this.getPrediction(matchId);
if (!prediction) {
throw new Error('No prediction returned from AI Engine');
throw new Error("No prediction returned from AI Engine");
}
const card = this.buildCardFromPrediction(match, prediction);
+13 -13
View File
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as fs from "fs";
@Injectable()
export class TwitterService {
@@ -9,18 +9,18 @@ export class TwitterService {
private isEnabled = false;
constructor(private readonly configService: ConfigService) {
const apiKey = this.configService.get<string>('TWITTER_API_KEY');
const apiSecret = this.configService.get<string>('TWITTER_API_SECRET');
const accessToken = this.configService.get<string>('TWITTER_ACCESS_TOKEN');
const apiKey = this.configService.get<string>("TWITTER_API_KEY");
const apiSecret = this.configService.get<string>("TWITTER_API_SECRET");
const accessToken = this.configService.get<string>("TWITTER_ACCESS_TOKEN");
const accessSecret = this.configService.get<string>(
'TWITTER_ACCESS_SECRET',
"TWITTER_ACCESS_SECRET",
);
if (apiKey && apiSecret && accessToken && accessSecret) {
void this.initClient(apiKey, apiSecret, accessToken, accessSecret);
} else {
this.logger.warn(
'⚠️ Twitter API keys not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET',
"⚠️ Twitter API keys not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET",
);
}
}
@@ -32,7 +32,7 @@ export class TwitterService {
accessSecret: string,
) {
try {
const { TwitterApi } = await import('twitter-api-v2');
const { TwitterApi } = await import("twitter-api-v2");
this.client = new TwitterApi({
appKey: apiKey,
appSecret: apiSecret,
@@ -40,9 +40,9 @@ export class TwitterService {
accessSecret,
});
this.isEnabled = true;
this.logger.log('✅ Twitter API client initialized');
this.logger.log("✅ Twitter API client initialized");
} catch (error) {
this.logger.error('Failed to initialize Twitter client', error);
this.logger.error("Failed to initialize Twitter client", error);
}
}
@@ -59,7 +59,7 @@ export class TwitterService {
*/
async postWithImage(text: string, imagePath: string): Promise<string | null> {
if (!this.available) {
this.logger.warn('Twitter not available, skipping post');
this.logger.warn("Twitter not available, skipping post");
return null;
}
@@ -67,7 +67,7 @@ export class TwitterService {
// Step 1: Upload media via v1.1
const mediaData = fs.readFileSync(imagePath);
const mediaId = await this.client.v1.uploadMedia(mediaData, {
mimeType: 'image/png',
mimeType: "image/png",
});
// Step 2: Create tweet via v2
+41 -41
View File
@@ -1,4 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsArray,
IsDateString,
@@ -10,37 +10,37 @@ import {
Max,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
} from "class-validator";
import { Type } from "class-transformer";
// ─── Bulletin Match Item (used in CreateBulletinDto) ───
export class BulletinMatchItemDto {
@ApiProperty({ example: 1, description: 'Sıra numarası (1-15)' })
@ApiProperty({ example: 1, description: "Sıra numarası (1-15)" })
@IsInt()
@Min(1)
@Max(15)
matchOrder: number;
@ApiProperty({ example: 'Blackpool' })
@ApiProperty({ example: "Blackpool" })
@IsString()
homeTeamName: string;
@ApiProperty({ example: 'Burton Albion' })
@ApiProperty({ example: "Burton Albion" })
@IsString()
awayTeamName: string;
@ApiPropertyOptional({ example: 'İN1' })
@ApiPropertyOptional({ example: "İN1" })
@IsOptional()
@IsString()
leagueName?: string;
@ApiPropertyOptional({ example: '2026-03-28T18:00:00' })
@ApiPropertyOptional({ example: "2026-03-28T18:00:00" })
@IsOptional()
@IsDateString()
kickoffTime?: string;
@ApiPropertyOptional({ description: 'Link to existing match ID' })
@ApiPropertyOptional({ description: "Link to existing match ID" })
@IsOptional()
@IsString()
matchId?: string;
@@ -49,26 +49,26 @@ export class BulletinMatchItemDto {
// ─── Create Bulletin DTO ───
export class CreateBulletinDto {
@ApiProperty({ example: 333, description: 'Game cycle number from API' })
@ApiProperty({ example: 333, description: "Game cycle number from API" })
@IsInt()
gameCycleNo: number;
@ApiPropertyOptional({ example: '27-29 Mart' })
@ApiPropertyOptional({ example: "27-29 Mart" })
@IsOptional()
@IsString()
programName?: string;
@ApiPropertyOptional({ example: '2025-2026' })
@ApiPropertyOptional({ example: "2025-2026" })
@IsOptional()
@IsString()
season?: string;
@ApiPropertyOptional({ example: '2026-03-22T10:00:00' })
@ApiPropertyOptional({ example: "2026-03-22T10:00:00" })
@IsOptional()
@IsDateString()
payinBeginDate?: string;
@ApiPropertyOptional({ example: '2026-03-27T20:55:00' })
@ApiPropertyOptional({ example: "2026-03-27T20:55:00" })
@IsOptional()
@IsDateString()
payinEndDate?: string;
@@ -83,24 +83,24 @@ export class CreateBulletinDto {
// ─── Update Results DTO ───
export class MatchResultDto {
@ApiProperty({ example: 1, description: 'Match order (1-15)' })
@ApiProperty({ example: 1, description: "Match order (1-15)" })
@IsInt()
@Min(1)
@Max(15)
matchOrder: number;
@ApiProperty({ enum: ['HOME', 'DRAW', 'AWAY'], example: 'HOME' })
@IsEnum({ HOME: 'HOME', DRAW: 'DRAW', AWAY: 'AWAY' })
result: 'HOME' | 'DRAW' | 'AWAY';
@ApiProperty({ enum: ["HOME", "DRAW", "AWAY"], example: "HOME" })
@IsEnum({ HOME: "HOME", DRAW: "DRAW", AWAY: "AWAY" })
result: "HOME" | "DRAW" | "AWAY";
@ApiPropertyOptional({ default: false })
@IsOptional()
isCancelled?: boolean;
@ApiPropertyOptional({ enum: ['HOME', 'DRAW', 'AWAY'] })
@ApiPropertyOptional({ enum: ["HOME", "DRAW", "AWAY"] })
@IsOptional()
@IsEnum({ HOME: 'HOME', DRAW: 'DRAW', AWAY: 'AWAY' })
drawResult?: 'HOME' | 'DRAW' | 'AWAY';
@IsEnum({ HOME: "HOME", DRAW: "DRAW", AWAY: "AWAY" })
drawResult?: "HOME" | "DRAW" | "AWAY";
}
export class UpdateResultsDto {
@@ -110,12 +110,12 @@ export class UpdateResultsDto {
@Type(() => MatchResultDto)
results: MatchResultDto[];
@ApiPropertyOptional({ description: '15 bilen sayısı' })
@ApiPropertyOptional({ description: "15 bilen sayısı" })
@IsOptional()
@IsInt()
winners15?: number;
@ApiPropertyOptional({ description: '15 bilen ödülü (TL)' })
@ApiPropertyOptional({ description: "15 bilen ödülü (TL)" })
@IsOptional()
@IsNumber()
prize15?: number;
@@ -150,7 +150,7 @@ export class UpdateResultsDto {
@IsNumber()
prize12?: number;
@ApiPropertyOptional({ description: 'Sonraki haftaya devir' })
@ApiPropertyOptional({ description: "Sonraki haftaya devir" })
@IsOptional()
@IsNumber()
rolloverNext?: number;
@@ -158,7 +158,7 @@ export class UpdateResultsDto {
// ─── Generate Columns DTO ───
export type TotoSelectionType = '1' | 'X' | '2';
export type TotoSelectionType = "1" | "X" | "2";
export class TotoMatchSelection {
@ApiProperty({ example: 1 })
@@ -169,15 +169,15 @@ export class TotoMatchSelection {
@ApiProperty({
type: [String],
example: ['1', 'X'],
description: 'Seçimler: 1=Ev, X=Beraberlik, 2=Deplasman',
example: ["1", "X"],
description: "Seçimler: 1=Ev, X=Beraberlik, 2=Deplasman",
})
@IsArray()
selections: TotoSelectionType[];
}
export class GenerateColumnsDto {
@ApiProperty({ description: 'Bulletin ID' })
@ApiProperty({ description: "Bulletin ID" })
@IsString()
bulletinId: string;
@@ -188,8 +188,8 @@ export class GenerateColumnsDto {
matchSelections: TotoMatchSelection[];
@ApiPropertyOptional({
example: 'FULL_SYSTEM',
description: 'FULL_SYSTEM | REDUCED_SYSTEM | MANUAL',
example: "FULL_SYSTEM",
description: "FULL_SYSTEM | REDUCED_SYSTEM | MANUAL",
})
@IsOptional()
@IsString()
@@ -197,7 +197,7 @@ export class GenerateColumnsDto {
@ApiPropertyOptional({
example: 100,
description: 'Max kolon sayısı (reduced system için)',
description: "Max kolon sayısı (reduced system için)",
})
@IsOptional()
@IsInt()
@@ -207,23 +207,23 @@ export class GenerateColumnsDto {
// ─── Generate AI Prediction DTO ───
export class GenerateSporTotoPredictionDto {
@ApiProperty({ description: 'Bulletin ID' })
@ApiProperty({ description: "Bulletin ID" })
@IsString()
bulletinId: string;
@ApiPropertyOptional({
example: 'BALANCED',
enum: ['CONSERVATIVE', 'BALANCED', 'AGGRESSIVE', 'FORMULA_6PCT'],
example: "BALANCED",
enum: ["CONSERVATIVE", "BALANCED", "AGGRESSIVE", "FORMULA_6PCT"],
description:
'CONSERVATIVE(100 col), BALANCED(500), AGGRESSIVE(2500), FORMULA_6PCT(%6 sampling)',
"CONSERVATIVE(100 col), BALANCED(500), AGGRESSIVE(2500), FORMULA_6PCT(%6 sampling)",
})
@IsOptional()
@IsString()
strategy?: 'CONSERVATIVE' | 'BALANCED' | 'AGGRESSIVE' | 'FORMULA_6PCT';
strategy?: "CONSERVATIVE" | "BALANCED" | "AGGRESSIVE" | "FORMULA_6PCT";
@ApiPropertyOptional({
example: 500,
description: 'Max bütçe (TL). Kolon sayısı buna göre sınırlanır.',
description: "Max bütçe (TL). Kolon sayısı buna göre sınırlanır.",
})
@IsOptional()
@IsNumber()
@@ -231,7 +231,7 @@ export class GenerateSporTotoPredictionDto {
@ApiPropertyOptional({
example: 200,
description: 'Max kolon sayısı override',
description: "Max kolon sayısı override",
})
@IsOptional()
@IsInt()
@@ -241,14 +241,14 @@ export class GenerateSporTotoPredictionDto {
// ─── Evaluate Columns DTO ───
export class EvaluateColumnsDto {
@ApiProperty({ description: 'Bulletin ID' })
@ApiProperty({ description: "Bulletin ID" })
@IsString()
bulletinId: string;
@ApiProperty({
type: [String],
example: ['11X2X1XX21X1121'],
description: 'Array of 15-char column strings',
example: ["11X2X1XX21X1121"],
description: "Array of 15-char column strings",
})
@IsArray()
@IsString({ each: true })
@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../database/prisma.service';
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../../database/prisma.service";
/**
* Spor Toto Analitik Servisi
@@ -91,8 +91,8 @@ export class TotoAnalyticsService {
consecutiveRollovers: number;
}> {
const bulletins = await this.prisma.totoBulletin.findMany({
where: { status: 'COMPLETED' },
orderBy: { gameCycleNo: 'desc' },
where: { status: "COMPLETED" },
orderBy: { gameCycleNo: "desc" },
take: limit,
include: { result: true },
});
@@ -1,8 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger } from "@nestjs/common";
export interface TotoMatchSelectionInput {
matchOrder: number;
selections: ('1' | 'X' | '2')[];
selections: ("1" | "X" | "2")[];
}
export interface GeneratedColumn {
@@ -38,7 +38,7 @@ export class TotoCombinatoricsService {
for (let i = 1; i <= 15; i++) {
const sel = selectionsMap.get(i);
if (!sel || sel.length === 0) {
orderedSelections.push(['1']); // Default: ev sahibi
orderedSelections.push(["1"]); // Default: ev sahibi
} else {
orderedSelections.push(sel);
}
@@ -56,7 +56,7 @@ export class TotoCombinatoricsService {
// Tüm kombinasyonları üret
const columns: GeneratedColumn[] = [];
this.generateCombinations(orderedSelections, 0, '', columns);
this.generateCombinations(orderedSelections, 0, "", columns);
return columns;
}
@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { Injectable, Logger } from "@nestjs/common";
import axios from "axios";
/**
* Spor Toto API response types
@@ -45,25 +45,25 @@ export interface SporTotoApiResponse {
@Injectable()
export class TotoFetcherService {
private readonly logger = new Logger(TotoFetcherService.name);
private readonly apiUrl = 'https://sportotov2.iddaa.com/SporToto';
private readonly apiUrl = "https://sportotov2.iddaa.com/SporToto";
/**
* Fetch current bulletin from Spor Toto API
*/
async fetchCurrentBulletin(): Promise<SporTotoApiResponse | null> {
try {
this.logger.log('Fetching current Spor Toto bulletin...');
this.logger.log("Fetching current Spor Toto bulletin...");
const response = await axios.get<SporTotoApiResponse>(this.apiUrl, {
timeout: 10000,
headers: {
Accept: 'application/json',
'User-Agent': 'SuggestBet/1.0',
Accept: "application/json",
"User-Agent": "SuggestBet/1.0",
},
});
if (!response.data?.isSuccess || !response.data?.data) {
this.logger.warn(
'Spor Toto API returned unsuccessful response',
"Spor Toto API returned unsuccessful response",
response.data?.message,
);
return null;
@@ -80,7 +80,7 @@ export class TotoFetcherService {
error.response?.status,
);
} else {
this.logger.error('Spor Toto fetch failed', error);
this.logger.error("Spor Toto fetch failed", error);
}
return null;
}
@@ -93,30 +93,30 @@ export class TotoFetcherService {
homeTeam: string;
awayTeam: string;
} {
const parts = eventName.split('-');
const parts = eventName.split("-");
if (parts.length >= 2) {
return {
homeTeam: parts[0].trim(),
awayTeam: parts.slice(1).join('-').trim(),
awayTeam: parts.slice(1).join("-").trim(),
};
}
return { homeTeam: eventName, awayTeam: '' };
return { homeTeam: eventName, awayTeam: "" };
}
/**
* Map API result/winner to TotoMatchResult enum value
* API returns: "1" (HOME), "0" (DRAW), "2" (AWAY)
*/
mapResultToEnum(winner: string | null): 'HOME' | 'DRAW' | 'AWAY' | null {
mapResultToEnum(winner: string | null): "HOME" | "DRAW" | "AWAY" | null {
if (!winner) return null;
switch (winner) {
case '1':
return 'HOME';
case '0':
case 'X':
return 'DRAW';
case '2':
return 'AWAY';
case "1":
return "HOME";
case "0":
case "X":
return "DRAW";
case "2":
return "AWAY";
default:
return null;
}
@@ -1,23 +1,23 @@
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../../database/prisma.service';
import { firstValueFrom } from 'rxjs';
import { Injectable, Logger } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { PrismaService } from "../../../database/prisma.service";
import { firstValueFrom } from "rxjs";
import {
TotoCombinatoricsService,
TotoMatchSelectionInput,
} from './toto-combinatorics.service';
import { TotoAnalyticsService } from './toto-analytics.service';
} from "./toto-combinatorics.service";
import { TotoAnalyticsService } from "./toto-analytics.service";
// ═══════════ TYPES ═══════════
export type PredictionStrategy =
| 'CONSERVATIVE'
| 'BALANCED'
| 'AGGRESSIVE'
| 'FORMULA_6PCT';
| "CONSERVATIVE"
| "BALANCED"
| "AGGRESSIVE"
| "FORMULA_6PCT";
export type TotoSelection = '1' | 'X' | '2';
export type TotoSelection = "1" | "X" | "2";
export interface MatchPredictionAnalysis {
matchOrder: number;
@@ -27,7 +27,7 @@ export interface MatchPredictionAnalysis {
/** Linked matchId from DB (null if not found) */
linkedMatchId: string | null;
/** AI Engine prediction source */
predictionSource: 'AI_ENGINE' | 'HISTORICAL_FORM' | 'FALLBACK';
predictionSource: "AI_ENGINE" | "HISTORICAL_FORM" | "FALLBACK";
/** Raw AI probabilities for each outcome */
probabilities: { home: number; draw: number; away: number };
/** AI confidence (0-100) */
@@ -62,7 +62,7 @@ export interface PredictionResult {
effectivePool: number;
ev15: number;
evPerColumn: number;
recommendation: 'PLAY' | 'WAIT' | 'HIGH_VALUE';
recommendation: "PLAY" | "WAIT" | "HIGH_VALUE";
recommendationReason: string;
};
/** System info */
@@ -140,7 +140,7 @@ export class TotoPredictionService {
private readonly analytics: TotoAnalyticsService,
) {
this.aiEngineUrl =
this.configService.get('AI_ENGINE_URL') || 'http://127.0.0.1:8000';
this.configService.get("AI_ENGINE_URL") || "http://127.0.0.1:8000";
}
/**
@@ -148,7 +148,7 @@ export class TotoPredictionService {
*/
async generatePrediction(
bulletinId: string,
strategy: PredictionStrategy = 'BALANCED',
strategy: PredictionStrategy = "BALANCED",
maxBudget?: number,
): Promise<PredictionResult> {
const config = STRATEGY_CONFIGS[strategy];
@@ -156,7 +156,7 @@ export class TotoPredictionService {
// 1. Bülteni getir
const bulletin = await this.prisma.totoBulletin.findUnique({
where: { id: bulletinId },
include: { matches: { orderBy: { matchOrder: 'asc' } } },
include: { matches: { orderBy: { matchOrder: "asc" } } },
});
if (!bulletin) {
@@ -286,10 +286,10 @@ export class TotoPredictionService {
// 2. AI Engine'den tahmin al
let probabilities = { home: 0.33, draw: 0.33, away: 0.34 };
let confidence = 33;
let aiPick: TotoSelection = '1';
let predictionSource: MatchPredictionAnalysis['predictionSource'] =
'FALLBACK';
let reasoning = 'Eşleşme bulunamadı, eşit dağılım kullanıldı';
let aiPick: TotoSelection = "1";
let predictionSource: MatchPredictionAnalysis["predictionSource"] =
"FALLBACK";
let reasoning = "Eşleşme bulunamadı, eşit dağılım kullanıldı";
if (linkedMatchId) {
const aiResult = await this.callAiEngine(linkedMatchId);
@@ -298,7 +298,7 @@ export class TotoPredictionService {
probabilities = aiResult.probabilities;
confidence = aiResult.confidence;
aiPick = aiResult.pick;
predictionSource = 'AI_ENGINE';
predictionSource = "AI_ENGINE";
reasoning = aiResult.reasoning;
} else {
// AI Engine erişilemez → tarihsel form analizi
@@ -306,7 +306,7 @@ export class TotoPredictionService {
probabilities = formResult.probabilities;
confidence = formResult.confidence;
aiPick = formResult.pick;
predictionSource = 'HISTORICAL_FORM';
predictionSource = "HISTORICAL_FORM";
reasoning = formResult.reasoning;
}
} else {
@@ -316,7 +316,7 @@ export class TotoPredictionService {
probabilities = formResult.probabilities;
confidence = formResult.confidence;
aiPick = formResult.pick;
predictionSource = 'HISTORICAL_FORM';
predictionSource = "HISTORICAL_FORM";
reasoning = formResult.reasoning;
}
}
@@ -362,7 +362,7 @@ export class TotoPredictionService {
>(
`SELECT id FROM live_matches
WHERE LOWER(match_name) LIKE $1 AND LOWER(match_name) LIKE $2
${kickoffTime ? `AND ABS(CAST(mst_utc AS BIGINT) - $3) < 259200000` : ''}
${kickoffTime ? `AND ABS(CAST(mst_utc AS BIGINT) - $3) < 259200000` : ""}
LIMIT 1`,
`%${homeNorm}%`,
`%${awayNorm}%`,
@@ -384,7 +384,7 @@ export class TotoPredictionService {
const match = await this.prisma.$queryRawUnsafe<Array<{ id: string }>>(
`SELECT id FROM matches
WHERE LOWER(match_name) LIKE $1 AND LOWER(match_name) LIKE $2
${kickoffTime ? `AND ABS(CAST(mst_utc AS BIGINT) - $3) < 259200000` : ''}
${kickoffTime ? `AND ABS(CAST(mst_utc AS BIGINT) - $3) < 259200000` : ""}
ORDER BY mst_utc DESC
LIMIT 1`,
`%${homeNorm}%`,
@@ -413,14 +413,14 @@ export class TotoPredictionService {
return name
.toLowerCase()
.trim()
.replace(/ı/g, 'i')
.replace(/ğ/g, 'g')
.replace(/ü/g, 'u')
.replace(/ş/g, 's')
.replace(/ö/g, 'o')
.replace(/ç/g, 'c')
.replace(/\./g, '')
.replace(/\s+/g, ' ');
.replace(/ı/g, "i")
.replace(/ğ/g, "g")
.replace(/ü/g, "u")
.replace(/ş/g, "s")
.replace(/ö/g, "o")
.replace(/ç/g, "c")
.replace(/\./g, "")
.replace(/\s+/g, " ");
}
// ═══════════ AI ENGINE INTEGRATION ═══════════
@@ -460,9 +460,9 @@ export class TotoPredictionService {
}>
).find(
(b) =>
b.market?.toLowerCase().includes('maç sonucu') ||
b.market?.toLowerCase().includes('match result') ||
b.market === '1X2',
b.market?.toLowerCase().includes("maç sonucu") ||
b.market?.toLowerCase().includes("match result") ||
b.market === "1X2",
);
// Score prediction'dan olasılıklar çıkar
@@ -495,23 +495,23 @@ export class TotoPredictionService {
}
// Pick'i Toto formatına çevir
let pick: TotoSelection = '1';
let pick: TotoSelection = "1";
if (msPick) {
const rawPick = msPick.pick?.toLowerCase();
if (
rawPick?.includes('2') ||
rawPick?.includes('away') ||
rawPick?.includes('deplasman')
rawPick?.includes("2") ||
rawPick?.includes("away") ||
rawPick?.includes("deplasman")
) {
pick = '2';
pick = "2";
} else if (
rawPick?.includes('x') ||
rawPick?.includes('draw') ||
rawPick?.includes('beraberlik')
rawPick?.includes("x") ||
rawPick?.includes("draw") ||
rawPick?.includes("beraberlik")
) {
pick = 'X';
pick = "X";
} else {
pick = '1';
pick = "1";
}
} else {
// No explicit MS pick → use probabilities
@@ -519,16 +519,16 @@ export class TotoPredictionService {
probabilities.away > probabilities.home &&
probabilities.away > probabilities.draw
) {
pick = '2';
pick = "2";
} else if (probabilities.draw > probabilities.home) {
pick = 'X';
pick = "X";
}
}
const confidence = Math.round(
(msPick?.calibrated_confidence ?? msPick?.confidence ?? 50) *
(typeof (msPick?.calibrated_confidence ?? msPick?.confidence) ===
'number' &&
"number" &&
(msPick?.calibrated_confidence ?? msPick?.confidence ?? 0) <= 1
? 100
: 1),
@@ -542,7 +542,7 @@ export class TotoPredictionService {
pick,
reasoning:
reasons.length > 0
? reasons.join(' | ')
? reasons.join(" | ")
: `AI Engine: ${pick} (confidence: ${confidence}%)`,
};
} catch (error) {
@@ -596,21 +596,21 @@ export class TotoPredictionService {
return {
probabilities: { home: 0.33, draw: 0.33, away: 0.34 },
confidence: 33,
pick: '1',
reasoning: 'Tarihsel veri bulunamadı, eşit dağılım',
pick: "1",
reasoning: "Tarihsel veri bulunamadı, eşit dağılım",
};
}
// Ev sahibi form analizi
const homeWins = homeMatches.filter((m) => m.winner === 'home').length;
const homeDraws = homeMatches.filter((m) => m.winner === 'draw').length;
const homeLosses = homeMatches.filter((m) => m.winner === 'away').length;
const homeWins = homeMatches.filter((m) => m.winner === "home").length;
const homeDraws = homeMatches.filter((m) => m.winner === "draw").length;
const homeLosses = homeMatches.filter((m) => m.winner === "away").length;
const homeTotal = homeMatches.length || 1;
// Deplasman form analizi
const awayWins = awayMatches.filter((m) => m.winner === 'away').length;
const awayDraws = awayMatches.filter((m) => m.winner === 'draw').length;
const awayLosses = awayMatches.filter((m) => m.winner === 'home').length;
const awayWins = awayMatches.filter((m) => m.winner === "away").length;
const awayDraws = awayMatches.filter((m) => m.winner === "draw").length;
const awayLosses = awayMatches.filter((m) => m.winner === "home").length;
const awayTotal = awayMatches.length || 1;
// Basit form bazlı olasılık
@@ -630,14 +630,14 @@ export class TotoPredictionService {
};
// En yüksek olasılık
let pick: TotoSelection = '1';
let pick: TotoSelection = "1";
if (
probabilities.away > probabilities.home &&
probabilities.away > probabilities.draw
) {
pick = '2';
pick = "2";
} else if (probabilities.draw > probabilities.home) {
pick = 'X';
pick = "X";
}
const confidence = Math.round(
@@ -656,8 +656,8 @@ export class TotoPredictionService {
return {
probabilities: { home: 0.33, draw: 0.33, away: 0.34 },
confidence: 33,
pick: '1',
reasoning: 'Form analizi yapılamadı, eşit dağılım',
pick: "1",
reasoning: "Form analizi yapılamadı, eşit dağılım",
};
}
}
@@ -685,9 +685,9 @@ export class TotoPredictionService {
} {
// Olasılıkları sırala
const probs: Array<{ pick: TotoSelection; prob: number }> = [
{ pick: '1' as TotoSelection, prob: probabilities.home },
{ pick: 'X' as TotoSelection, prob: probabilities.draw },
{ pick: '2' as TotoSelection, prob: probabilities.away },
{ pick: "1" as TotoSelection, prob: probabilities.home },
{ pick: "X" as TotoSelection, prob: probabilities.draw },
{ pick: "2" as TotoSelection, prob: probabilities.away },
].sort((a, b) => b.prob - a.prob);
const topProb = probs[0].prob;
@@ -724,7 +724,7 @@ export class TotoPredictionService {
contrarianReasoning = `İkili: ${probs[0].pick} + ${probs[1].pick} — Orta güven, varyans koruması`;
} else {
// Düşük güven → üçlü kapatma
selections = ['1', 'X', '2'];
selections = ["1", "X", "2"];
contrarianReasoning = `Kapatma: 1X2 — Düşük güven (${confidence}%), maç çok belirsiz`;
}
@@ -741,7 +741,7 @@ export class TotoPredictionService {
rolloverAmount: number,
columnCount: number,
totalCost: number,
): Promise<PredictionResult['evReport']> {
): Promise<PredictionResult["evReport"]> {
const effectivePool = poolTotal + rolloverAmount;
const distribution =
this.analytics.calculatePoolDistribution(effectivePool);
@@ -765,20 +765,20 @@ export class TotoPredictionService {
}
// Karar
let recommendation: PredictionResult['evReport']['recommendation'];
let recommendation: PredictionResult["evReport"]["recommendation"];
let recommendationReason: string;
if (rolloverAmount > 50_000_000) {
recommendation = 'HIGH_VALUE';
recommendation = "HIGH_VALUE";
recommendationReason = `🔥 ${(rolloverAmount / 1_000_000).toFixed(1)}M TL devir! Tarihi fırsat. Agresif oyna.`;
} else if (rolloverAmount > 20_000_000) {
recommendation = 'PLAY';
recommendation = "PLAY";
recommendationReason = `${(rolloverAmount / 1_000_000).toFixed(1)}M TL devir. Oynamaya değer. (Ardışık ${rolloverData.consecutiveRollovers} hafta devir)`;
} else if (rolloverAmount > 5_000_000) {
recommendation = 'PLAY';
recommendation = "PLAY";
recommendationReason = `✅ Orta düzey devir: ${(rolloverAmount / 1_000_000).toFixed(1)}M TL`;
} else {
recommendation = 'WAIT';
recommendation = "WAIT";
recommendationReason = `⏳ Devir düşük (${(rolloverAmount / 1_000_000).toFixed(1)}M TL). Havuz büyümesini bekle.`;
}
+71 -71
View File
@@ -10,7 +10,7 @@ import {
HttpStatus,
Logger,
UseGuards,
} from '@nestjs/common';
} from "@nestjs/common";
import {
ApiTags,
ApiOperation,
@@ -19,21 +19,21 @@ import {
ApiParam,
ApiBody,
ApiBearerAuth,
} from '@nestjs/swagger';
import { SporTotoService } from './spor-toto.service';
} from "@nestjs/swagger";
import { SporTotoService } from "./spor-toto.service";
import {
CreateBulletinDto,
UpdateResultsDto,
GenerateColumnsDto,
GenerateSporTotoPredictionDto,
EvaluateColumnsDto,
} from './dto/spor-toto.dto';
import { Public, Roles } from '../../common/decorators';
import { JwtAuthGuard } from '../auth/guards/auth.guards';
import { TotoBulletinStatus } from '@prisma/client';
} from "./dto/spor-toto.dto";
import { Public, Roles } from "../../common/decorators";
import { JwtAuthGuard } from "../auth/guards/auth.guards";
import { TotoBulletinStatus } from "@prisma/client";
@ApiTags('Spor Toto')
@Controller('spor-toto')
@ApiTags("Spor Toto")
@Controller("spor-toto")
export class SporTotoController {
private readonly logger = new Logger(SporTotoController.name);
@@ -41,51 +41,51 @@ export class SporTotoController {
// ═══════════ BULLETINS ═══════════
@Post('sync')
@Post("sync")
@UseGuards(JwtAuthGuard)
@Roles('admin')
@Roles("admin")
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Sync current bulletin from Spor Toto API',
summary: "Sync current bulletin from Spor Toto API",
description:
'Fetches the latest bulletin from sportotov2.iddaa.com and upserts it into the database. Updates match results and dividends if already exists.',
"Fetches the latest bulletin from sportotov2.iddaa.com and upserts it into the database. Updates match results and dividends if already exists.",
})
@ApiResponse({
status: 200,
description: 'Sync result with action (created/updated/unchanged)',
description: "Sync result with action (created/updated/unchanged)",
})
async syncFromApi() {
const result = await this.sporTotoService.syncFromApi();
return { success: true, data: result };
}
@Get('bulletins')
@Get("bulletins")
@Public()
@ApiOperation({
summary: 'List Spor Toto bulletins',
summary: "List Spor Toto bulletins",
description:
'Returns a paginated list of bulletins, optionally filtered by status.',
"Returns a paginated list of bulletins, optionally filtered by status.",
})
@ApiQuery({
name: 'status',
name: "status",
required: false,
enum: TotoBulletinStatus,
description: 'Filter by bulletin status',
description: "Filter by bulletin status",
})
@ApiQuery({
name: 'limit',
name: "limit",
required: false,
type: Number,
description: 'Max results (default: 10)',
description: "Max results (default: 10)",
})
@ApiResponse({
status: 200,
description: 'Array of bulletins with matches and results',
description: "Array of bulletins with matches and results",
})
async listBulletins(
@Query('status') status?: TotoBulletinStatus,
@Query('limit') limit?: string,
@Query("status") status?: TotoBulletinStatus,
@Query("limit") limit?: string,
) {
const bulletins = await this.sporTotoService.listBulletins(
status,
@@ -94,95 +94,95 @@ export class SporTotoController {
return { success: true, data: bulletins };
}
@Get('bulletins/:id')
@Get("bulletins/:id")
@Public()
@ApiOperation({
summary: 'Get bulletin details',
summary: "Get bulletin details",
description:
'Returns a single bulletin with all 15 matches, results, and dividend info.',
"Returns a single bulletin with all 15 matches, results, and dividend info.",
})
@ApiParam({ name: 'id', description: 'Bulletin UUID' })
@ApiParam({ name: "id", description: "Bulletin UUID" })
@ApiResponse({
status: 200,
description: 'Bulletin with matches and results',
description: "Bulletin with matches and results",
})
@ApiResponse({ status: 404, description: 'Bulletin not found' })
async getBulletin(@Param('id') id: string) {
@ApiResponse({ status: 404, description: "Bulletin not found" })
async getBulletin(@Param("id") id: string) {
const bulletin = await this.sporTotoService.getBulletinById(id);
return { success: true, data: bulletin };
}
@Post('bulletins')
@Post("bulletins")
@UseGuards(JwtAuthGuard)
@Roles('admin')
@Roles("admin")
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Create a bulletin manually',
summary: "Create a bulletin manually",
description:
'Creates a new bulletin with 15 matches. Fails if gameCycleNo already exists.',
"Creates a new bulletin with 15 matches. Fails if gameCycleNo already exists.",
})
@ApiBody({ type: CreateBulletinDto })
@ApiResponse({ status: 201, description: 'Created bulletin with matches' })
@ApiResponse({ status: 201, description: "Created bulletin with matches" })
@ApiResponse({
status: 409,
description: 'Bulletin with this gameCycleNo already exists',
description: "Bulletin with this gameCycleNo already exists",
})
async createBulletin(@Body() dto: CreateBulletinDto) {
const bulletin = await this.sporTotoService.createBulletin(dto);
return { success: true, data: bulletin };
}
@Patch('bulletins/:id/results')
@Patch("bulletins/:id/results")
@UseGuards(JwtAuthGuard)
@Roles('admin')
@Roles("admin")
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Update bulletin match results',
summary: "Update bulletin match results",
description:
'Updates individual match results and optionally upserts dividend/prize data. Marks bulletin COMPLETED when all 15 results are entered.',
"Updates individual match results and optionally upserts dividend/prize data. Marks bulletin COMPLETED when all 15 results are entered.",
})
@ApiParam({ name: 'id', description: 'Bulletin UUID' })
@ApiParam({ name: "id", description: "Bulletin UUID" })
@ApiBody({ type: UpdateResultsDto })
@ApiResponse({ status: 200, description: 'Updated bulletin with results' })
@ApiResponse({ status: 404, description: 'Bulletin not found' })
async updateResults(@Param('id') id: string, @Body() dto: UpdateResultsDto) {
@ApiResponse({ status: 200, description: "Updated bulletin with results" })
@ApiResponse({ status: 404, description: "Bulletin not found" })
async updateResults(@Param("id") id: string, @Body() dto: UpdateResultsDto) {
const bulletin = await this.sporTotoService.updateResults(id, dto);
return { success: true, data: bulletin };
}
// ═══════════ STATS & ANALYTICS ═══════════
@Get('bulletins/:id/stats')
@Get("bulletins/:id/stats")
@Public()
@ApiOperation({
summary: 'Get bulletin pool & EV statistics',
summary: "Get bulletin pool & EV statistics",
description:
'Returns pool distribution (35/20/20/25), expected value calculations, and rollover analysis for a bulletin.',
"Returns pool distribution (35/20/20/25), expected value calculations, and rollover analysis for a bulletin.",
})
@ApiParam({ name: 'id', description: 'Bulletin UUID' })
@ApiResponse({ status: 200, description: 'Pool distribution and EV stats' })
async getBulletinStats(@Param('id') id: string) {
@ApiParam({ name: "id", description: "Bulletin UUID" })
@ApiResponse({ status: 200, description: "Pool distribution and EV stats" })
async getBulletinStats(@Param("id") id: string) {
const stats = await this.sporTotoService.getBulletinStats(id);
return { success: true, data: stats };
}
@Get('history')
@Get("history")
@Public()
@ApiOperation({
summary: 'Get rollover history and trends',
summary: "Get rollover history and trends",
description:
'Returns the last N bulletins with rollover amounts and consecutive rollover streak.',
"Returns the last N bulletins with rollover amounts and consecutive rollover streak.",
})
@ApiQuery({
name: 'limit',
name: "limit",
required: false,
type: Number,
description: 'Number of results (default: 20)',
description: "Number of results (default: 20)",
})
@ApiResponse({ status: 200, description: 'Rollover history with trend data' })
async getRolloverHistory(@Query('limit') limit?: string) {
@ApiResponse({ status: 200, description: "Rollover history with trend data" })
async getRolloverHistory(@Query("limit") limit?: string) {
const history = await this.sporTotoService.getRolloverHistory(
Number(limit) || 20,
);
@@ -191,38 +191,38 @@ export class SporTotoController {
// ═══════════ COLUMNS ═══════════
@Post('columns/generate')
@Post("columns/generate")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Generate Spor Toto columns (full or reduced system)',
summary: "Generate Spor Toto columns (full or reduced system)",
description:
'Takes match selections (1/X/2 per match) and generates columns via Cartesian product (full) or random sampling (reduced). Returns columns with cost calculation.',
"Takes match selections (1/X/2 per match) and generates columns via Cartesian product (full) or random sampling (reduced). Returns columns with cost calculation.",
})
@ApiBody({ type: GenerateColumnsDto })
@ApiResponse({
status: 200,
description: 'Generated columns with strategy, cost, and column strings',
description: "Generated columns with strategy, cost, and column strings",
})
async generateColumns(@Body() dto: GenerateColumnsDto) {
const result = await this.sporTotoService.generateColumns(dto);
return { success: true, data: result };
}
@Post('columns/evaluate')
@Post("columns/evaluate")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Evaluate columns against results',
summary: "Evaluate columns against results",
description:
'Compares generated column strings against actual match results. Returns correct count per column and summary (15/14/13/12 bilen).',
"Compares generated column strings against actual match results. Returns correct count per column and summary (15/14/13/12 bilen).",
})
@ApiBody({ type: EvaluateColumnsDto })
@ApiResponse({
status: 200,
description: 'Evaluation results with correct counts per column',
description: "Evaluation results with correct counts per column",
})
async evaluateColumns(@Body() dto: EvaluateColumnsDto) {
const result = await this.sporTotoService.evaluateColumns(
@@ -234,24 +234,24 @@ export class SporTotoController {
// ═══════════ AI PREDICTION ═══════════
@Post('predict')
@Post("predict")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Generate AI predictions with contrarian strategy',
summary: "Generate AI predictions with contrarian strategy",
description:
'Analyzes bulletin matches via AI Engine V20+, applies contrarian parimutüel strategy, and generates optimized system coupons. Supports 4 strategies: CONSERVATIVE (100 cols), BALANCED (500), AGGRESSIVE (2500), FORMULA_6PCT (6% sampling).',
"Analyzes bulletin matches via AI Engine V20+, applies contrarian parimutüel strategy, and generates optimized system coupons. Supports 4 strategies: CONSERVATIVE (100 cols), BALANCED (500), AGGRESSIVE (2500), FORMULA_6PCT (6% sampling).",
})
@ApiBody({ type: GenerateSporTotoPredictionDto })
@ApiResponse({
status: 200,
description:
'Prediction result with per-match analysis, system coupon, and EV report with play recommendation',
"Prediction result with per-match analysis, system coupon, and EV report with play recommendation",
})
async generatePrediction(@Body() dto: GenerateSporTotoPredictionDto) {
this.logger.log(
`Generating prediction for bulletin ${dto.bulletinId} with strategy ${dto.strategy || 'BALANCED'}`,
`Generating prediction for bulletin ${dto.bulletinId} with strategy ${dto.strategy || "BALANCED"}`,
);
const result = await this.sporTotoService.generatePrediction(dto);
return { success: true, data: result };
+10 -10
View File
@@ -1,13 +1,13 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ConfigModule } from '@nestjs/config';
import { SporTotoController } from './spor-toto.controller';
import { SporTotoService } from './spor-toto.service';
import { TotoFetcherService } from './services/toto-fetcher.service';
import { TotoCombinatoricsService } from './services/toto-combinatorics.service';
import { TotoAnalyticsService } from './services/toto-analytics.service';
import { TotoPredictionService } from './services/toto-prediction.service';
import { DatabaseModule } from '../../database/database.module';
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule } from "@nestjs/config";
import { SporTotoController } from "./spor-toto.controller";
import { SporTotoService } from "./spor-toto.service";
import { TotoFetcherService } from "./services/toto-fetcher.service";
import { TotoCombinatoricsService } from "./services/toto-combinatorics.service";
import { TotoAnalyticsService } from "./services/toto-analytics.service";
import { TotoPredictionService } from "./services/toto-prediction.service";
import { DatabaseModule } from "../../database/database.module";
@Module({
imports: [DatabaseModule, HttpModule, ConfigModule],
+31 -31
View File
@@ -3,25 +3,25 @@ import {
Logger,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { TotoFetcherService } from './services/toto-fetcher.service';
} from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { TotoFetcherService } from "./services/toto-fetcher.service";
import {
TotoCombinatoricsService,
TotoMatchSelectionInput,
} from './services/toto-combinatorics.service';
import { TotoAnalyticsService } from './services/toto-analytics.service';
} from "./services/toto-combinatorics.service";
import { TotoAnalyticsService } from "./services/toto-analytics.service";
import {
TotoPredictionService,
PredictionStrategy,
} from './services/toto-prediction.service';
} from "./services/toto-prediction.service";
import {
CreateBulletinDto,
UpdateResultsDto,
GenerateColumnsDto,
GenerateSporTotoPredictionDto,
} from './dto/spor-toto.dto';
import { TotoBulletinStatus, TotoMatchResult, Prisma } from '@prisma/client';
} from "./dto/spor-toto.dto";
import { TotoBulletinStatus, TotoMatchResult, Prisma } from "@prisma/client";
@Injectable()
export class SporTotoService {
@@ -41,13 +41,13 @@ export class SporTotoService {
* Fetch and sync current bulletin from Spor Toto API
*/
async syncFromApi(): Promise<{
action: 'created' | 'updated' | 'unchanged';
action: "created" | "updated" | "unchanged";
gameCycleNo: number;
matchCount: number;
}> {
const apiResponse = await this.fetcher.fetchCurrentBulletin();
if (!apiResponse?.data) {
throw new NotFoundException('Spor Toto API returned no data');
throw new NotFoundException("Spor Toto API returned no data");
}
const apiData = apiResponse.data;
@@ -84,7 +84,7 @@ export class SporTotoService {
// Check if all matches have results → mark COMPLETED
const allHaveResults = apiData.events.every((e) => e.winner !== null);
if (allHaveResults && existing.status !== 'COMPLETED') {
if (allHaveResults && existing.status !== "COMPLETED") {
await this.prisma.totoBulletin.update({
where: { id: existing.id },
data: { status: TotoBulletinStatus.COMPLETED },
@@ -93,7 +93,7 @@ export class SporTotoService {
}
return {
action: hasChanges ? 'updated' : 'unchanged',
action: hasChanges ? "updated" : "unchanged",
gameCycleNo: apiData.gameCycleNo,
matchCount: apiData.events.length,
};
@@ -132,7 +132,7 @@ export class SporTotoService {
);
return {
action: 'created',
action: "created",
gameCycleNo: apiData.gameCycleNo,
matchCount: matchData.length,
};
@@ -187,10 +187,10 @@ export class SporTotoService {
return this.prisma.totoBulletin.findMany({
where,
orderBy: { gameCycleNo: 'desc' },
orderBy: { gameCycleNo: "desc" },
take: limit,
include: {
matches: { orderBy: { matchOrder: 'asc' } },
matches: { orderBy: { matchOrder: "asc" } },
result: true,
},
});
@@ -203,13 +203,13 @@ export class SporTotoService {
const bulletin = await this.prisma.totoBulletin.findUnique({
where: { id },
include: {
matches: { orderBy: { matchOrder: 'asc' } },
matches: { orderBy: { matchOrder: "asc" } },
result: true,
},
});
if (!bulletin) {
throw new NotFoundException('Bulletin not found');
throw new NotFoundException("Bulletin not found");
}
return bulletin;
@@ -227,7 +227,7 @@ export class SporTotoService {
});
if (!bulletin) {
throw new NotFoundException('Bulletin not found');
throw new NotFoundException("Bulletin not found");
}
// Update individual match results
@@ -347,10 +347,10 @@ export class SporTotoService {
this.combinatorics.calculateColumnCount(matchSelections);
let columns;
const strategy = dto.strategy || 'FULL_SYSTEM';
const strategy = dto.strategy || "FULL_SYSTEM";
if (
strategy === 'REDUCED_SYSTEM' &&
strategy === "REDUCED_SYSTEM" &&
dto.maxColumns &&
totalColumnCount > dto.maxColumns
) {
@@ -381,33 +381,33 @@ export class SporTotoService {
async evaluateColumns(bulletinId: string, columnPredictions: string[]) {
const bulletin = await this.prisma.totoBulletin.findUnique({
where: { id: bulletinId },
include: { matches: { orderBy: { matchOrder: 'asc' } } },
include: { matches: { orderBy: { matchOrder: "asc" } } },
});
if (!bulletin) {
throw new NotFoundException('Bulletin not found');
throw new NotFoundException("Bulletin not found");
}
// Build results string (15 chars)
const resultMap: Record<string, string> = {
HOME: '1',
DRAW: 'X',
AWAY: '2',
HOME: "1",
DRAW: "X",
AWAY: "2",
};
const resultsString = bulletin.matches
.map((m) => {
if (m.isCancelled && m.drawResult) {
return resultMap[m.drawResult] || '?';
return resultMap[m.drawResult] || "?";
}
return m.result ? resultMap[m.result] || '?' : '?';
return m.result ? resultMap[m.result] || "?" : "?";
})
.join('');
.join("");
if (resultsString.includes('?')) {
if (resultsString.includes("?")) {
return {
complete: false,
message: 'Bazı maçların sonuçları henüz girilmedi',
message: "Bazı maçların sonuçları henüz girilmedi",
resultsString,
evaluations: [],
};
@@ -452,7 +452,7 @@ export class SporTotoService {
* AI Engine ile akıllı sistem kuponu üret
*/
async generatePrediction(dto: GenerateSporTotoPredictionDto) {
const strategy: PredictionStrategy = dto.strategy || 'BALANCED';
const strategy: PredictionStrategy = dto.strategy || "BALANCED";
return this.prediction.generatePrediction(
dto.bulletinId,
strategy,
+13 -13
View File
@@ -4,25 +4,25 @@ import {
IsOptional,
IsBoolean,
MinLength,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
} from "class-validator";
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
export class CreateUserDto {
@ApiPropertyOptional({ example: 'user@example.com' })
@ApiPropertyOptional({ example: "user@example.com" })
@IsEmail()
email: string;
@ApiPropertyOptional({ example: 'password123', minLength: 8 })
@ApiPropertyOptional({ example: "password123", minLength: 8 })
@IsString()
@MinLength(8)
password: string;
@ApiPropertyOptional({ example: 'John' })
@ApiPropertyOptional({ example: "John" })
@IsOptional()
@IsString()
firstName?: string;
@ApiPropertyOptional({ example: 'Doe' })
@ApiPropertyOptional({ example: "Doe" })
@IsOptional()
@IsString()
lastName?: string;
@@ -34,12 +34,12 @@ export class CreateUserDto {
}
export class UpdateUserDto extends PartialType(CreateUserDto) {
@ApiPropertyOptional({ example: 'John' })
@ApiPropertyOptional({ example: "John" })
@IsOptional()
@IsString()
firstName?: string;
@ApiPropertyOptional({ example: 'Doe' })
@ApiPropertyOptional({ example: "Doe" })
@IsOptional()
@IsString()
lastName?: string;
@@ -51,29 +51,29 @@ export class UpdateUserDto extends PartialType(CreateUserDto) {
}
export class UpdateProfileDto {
@ApiPropertyOptional({ example: 'John' })
@ApiPropertyOptional({ example: "John" })
@IsOptional()
@IsString()
firstName?: string;
@ApiPropertyOptional({ example: 'Doe' })
@ApiPropertyOptional({ example: "Doe" })
@IsOptional()
@IsString()
lastName?: string;
}
export class ChangePasswordDto {
@ApiProperty({ example: 'oldPassword123' })
@ApiProperty({ example: "oldPassword123" })
@IsString()
currentPassword: string;
@ApiProperty({ example: 'newPassword456', minLength: 8 })
@ApiProperty({ example: "newPassword456", minLength: 8 })
@IsString()
@MinLength(8)
newPassword: string;
}
import { Exclude, Expose } from 'class-transformer';
import { Exclude, Expose } from "class-transformer";
@Exclude()
export class UserResponseDto {
+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 {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiOkResponse,
} from '@nestjs/swagger';
import { BaseController } from '../../common/base';
import { UsersService } from './users.service';
} from "@nestjs/swagger";
import { BaseController } from "../../common/base";
import { UsersService } from "./users.service";
import {
CreateUserDto,
UpdateUserDto,
UpdateProfileDto,
ChangePasswordDto,
} from './dto/user.dto';
import { CurrentUser, Roles } from '../../common/decorators';
} from "./dto/user.dto";
import { CurrentUser, Roles } from "../../common/decorators";
import {
ApiResponse,
createSuccessResponse,
} from '../../common/types/api-response.type';
import { User } from '@prisma/client';
} from "../../common/types/api-response.type";
import { User } from "@prisma/client";
import { plainToInstance } from 'class-transformer';
import { UserResponseDto } from './dto/user.dto';
import { plainToInstance } from "class-transformer";
import { UserResponseDto } from "./dto/user.dto";
interface AuthenticatedUser {
id: string;
@@ -29,20 +29,20 @@ interface AuthenticatedUser {
role: string;
}
@ApiTags('Users')
@ApiTags("Users")
@ApiBearerAuth()
@Controller('users')
@Controller("users")
export class UsersController extends BaseController<
User,
CreateUserDto,
UpdateUserDto
> {
constructor(private readonly usersService: UsersService) {
super(usersService, 'User');
super(usersService, "User");
}
@Get('me')
@ApiOperation({ summary: 'Get current authenticated user profile' })
@Get("me")
@ApiOperation({ summary: "Get current authenticated user profile" })
@ApiOkResponse({ type: UserResponseDto })
async getMe(
@CurrentUser() user: AuthenticatedUser,
@@ -50,12 +50,12 @@ export class UsersController extends BaseController<
const fullUser = await this.usersService.findOneWithDetails(user.id);
return createSuccessResponse(
plainToInstance(UserResponseDto, fullUser),
'User profile retrieved successfully',
"User profile retrieved successfully",
);
}
@Put('me')
@ApiOperation({ summary: 'Update current user profile' })
@Put("me")
@ApiOperation({ summary: "Update current user profile" })
@ApiOkResponse({ type: UserResponseDto })
async updateMe(
@CurrentUser() user: AuthenticatedUser,
@@ -64,13 +64,13 @@ export class UsersController extends BaseController<
const updatedUser = await this.usersService.updateProfile(user.id, dto);
return createSuccessResponse(
plainToInstance(UserResponseDto, updatedUser),
'User profile updated successfully',
"User profile updated successfully",
);
}
@Patch('me/password')
@ApiOperation({ summary: 'Change current user password' })
@ApiOkResponse({ description: 'Password changed successfully' })
@Patch("me/password")
@ApiOperation({ summary: "Change current user password" })
@ApiOkResponse({ description: "Password changed successfully" })
async changePassword(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: ChangePasswordDto,
@@ -80,24 +80,24 @@ export class UsersController extends BaseController<
dto.currentPassword,
dto.newPassword,
);
return createSuccessResponse(null, 'Password changed successfully');
return createSuccessResponse(null, "Password changed successfully");
}
// Override create to require admin role
@Roles('admin')
@Roles("admin")
async create(
...args: Parameters<
BaseController<User, CreateUserDto, UpdateUserDto>['create']
BaseController<User, CreateUserDto, UpdateUserDto>["create"]
>
) {
return super.create(...args);
}
// Override delete to require admin role
@Roles('admin')
@Roles("admin")
async delete(
...args: Parameters<
BaseController<User, CreateUserDto, UpdateUserDto>['delete']
BaseController<User, CreateUserDto, UpdateUserDto>["delete"]
>
) {
return super.delete(...args);
+3 -3
View File
@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
@Module({
controllers: [UsersController],
+10 -10
View File
@@ -2,12 +2,12 @@ import {
Injectable,
ConflictException,
UnauthorizedException,
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../../database/prisma.service';
import { BaseService } from '../../common/base';
import { CreateUserDto, UpdateUserDto, UpdateProfileDto } from './dto/user.dto';
import { User, UserRole } from '@prisma/client';
} from "@nestjs/common";
import * as bcrypt from "bcrypt";
import { PrismaService } from "../../database/prisma.service";
import { BaseService } from "../../common/base";
import { CreateUserDto, UpdateUserDto, UpdateProfileDto } from "./dto/user.dto";
import { User, UserRole } from "@prisma/client";
@Injectable()
export class UsersService extends BaseService<
@@ -16,7 +16,7 @@ export class UsersService extends BaseService<
UpdateUserDto
> {
constructor(prisma: PrismaService) {
super(prisma, 'User');
super(prisma, "User");
}
/**
@@ -26,7 +26,7 @@ export class UsersService extends BaseService<
// Check if email already exists
const existingUser = await this.findOneBy({ email: dto.email });
if (existingUser) {
throw new ConflictException('EMAIL_ALREADY_EXISTS');
throw new ConflictException("EMAIL_ALREADY_EXISTS");
}
// Hash password
@@ -177,7 +177,7 @@ export class UsersService extends BaseService<
});
if (!user) {
throw new UnauthorizedException('USER_NOT_FOUND');
throw new UnauthorizedException("USER_NOT_FOUND");
}
const isCurrentPasswordValid = await this.comparePassword(
@@ -186,7 +186,7 @@ export class UsersService extends BaseService<
);
if (!isCurrentPasswordValid) {
throw new UnauthorizedException('INVALID_CURRENT_PASSWORD');
throw new UnauthorizedException("INVALID_CURRENT_PASSWORD");
}
const hashedNewPassword = await this.hashPassword(newPassword);
+76 -76
View File
@@ -9,8 +9,8 @@
* Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/backtest-accuracy.ts
*/
import { PrismaClient } from '@prisma/client';
import axios from 'axios';
import { PrismaClient } from "@prisma/client";
import axios from "axios";
const prisma = new PrismaClient();
@@ -18,7 +18,7 @@ const prisma = new PrismaClient();
// Configuration
// ═══════════════════════════════════════════════════════
const AI_ENGINE_URL = process.env.AI_ENGINE_URL || 'http://127.0.0.1:3005';
const AI_ENGINE_URL = process.env.AI_ENGINE_URL || "http://127.0.0.1:3005";
const CONCURRENT_REQUESTS = 5;
const MAX_MATCHES = 1000;
@@ -60,14 +60,14 @@ function determineActualOutcome(
htScoreHome: number | null,
htScoreAway: number | null,
): { ms: string; ou25: string; btts: string; htft: string } {
const ms = scoreHome > scoreAway ? '1' : scoreHome < scoreAway ? '2' : 'X';
const ou25 = scoreHome + scoreAway > 2.5 ? 'Over' : 'Under';
const btts = scoreHome > 0 && scoreAway > 0 ? 'Yes' : 'No';
const ms = scoreHome > scoreAway ? "1" : scoreHome < scoreAway ? "2" : "X";
const ou25 = scoreHome + scoreAway > 2.5 ? "Over" : "Under";
const btts = scoreHome > 0 && scoreAway > 0 ? "Yes" : "No";
let htft = 'unknown';
let htft = "unknown";
if (htScoreHome !== null && htScoreAway !== null) {
const htResult =
htScoreHome > htScoreAway ? '1' : htScoreHome < htScoreAway ? '2' : 'X';
htScoreHome > htScoreAway ? "1" : htScoreHome < htScoreAway ? "2" : "X";
htft = `${htResult}/${ms}`;
}
@@ -78,7 +78,7 @@ function extractPrediction(response: unknown): {
ms: string;
ou25: string;
btts: string;
probs: BacktestResult['probabilities'];
probs: BacktestResult["probabilities"];
mainPick: string;
mainMarket: string;
} {
@@ -87,9 +87,9 @@ function extractPrediction(response: unknown): {
const mainPickObj = data?.main_pick as Record<string, unknown> | undefined;
const mainPick =
typeof mainPickObj?.pick === 'string' ? mainPickObj.pick : '';
typeof mainPickObj?.pick === "string" ? mainPickObj.pick : "";
const mainMarket =
typeof mainPickObj?.market === 'string' ? mainPickObj.market : '';
typeof mainPickObj?.market === "string" ? mainPickObj.market : "";
// Extract MS from probabilities or main pick
const msProbs = (predictions?.ms || data?.ms || {}) as Record<
@@ -97,27 +97,27 @@ function extractPrediction(response: unknown): {
unknown
>;
const homeProb =
typeof msProbs['1'] === 'number'
? msProbs['1']
: typeof msProbs.home_prob === 'number'
typeof msProbs["1"] === "number"
? msProbs["1"]
: typeof msProbs.home_prob === "number"
? msProbs.home_prob
: 0;
const drawProb =
typeof msProbs['X'] === 'number'
? msProbs['X']
: typeof msProbs.draw_prob === 'number'
typeof msProbs["X"] === "number"
? msProbs["X"]
: typeof msProbs.draw_prob === "number"
? msProbs.draw_prob
: 0;
const awayProb =
typeof msProbs['2'] === 'number'
? msProbs['2']
: typeof msProbs.away_prob === 'number'
typeof msProbs["2"] === "number"
? msProbs["2"]
: typeof msProbs.away_prob === "number"
? msProbs.away_prob
: 0;
let ms = '1';
if (drawProb > homeProb && drawProb > awayProb) ms = 'X';
else if (awayProb > homeProb) ms = '2';
let ms = "1";
if (drawProb > homeProb && drawProb > awayProb) ms = "X";
else if (awayProb > homeProb) ms = "2";
// Extract OU25
const ou25Probs = (predictions?.ou25 || data?.ou25 || {}) as Record<
@@ -125,18 +125,18 @@ function extractPrediction(response: unknown): {
unknown
>;
const overProb =
typeof ou25Probs.Over === 'number'
typeof ou25Probs.Over === "number"
? ou25Probs.Over
: typeof ou25Probs.over_prob === 'number'
: typeof ou25Probs.over_prob === "number"
? ou25Probs.over_prob
: 0;
const underProb =
typeof ou25Probs.Under === 'number'
typeof ou25Probs.Under === "number"
? ou25Probs.Under
: typeof ou25Probs.under_prob === 'number'
: typeof ou25Probs.under_prob === "number"
? ou25Probs.under_prob
: 0;
const ou25 = overProb > underProb ? 'Over' : 'Under';
const ou25 = overProb > underProb ? "Over" : "Under";
// Extract BTTS
const bttsProbs = (predictions?.btts || data?.btts || {}) as Record<
@@ -144,18 +144,18 @@ function extractPrediction(response: unknown): {
unknown
>;
const bttsYes =
typeof bttsProbs.Yes === 'number'
typeof bttsProbs.Yes === "number"
? bttsProbs.Yes
: typeof bttsProbs.yes_prob === 'number'
: typeof bttsProbs.yes_prob === "number"
? bttsProbs.yes_prob
: 0;
const bttsNo =
typeof bttsProbs.No === 'number'
typeof bttsProbs.No === "number"
? bttsProbs.No
: typeof bttsProbs.no_prob === 'number'
: typeof bttsProbs.no_prob === "number"
? bttsProbs.no_prob
: 0;
const btts = bttsYes > bttsNo ? 'Yes' : 'No';
const btts = bttsYes > bttsNo ? "Yes" : "No";
return {
ms,
@@ -197,11 +197,11 @@ async function processBatch(batch: TestMatch[]): Promise<BacktestResult[]> {
// Check main pick
let mainPickCorrect = false;
if (pred.mainMarket === 'MS') {
if (pred.mainMarket === "MS") {
mainPickCorrect = pred.mainPick === actual.ms;
} else if (pred.mainMarket === 'OU25') {
} else if (pred.mainMarket === "OU25") {
mainPickCorrect = pred.mainPick === actual.ou25;
} else if (pred.mainMarket === 'BTTS') {
} else if (pred.mainMarket === "BTTS") {
mainPickCorrect = pred.mainPick === actual.btts;
}
@@ -226,8 +226,8 @@ async function processBatch(batch: TestMatch[]): Promise<BacktestResult[]> {
// ═══════════════════════════════════════════════════════
async function runBacktest(): Promise<void> {
console.log('🎯 BACKTEST ACCURACY — V30 Betting Engine');
console.log('════════════════════════════════════════════════════════');
console.log("🎯 BACKTEST ACCURACY — V30 Betting Engine");
console.log("════════════════════════════════════════════════════════");
// 1. Health check
try {
@@ -236,12 +236,12 @@ async function runBacktest(): Promise<void> {
});
console.log(`✅ AI Engine: ${JSON.stringify(health.data)}`);
} catch {
console.error('❌ AI Engine not reachable at', AI_ENGINE_URL);
console.error("❌ AI Engine not reachable at", AI_ENGINE_URL);
process.exit(1);
}
// 2. Load finished matches with features
console.log('\n📥 Loading test matches...');
console.log("\n📥 Loading test matches...");
const matches = await prisma.$queryRaw<TestMatch[]>`
SELECT m.id, m.score_home AS "scoreHome", m.score_away AS "scoreAway",
m.ht_score_home AS "htScoreHome", m.ht_score_away AS "htScoreAway"
@@ -259,7 +259,7 @@ async function runBacktest(): Promise<void> {
console.log(` 📊 Test matches: ${matches.length}`);
// 3. Run predictions in batches
console.log('\n🤖 Running predictions...');
console.log("\n🤖 Running predictions...");
const allResults: BacktestResult[] = [];
let processed = 0;
@@ -277,7 +277,7 @@ async function runBacktest(): Promise<void> {
allResults.length) *
100
).toFixed(1)
: '0';
: "0";
console.log(
` 📊 ${processed}/${matches.length} — Success: ${allResults.length} — MS Acc: ${currentMsAcc}%`,
);
@@ -287,7 +287,7 @@ async function runBacktest(): Promise<void> {
// 4. Calculate metrics
const total = allResults.length;
if (total === 0) {
console.error('❌ No results to analyze');
console.error("❌ No results to analyze");
process.exit(1);
}
@@ -303,22 +303,22 @@ async function runBacktest(): Promise<void> {
const mainPickCorrect = allResults.filter((r) => r.mainPickCorrect).length;
// Actual distribution
const actHome = allResults.filter((r) => r.actual.ms === '1').length;
const actDraw = allResults.filter((r) => r.actual.ms === 'X').length;
const actAway = allResults.filter((r) => r.actual.ms === '2').length;
const actHome = allResults.filter((r) => r.actual.ms === "1").length;
const actDraw = allResults.filter((r) => r.actual.ms === "X").length;
const actAway = allResults.filter((r) => r.actual.ms === "2").length;
// Predicted distribution
const predHome = allResults.filter((r) => r.predicted.ms === '1').length;
const predDraw = allResults.filter((r) => r.predicted.ms === 'X').length;
const predAway = allResults.filter((r) => r.predicted.ms === '2').length;
const predHome = allResults.filter((r) => r.predicted.ms === "1").length;
const predDraw = allResults.filter((r) => r.predicted.ms === "X").length;
const predAway = allResults.filter((r) => r.predicted.ms === "2").length;
// Confidence calibration (based on max probability)
const buckets: Record<string, { correct: number; total: number }> = {
'33-40%': { correct: 0, total: 0 },
'40-50%': { correct: 0, total: 0 },
'50-60%': { correct: 0, total: 0 },
'60-70%': { correct: 0, total: 0 },
'70%+': { correct: 0, total: 0 },
"33-40%": { correct: 0, total: 0 },
"40-50%": { correct: 0, total: 0 },
"50-60%": { correct: 0, total: 0 },
"60-70%": { correct: 0, total: 0 },
"70%+": { correct: 0, total: 0 },
};
for (const r of allResults) {
@@ -329,25 +329,25 @@ async function runBacktest(): Promise<void> {
);
const key =
maxProb >= 0.7
? '70%+'
? "70%+"
: maxProb >= 0.6
? '60-70%'
? "60-70%"
: maxProb >= 0.5
? '50-60%'
? "50-60%"
: maxProb >= 0.4
? '40-50%'
: '33-40%';
? "40-50%"
: "33-40%";
buckets[key].total++;
if (r.predicted.ms === r.actual.ms) buckets[key].correct++;
}
// 5. Print Report
console.log('\n════════════════════════════════════════════════════════');
console.log('📊 BACKTEST ACCURACY REPORT');
console.log('════════════════════════════════════════════════════════');
console.log("\n════════════════════════════════════════════════════════");
console.log("📊 BACKTEST ACCURACY REPORT");
console.log("════════════════════════════════════════════════════════");
console.log(` Total Matches Analyzed: ${total}`);
console.log('');
console.log(' 🎯 Market Accuracy:');
console.log("");
console.log(" 🎯 Market Accuracy:");
console.log(
` ⚽ Match Result (MS): ${((msCorrect / total) * 100).toFixed(2)}% (${msCorrect}/${total})`,
);
@@ -361,7 +361,7 @@ async function runBacktest(): Promise<void> {
` 🏆 Main Pick Success: ${((mainPickCorrect / total) * 100).toFixed(2)}% (${mainPickCorrect}/${total})`,
);
console.log('\n 📊 MS Distribution:');
console.log("\n 📊 MS Distribution:");
console.log(
` Actual: 1: ${actHome} (${((actHome / total) * 100).toFixed(1)}%) | X: ${actDraw} (${((actDraw / total) * 100).toFixed(1)}%) | 2: ${actAway} (${((actAway / total) * 100).toFixed(1)}%)`,
);
@@ -369,21 +369,21 @@ async function runBacktest(): Promise<void> {
` Predicted: 1: ${predHome} (${((predHome / total) * 100).toFixed(1)}%) | X: ${predDraw} (${((predDraw / total) * 100).toFixed(1)}%) | 2: ${predAway} (${((predAway / total) * 100).toFixed(1)}%)`,
);
console.log('\n 📊 Confidence Calibration:');
console.log("\n 📊 Confidence Calibration:");
for (const [range, bucket] of Object.entries(buckets)) {
if (bucket.total === 0) continue;
const acc = (bucket.correct / bucket.total) * 100;
const bar = '█'.repeat(Math.round(acc / 3));
const bar = "█".repeat(Math.round(acc / 3));
console.log(
` ${range.padEnd(8)} : ${acc.toFixed(1)}% acc (n=${bucket.total}) ${bar}`,
);
}
// 6. Per-market deep dive
console.log('\n 📊 OU25 Breakdown:');
const actOver = allResults.filter((r) => r.actual.ou25 === 'Over').length;
console.log("\n 📊 OU25 Breakdown:");
const actOver = allResults.filter((r) => r.actual.ou25 === "Over").length;
const actUnder = total - actOver;
const predOver = allResults.filter((r) => r.predicted.ou25 === 'Over').length;
const predOver = allResults.filter((r) => r.predicted.ou25 === "Over").length;
const predUnder = total - predOver;
console.log(
` Actual: Over: ${actOver} (${((actOver / total) * 100).toFixed(1)}%) | Under: ${actUnder} (${((actUnder / total) * 100).toFixed(1)}%)`,
@@ -392,11 +392,11 @@ async function runBacktest(): Promise<void> {
` Predicted: Over: ${predOver} (${((predOver / total) * 100).toFixed(1)}%) | Under: ${predUnder} (${((predUnder / total) * 100).toFixed(1)}%)`,
);
console.log('\n 📊 BTTS Breakdown:');
const actBttsYes = allResults.filter((r) => r.actual.btts === 'Yes').length;
console.log("\n 📊 BTTS Breakdown:");
const actBttsYes = allResults.filter((r) => r.actual.btts === "Yes").length;
const actBttsNo = total - actBttsYes;
const predBttsYes = allResults.filter(
(r) => r.predicted.btts === 'Yes',
(r) => r.predicted.btts === "Yes",
).length;
const predBttsNo = total - predBttsYes;
console.log(
@@ -406,14 +406,14 @@ async function runBacktest(): Promise<void> {
` Predicted: Yes: ${predBttsYes} (${((predBttsYes / total) * 100).toFixed(1)}%) | No: ${predBttsNo} (${((predBttsNo / total) * 100).toFixed(1)}%)`,
);
console.log('════════════════════════════════════════════════════════');
console.log('✅ Backtest complete!');
console.log("════════════════════════════════════════════════════════");
console.log("✅ Backtest complete!");
await prisma.$disconnect();
}
runBacktest().catch((err: unknown) => {
console.error('❌ Backtest failed:', err);
console.error("❌ Backtest failed:", err);
void prisma.$disconnect();
process.exit(1);
});
+12 -12
View File
@@ -9,18 +9,18 @@
* Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/batch-predict.ts
*/
import { PrismaClient } from '@prisma/client';
import axios from 'axios';
import { PrismaClient } from "@prisma/client";
import axios from "axios";
const prisma = new PrismaClient();
const AI_ENGINE_URL = process.env.AI_ENGINE_URL || 'http://127.0.0.1:3005';
const AI_ENGINE_URL = process.env.AI_ENGINE_URL || "http://127.0.0.1:3005";
const BATCH_SIZE = 5;
const MAX_MATCHES_TO_PROCESS = 1000; // Limit for local testing/batch capacity
async function runBatchPrediction() {
console.log('🗓 BATCH PREDICTION PIPELINE STARTING');
console.log('════════════════════════════════════════════════════════');
console.log("🗓 BATCH PREDICTION PIPELINE STARTING");
console.log("════════════════════════════════════════════════════════");
// 1. Health check
try {
@@ -30,20 +30,20 @@ async function runBatchPrediction() {
console.log(`✅ AI Engine Health: ${JSON.stringify(health.data)}`);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
console.error('❌ AI Engine not reachable at', AI_ENGINE_URL);
console.error("❌ AI Engine not reachable at", AI_ENGINE_URL);
process.exit(1);
}
// 2. Load upcoming matches (Not Started)
const upcomingMatches = await prisma.match.findMany({
where: {
status: 'NS',
status: "NS",
mstUtc: {
gte: Math.floor(Date.now() / 1000), // Future matches
},
sport: 'football',
sport: "football",
},
orderBy: { mstUtc: 'asc' },
orderBy: { mstUtc: "asc" },
take: MAX_MATCHES_TO_PROCESS,
select: {
id: true,
@@ -105,7 +105,7 @@ async function runBatchPrediction() {
const err = e as Error;
console.error(
` ❌ Failed for match ${match.id}:`,
err?.message || 'Unknown error',
err?.message || "Unknown error",
);
return false;
}
@@ -116,12 +116,12 @@ async function runBatchPrediction() {
processedCount += batch.length;
}
console.log('\n════════════════════════════════════════════════════════');
console.log("\n════════════════════════════════════════════════════════");
console.log(`🎉 BATCH PROCESS COMPLETE`);
console.log(` Total Processed: ${processedCount}`);
console.log(` Successfully Updated/Created: ${successCount}`);
console.log(` Failed: ${processedCount - successCount}`);
console.log('════════════════════════════════════════════════════════');
console.log("════════════════════════════════════════════════════════");
await prisma.$disconnect();
}
+11 -11
View File
@@ -1,9 +1,9 @@
import { PrismaClient } from '@prisma/client';
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
console.log('🔍 Checking for potential duplicate matches...');
console.log("🔍 Checking for potential duplicate matches...");
// Group by unique match characteristics
// Since we can't easily do GROUP BY with HAVING count > 1 in Prisma standard API without raw query,
@@ -35,7 +35,7 @@ async function main() {
if (duplicates.length === 0) {
console.log(
'✅ No duplicate matches found based on (HomeTeam + AwayTeam + Date).',
"✅ No duplicate matches found based on (HomeTeam + AwayTeam + Date).",
);
return;
}
@@ -56,7 +56,7 @@ async function main() {
console.log(
`📅 ${date} | ${homeTeam?.name} vs ${awayTeam?.name} (Count: ${group.count})`,
);
console.log(` IDs: ${group.ids.join(', ')}`);
console.log(` IDs: ${group.ids.join(", ")}`);
// Check details of the duplicates to see if one is complete and one is not
for (const id of group.ids) {
@@ -73,20 +73,20 @@ async function main() {
if (match) {
const counts = [
match.oddCategories.length > 0 ? 'Odds' : '',
match.footballTeamStats.length > 0 ? 'Stats' : '',
match.playerEvents.length > 0 ? 'Events' : '',
match.officials.length > 0 ? 'Officials' : '',
match.oddCategories.length > 0 ? "Odds" : "",
match.footballTeamStats.length > 0 ? "Stats" : "",
match.playerEvents.length > 0 ? "Events" : "",
match.officials.length > 0 ? "Officials" : "",
]
.filter(Boolean)
.join(', ');
.join(", ");
console.log(
` - [${id}] Status: ${match.status} | Score: ${match.scoreHome}-${match.scoreAway} | Data: ${counts || 'None'}`,
` - [${id}] Status: ${match.status} | Score: ${match.scoreHome}-${match.scoreAway} | Data: ${counts || "None"}`,
);
}
}
console.log('---------------------------------------------------');
console.log("---------------------------------------------------");
}
}
+20 -20
View File
@@ -5,29 +5,29 @@
* Kullanım: npx ts-node -r tsconfig-paths/register src/scripts/cleanup-live-matches.ts
*/
import { PrismaClient } from '@prisma/client';
import { PrismaClient } from "@prisma/client";
const FINISHED_STATUSES = ['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'];
const FINISHED_STATES = ['Finished', 'post', 'FT', 'postGame'];
const FINISHED_STATUSES = ["Finished", "Played", "FT", "AET", "PEN", "Ended"];
const FINISHED_STATES = ["Finished", "post", "FT", "postGame"];
const LIVE_STATUSES = [
'LIVE',
'1H',
'2H',
'HT',
'1Q',
'2Q',
'3Q',
'4Q',
'Playing',
'Half Time',
"LIVE",
"1H",
"2H",
"HT",
"1Q",
"2Q",
"3Q",
"4Q",
"Playing",
"Half Time",
];
const LIVE_STATES = ['live', 'firsthalf', 'secondhalf'];
const LIVE_STATES = ["live", "firsthalf", "secondhalf"];
async function cleanupLiveMatches() {
const prisma = new PrismaClient();
try {
console.log('🧹 Live matches temizliği başlıyor...');
console.log("🧹 Live matches temizliği başlıyor...");
const now = Date.now();
const finishedGraceMs = 6 * 60 * 60 * 1000;
@@ -51,7 +51,7 @@ async function cleanupLiveMatches() {
},
});
console.log('📊 Mevcut durum:');
console.log("📊 Mevcut durum:");
console.log(` Toplam live_matches: ${totalBefore}`);
console.log(` Geçmiş zamanlı kayıt: ${outdatedCount}`);
console.log(
@@ -83,7 +83,7 @@ async function cleanupLiveMatches() {
const totalAfter = await prisma.liveMatch.count();
console.log('\n✅ Temizlik tamamlandı!');
console.log("\n✅ Temizlik tamamlandı!");
console.log(` Silinen maç: ${deleted.count}`);
console.log(` Kalan maç: ${totalAfter}`);
@@ -93,12 +93,12 @@ async function cleanupLiveMatches() {
GROUP BY state
`;
console.log('\n📋 Kalan maçların durumları:');
console.log("\n📋 Kalan maçların durumları:");
(states as any).forEach((s: any) => {
console.log(` ${s.state || 'null'}: ${s.count}`);
console.log(` ${s.state || "null"}: ${s.count}`);
});
} catch (error) {
console.error('❌ Hata:', error);
console.error("❌ Hata:", error);
} finally {
await prisma.$disconnect();
}
+24 -24
View File
@@ -12,7 +12,7 @@
* Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/compute-elo-ratings.ts
*/
import { PrismaClient } from '@prisma/client';
import { PrismaClient } from "@prisma/client";
// ─────────────────────────────────────────────────────────────
// Types
@@ -72,9 +72,9 @@ function getResultChar(
scoreAway: number,
isHomeTeam: boolean,
): string {
if (scoreHome > scoreAway) return isHomeTeam ? 'W' : 'L';
if (scoreHome < scoreAway) return isHomeTeam ? 'L' : 'W';
return 'D';
if (scoreHome > scoreAway) return isHomeTeam ? "W" : "L";
if (scoreHome < scoreAway) return isHomeTeam ? "L" : "W";
return "D";
}
function calculateFormElo(recentResults: string[]): number {
@@ -86,7 +86,7 @@ function calculateFormElo(recentResults: string[]): number {
for (let i = 0; i < recentResults.length; i++) {
const weight = Math.pow(FORM_DECAY, i); // Most recent = highest weight
const result = recentResults[i];
const score = result === 'W' ? 3 : result === 'D' ? 1 : 0;
const score = result === "W" ? 3 : result === "D" ? 1 : 0;
formScore += score * weight;
totalWeight += 3 * weight; // Max possible per match
}
@@ -105,14 +105,14 @@ async function computeEloRatings(): Promise<void> {
const startTime = Date.now();
try {
console.log('🏟️ ELO Rating Computation — Starting...');
console.log('─'.repeat(60));
console.log("🏟️ ELO Rating Computation — Starting...");
console.log("─".repeat(60));
// 1. Fetch all finished football matches in chronological order
const matches: MatchRecord[] = await prisma.match.findMany({
where: {
sport: 'football',
status: 'FT',
sport: "football",
status: "FT",
scoreHome: { not: null },
scoreAway: { not: null },
homeTeamId: { not: null },
@@ -126,7 +126,7 @@ async function computeEloRatings(): Promise<void> {
scoreAway: true,
mstUtc: true,
},
orderBy: { mstUtc: 'asc' },
orderBy: { mstUtc: "asc" },
});
console.log(
@@ -228,7 +228,7 @@ async function computeEloRatings(): Promise<void> {
);
// 4. Bulk upsert to team_elo_ratings
console.log('💾 Writing to team_elo_ratings...');
console.log("💾 Writing to team_elo_ratings...");
const BATCH_SIZE = 500;
const teams = Array.from(eloMap.entries());
@@ -246,7 +246,7 @@ async function computeEloRatings(): Promise<void> {
awayElo: Math.round(state.awayElo * 10) / 10,
formElo: Math.round(state.formElo * 10) / 10,
matchesPlayed: state.matchesPlayed,
recentForm: state.recentResults.join(''),
recentForm: state.recentResults.join(""),
},
create: {
teamId,
@@ -255,7 +255,7 @@ async function computeEloRatings(): Promise<void> {
awayElo: Math.round(state.awayElo * 10) / 10,
formElo: Math.round(state.formElo * 10) / 10,
matchesPlayed: state.matchesPlayed,
recentForm: state.recentResults.join(''),
recentForm: state.recentResults.join(""),
},
}),
),
@@ -276,38 +276,38 @@ async function computeEloRatings(): Promise<void> {
.map((s) => s.overallElo)
.sort((a, b) => b - a);
console.log('─'.repeat(60));
console.log('📊 ELO Rating Summary:');
console.log("─".repeat(60));
console.log("📊 ELO Rating Summary:");
console.log(` Teams rated: ${eloMap.size.toLocaleString()}`);
console.log(` Matches used: ${processed.toLocaleString()}`);
console.log(` Highest ELO: ${overallElos[0]?.toFixed(1) ?? 'N/A'}`);
console.log(` Highest ELO: ${overallElos[0]?.toFixed(1) ?? "N/A"}`);
console.log(
` Lowest ELO: ${overallElos[overallElos.length - 1]?.toFixed(1) ?? 'N/A'}`,
` Lowest ELO: ${overallElos[overallElos.length - 1]?.toFixed(1) ?? "N/A"}`,
);
console.log(
` Median ELO: ${overallElos[Math.floor(overallElos.length / 2)]?.toFixed(1) ?? 'N/A'}`,
` Median ELO: ${overallElos[Math.floor(overallElos.length / 2)]?.toFixed(1) ?? "N/A"}`,
);
console.log(` Duration: ${elapsedTotal}s`);
console.log('─'.repeat(60));
console.log("─".repeat(60));
// Top 20 teams
const topTeams = await prisma.teamEloRating.findMany({
orderBy: { overallElo: 'desc' },
orderBy: { overallElo: "desc" },
take: 20,
include: { team: { select: { name: true } } },
});
console.log('\n🏆 Top 20 Teams by ELO:');
console.log("\n🏆 Top 20 Teams by ELO:");
topTeams.forEach((t, i) => {
const form = t.recentForm.split('').join('-');
const form = t.recentForm.split("").join("-");
console.log(
` ${String(i + 1).padStart(2)}. ${t.team.name.padEnd(25)} Overall: ${t.overallElo.toFixed(1).padStart(7)} Home: ${t.homeElo.toFixed(1).padStart(7)} Away: ${t.awayElo.toFixed(1).padStart(7)} Form: ${form}`,
);
});
console.log('\n✅ Done!');
console.log("\n✅ Done!");
} catch (error) {
console.error('❌ ELO computation failed:', error);
console.error("❌ ELO computation failed:", error);
process.exit(1);
} finally {
await prisma.$disconnect();
+170 -165
View File
@@ -1,9 +1,9 @@
import 'reflect-metadata';
import { mkdirSync, writeFileSync } from 'node:fs';
import * as path from 'node:path';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from '../app.module';
import "reflect-metadata";
import { mkdirSync, writeFileSync } from "node:fs";
import * as path from "node:path";
import { NestFactory } from "@nestjs/core";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "../app.module";
type JsonRecord = Record<string, unknown>;
type SwaggerPaths = Record<string, Record<string, JsonRecord>>;
@@ -14,7 +14,7 @@ interface PostmanResponse {
originalRequest: JsonRecord;
status: string;
code: number;
_postman_previewlanguage: 'json';
_postman_previewlanguage: "json";
header: Array<{ key: string; value: string }>;
body: string;
}
@@ -28,7 +28,7 @@ interface PostmanItem {
interface AiEndpointDefinition {
name: string;
method: 'GET' | 'POST';
method: "GET" | "POST";
path: string;
description: string;
query?: Array<{ key: string; value: string; description: string }>;
@@ -40,7 +40,7 @@ function refName(ref: string | undefined): string | null {
if (!ref) {
return null;
}
const parts = ref.split('/');
const parts = ref.split("/");
return parts[parts.length - 1] ?? null;
}
@@ -48,12 +48,13 @@ function resolveSchema(
schema: unknown,
schemas: SwaggerSchemas,
): JsonRecord | null {
if (!schema || typeof schema !== 'object') {
if (!schema || typeof schema !== "object") {
return null;
}
const schemaObject = schema as JsonRecord;
const schemaRef = typeof schemaObject.$ref === 'string' ? schemaObject.$ref : null;
const schemaRef =
typeof schemaObject.$ref === "string" ? schemaObject.$ref : null;
if (schemaRef) {
const name = refName(schemaRef);
return name ? (schemas[name] ?? null) : null;
@@ -73,31 +74,31 @@ function examplePrimitive(schema: JsonRecord): unknown {
return schema.enum[0];
}
const type = typeof schema.type === 'string' ? schema.type : 'string';
const format = typeof schema.format === 'string' ? schema.format : '';
const type = typeof schema.type === "string" ? schema.type : "string";
const format = typeof schema.format === "string" ? schema.format : "";
if (type === 'string') {
if (format === 'email') {
return 'user@example.com';
if (type === "string") {
if (format === "email") {
return "user@example.com";
}
if (format === 'date-time') {
return '2026-04-14T00:00:00.000Z';
if (format === "date-time") {
return "2026-04-14T00:00:00.000Z";
}
if (format === 'date') {
return '2026-04-14';
if (format === "date") {
return "2026-04-14";
}
if (format === 'uuid') {
return '11111111-1111-1111-1111-111111111111';
if (format === "uuid") {
return "11111111-1111-1111-1111-111111111111";
}
return 'string';
return "string";
}
if (type === 'integer' || type === 'number') {
if (type === "integer" || type === "number") {
return 1;
}
if (type === 'boolean') {
if (type === "boolean") {
return true;
}
return 'string';
return "string";
}
function buildExampleFromSchema(
@@ -110,7 +111,7 @@ function buildExampleFromSchema(
return null;
}
const schemaRef = typeof resolved.$ref === 'string' ? resolved.$ref : null;
const schemaRef = typeof resolved.$ref === "string" ? resolved.$ref : null;
if (schemaRef) {
const name = refName(schemaRef);
if (!name || visited.has(name)) {
@@ -124,7 +125,7 @@ function buildExampleFromSchema(
if (Array.isArray(resolved.allOf) && resolved.allOf.length > 0) {
return resolved.allOf.reduce<JsonRecord>((accumulator, part) => {
const partial = buildExampleFromSchema(part, schemas, visited);
if (partial && typeof partial === 'object' && !Array.isArray(partial)) {
if (partial && typeof partial === "object" && !Array.isArray(partial)) {
return { ...accumulator, ...(partial as JsonRecord) };
}
return accumulator;
@@ -139,12 +140,12 @@ function buildExampleFromSchema(
return buildExampleFromSchema(resolved.anyOf[0], schemas, visited);
}
const type = typeof resolved.type === 'string' ? resolved.type : 'object';
if (type === 'array') {
const type = typeof resolved.type === "string" ? resolved.type : "object";
if (type === "array") {
return [buildExampleFromSchema(resolved.items, schemas, visited)];
}
if (type === 'object' || resolved.properties) {
if (type === "object" || resolved.properties) {
const properties = (resolved.properties ?? {}) as JsonRecord;
const output: JsonRecord = {};
for (const [key, value] of Object.entries(properties)) {
@@ -159,23 +160,23 @@ function buildExampleFromSchema(
}
function swaggerSchemaFromContent(content: unknown): unknown {
if (!content || typeof content !== 'object') {
if (!content || typeof content !== "object") {
return null;
}
const contentObject = content as JsonRecord;
const jsonContent = contentObject['application/json'];
if (jsonContent && typeof jsonContent === 'object') {
const jsonContent = contentObject["application/json"];
if (jsonContent && typeof jsonContent === "object") {
return (jsonContent as JsonRecord).schema ?? null;
}
const firstContent = Object.values(contentObject)[0];
if (firstContent && typeof firstContent === 'object') {
if (firstContent && typeof firstContent === "object") {
return (firstContent as JsonRecord).schema ?? null;
}
return null;
}
function toPostmanPath(pathname: string): string {
return pathname.replace(/\{([^}]+)\}/g, '{{$1}}');
return pathname.replace(/\{([^}]+)\}/g, "{{$1}}");
}
function buildRequestBody(
@@ -213,25 +214,26 @@ function buildResponses(
name: `${method.toUpperCase()} ${rawPath} - ${statusCode}`,
originalRequest: {
method: method.toUpperCase(),
header: [{ key: 'Content-Type', value: 'application/json' }],
header: [{ key: "Content-Type", value: "application/json" }],
body: body
? {
mode: 'raw',
mode: "raw",
raw: body,
}
: undefined,
url: {
raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`,
host: [`{{${baseUrlVariable}}}`],
path: rawPath.split('/').filter(Boolean),
path: rawPath.split("/").filter(Boolean),
},
},
status: typeof responseRecord.description === 'string'
? responseRecord.description
: `HTTP ${statusCode}`,
status:
typeof responseRecord.description === "string"
? responseRecord.description
: `HTTP ${statusCode}`,
code: Number.isFinite(numericStatus) ? numericStatus : 200,
_postman_previewlanguage: 'json',
header: [{ key: 'Content-Type', value: 'application/json' }],
_postman_previewlanguage: "json",
header: [{ key: "Content-Type", value: "application/json" }],
body: JSON.stringify(example ?? {}, null, 2),
};
});
@@ -243,14 +245,14 @@ function buildQueryParams(operation: JsonRecord): Array<JsonRecord> {
: [];
return parameters
.filter((parameter) => parameter.in === 'query')
.filter((parameter) => parameter.in === "query")
.map((parameter) => ({
key: String(parameter.name ?? ''),
key: String(parameter.name ?? ""),
value:
parameter.schema && typeof parameter.schema === 'object'
? String(((parameter.schema as JsonRecord).default ?? ''))
: '',
description: String(parameter.description ?? ''),
parameter.schema && typeof parameter.schema === "object"
? String((parameter.schema as JsonRecord).default ?? "")
: "",
description: String(parameter.description ?? ""),
disabled: parameter.required === true ? false : true,
}));
}
@@ -258,8 +260,8 @@ function buildQueryParams(operation: JsonRecord): Array<JsonRecord> {
function buildHeaders(operation: JsonRecord): Array<JsonRecord> {
const headers: Array<JsonRecord> = [
{
key: 'Content-Type',
value: 'application/json',
key: "Content-Type",
value: "application/json",
},
];
@@ -268,8 +270,8 @@ function buildHeaders(operation: JsonRecord): Array<JsonRecord> {
: [];
if (security.length > 0) {
headers.push({
key: 'Authorization',
value: 'Bearer {{accessToken}}',
key: "Authorization",
value: "Bearer {{accessToken}}",
});
}
@@ -292,20 +294,22 @@ function createRequestItem(
method: method.toUpperCase(),
header: headers,
description:
typeof operation.description === 'string'
typeof operation.description === "string"
? operation.description
: (typeof operation.summary === 'string' ? operation.summary : ''),
: typeof operation.summary === "string"
? operation.summary
: "",
url: {
raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`,
host: [`{{${baseUrlVariable}}}`],
path: rawPath.split('/').filter(Boolean),
path: rawPath.split("/").filter(Boolean),
query,
},
};
if (body) {
request.body = {
mode: 'raw',
mode: "raw",
raw: body,
};
}
@@ -335,18 +339,19 @@ function buildNestFolders(document: JsonRecord): PostmanItem[] {
for (const [rawPath, pathItem] of Object.entries(paths)) {
for (const [method, operationObject] of Object.entries(pathItem)) {
if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
if (!["get", "post", "put", "patch", "delete"].includes(method)) {
continue;
}
const operation = operationObject as JsonRecord;
const operation = operationObject;
const tags = Array.isArray(operation.tags) ? operation.tags : [];
const folderName =
typeof tags[0] === 'string' && tags[0].trim().length > 0
typeof tags[0] === "string" && tags[0].trim().length > 0
? tags[0]
: 'Misc';
: "Misc";
const requestName =
typeof operation.summary === 'string' && operation.summary.trim().length > 0
typeof operation.summary === "string" &&
operation.summary.trim().length > 0
? operation.summary
: `${method.toUpperCase()} ${rawPath}`;
@@ -354,7 +359,7 @@ function buildNestFolders(document: JsonRecord): PostmanItem[] {
requestName,
method,
rawPath,
'beBaseUrl',
"beBaseUrl",
operation,
safeSchemas,
);
@@ -379,8 +384,8 @@ function createAiRequest(
): PostmanItem {
const url: JsonRecord = {
raw: `{{aiBaseUrl}}${endpoint.path}`,
host: ['{{aiBaseUrl}}'],
path: endpoint.path.split('/').filter(Boolean),
host: ["{{aiBaseUrl}}"],
path: endpoint.path.split("/").filter(Boolean),
};
if (endpoint.query && endpoint.query.length > 0) {
@@ -393,14 +398,14 @@ function createAiRequest(
const request: JsonRecord = {
method: endpoint.method,
header: [{ key: 'Content-Type', value: 'application/json' }],
header: [{ key: "Content-Type", value: "application/json" }],
description: endpoint.description,
url,
};
if (endpoint.body) {
request.body = {
mode: 'raw',
mode: "raw",
raw: JSON.stringify(endpoint.body, null, 2),
};
}
@@ -412,10 +417,10 @@ function createAiRequest(
{
name: `${endpoint.method} ${endpoint.path}`,
originalRequest: request,
status: 'OK',
status: "OK",
code: 200,
_postman_previewlanguage: 'json',
header: [{ key: 'Content-Type', value: 'application/json' }],
_postman_previewlanguage: "json",
header: [{ key: "Content-Type", value: "application/json" }],
body: JSON.stringify(endpoint.response, null, 2),
},
],
@@ -425,105 +430,105 @@ function createAiRequest(
function buildAiFolder(): PostmanItem {
const v20Endpoints: AiEndpointDefinition[] = [
{
name: 'Root',
method: 'GET',
path: '/',
description: 'AI engine root status endpoint',
name: "Root",
method: "GET",
path: "/",
description: "AI engine root status endpoint",
response: {
status: 'Suggest-Bet AI Engine v20+',
engine: 'V20 Plus Single Match Orchestrator',
status: "Suggest-Bet AI Engine v20+",
engine: "V20 Plus Single Match Orchestrator",
},
},
{
name: 'Health',
method: 'GET',
path: '/health',
description: 'AI engine health endpoint',
response: { status: 'healthy', engine: 'v20plus', ready: true },
name: "Health",
method: "GET",
path: "/health",
description: "AI engine health endpoint",
response: { status: "healthy", engine: "v20plus", ready: true },
},
{
name: 'Analyze Match',
method: 'POST',
path: '/v20plus/analyze/{{match_id}}',
description: 'Full V20+ single match analysis',
name: "Analyze Match",
method: "POST",
path: "/v20plus/analyze/{{match_id}}",
description: "Full V20+ single match analysis",
response: {
model_version: 'v30.0',
match_info: { match_id: '{{match_id}}' },
main_pick: { market: 'OU25', pick: '2.5 Üst' },
model_version: "v30.0",
match_info: { match_id: "{{match_id}}" },
main_pick: { market: "OU25", pick: "2.5 Üst" },
market_board: {},
},
},
{
name: 'Analyze HTMS',
method: 'GET',
path: '/v20plus/analyze-htms/{{match_id}}',
description: 'Half-time result analysis endpoint',
response: { match_id: '{{match_id}}', market: 'HT' },
name: "Analyze HTMS",
method: "GET",
path: "/v20plus/analyze-htms/{{match_id}}",
description: "Half-time result analysis endpoint",
response: { match_id: "{{match_id}}", market: "HT" },
},
{
name: 'Analyze HTFT',
method: 'GET',
path: '/v20plus/analyze-htft/{{match_id}}',
description: 'Half-time/full-time analysis endpoint',
name: "Analyze HTFT",
method: "GET",
path: "/v20plus/analyze-htft/{{match_id}}",
description: "Half-time/full-time analysis endpoint",
query: [
{
key: 'timeout_sec',
value: '30',
description: 'Timeout between 3 and 120 seconds',
key: "timeout_sec",
value: "30",
description: "Timeout between 3 and 120 seconds",
},
],
response: {
engine: 'v20plus.1',
match_info: { match_id: '{{match_id}}' },
ht_ft_probs: { '1/1': 0.25, 'X/X': 0.18 },
engine: "v20plus.1",
match_info: { match_id: "{{match_id}}" },
ht_ft_probs: { "1/1": 0.25, "X/X": 0.18 },
},
},
{
name: 'Generate Coupon',
method: 'POST',
path: '/v20plus/coupon',
description: 'Generate V20+ coupon from selected matches',
name: "Generate Coupon",
method: "POST",
path: "/v20plus/coupon",
description: "Generate V20+ coupon from selected matches",
body: {
match_ids: ['match-1', 'match-2'],
strategy: 'BALANCED',
match_ids: ["match-1", "match-2"],
strategy: "BALANCED",
max_matches: 4,
min_confidence: 55,
},
response: {
success: true,
data: {
strategy: 'BALANCED',
strategy: "BALANCED",
bets: [],
},
},
},
{
name: 'Daily Banker',
method: 'GET',
path: '/v20plus/daily-banker',
description: 'Get daily banker picks',
name: "Daily Banker",
method: "GET",
path: "/v20plus/daily-banker",
description: "Get daily banker picks",
query: [
{
key: 'count',
value: '3',
description: 'Number of banker picks',
key: "count",
value: "3",
description: "Number of banker picks",
},
],
response: { count: 3, bankers: [] },
},
{
name: 'Reversal Watchlist',
method: 'GET',
path: '/v20plus/reversal-watchlist',
description: 'Reversal watchlist candidates',
name: "Reversal Watchlist",
method: "GET",
path: "/v20plus/reversal-watchlist",
description: "Reversal watchlist candidates",
query: [
{ key: 'count', value: '20', description: 'Result size' },
{ key: 'horizon_hours', value: '72', description: 'Future horizon' },
{ key: 'min_score', value: '45', description: 'Minimum score' },
{ key: "count", value: "20", description: "Result size" },
{ key: "horizon_hours", value: "72", description: "Future horizon" },
{ key: "min_score", value: "45", description: "Minimum score" },
{
key: 'top_leagues_only',
value: 'false',
description: 'Filter to top leagues',
key: "top_leagues_only",
value: "false",
description: "Filter to top leagues",
},
],
response: { count: 0, items: [] },
@@ -532,42 +537,42 @@ function buildAiFolder(): PostmanItem {
const v2Endpoints: AiEndpointDefinition[] = [
{
name: 'V2 Health',
method: 'GET',
path: '/v2/health',
description: 'V2 betting engine health',
name: "V2 Health",
method: "GET",
path: "/v2/health",
description: "V2 betting engine health",
response: {
status: 'healthy',
engine: 'v2.betting_engine',
status: "healthy",
engine: "v2.betting_engine",
models_loaded: true,
},
},
{
name: 'V2 Analyze Match',
method: 'POST',
path: '/v2/analyze/{{match_id}}',
description: 'V2 leakage-free match analysis',
name: "V2 Analyze Match",
method: "POST",
path: "/v2/analyze/{{match_id}}",
description: "V2 leakage-free match analysis",
response: {
model_version: 'v2.betting_engine',
match_info: { match_id: '{{match_id}}' },
main_pick: { market: 'MS', pick: '1' },
model_version: "v2.betting_engine",
match_info: { match_id: "{{match_id}}" },
main_pick: { market: "MS", pick: "1" },
market_board: {
MS: { pick: '1', confidence: 58.4 },
MS: { pick: "1", confidence: 58.4 },
},
},
},
];
return {
name: 'AI Engine',
name: "AI Engine",
item: [
{
name: 'V20+',
item: v20Endpoints.map((endpoint) => createAiRequest(endpoint, 'V20+')),
name: "V20+",
item: v20Endpoints.map((endpoint) => createAiRequest(endpoint, "V20+")),
},
{
name: 'V2',
item: v2Endpoints.map((endpoint) => createAiRequest(endpoint, 'V2')),
name: "V2",
item: v2Endpoints.map((endpoint) => createAiRequest(endpoint, "V2")),
},
],
};
@@ -575,21 +580,21 @@ function buildAiFolder(): PostmanItem {
async function run(): Promise<void> {
const projectRoot = process.cwd();
const outputDir = path.join(projectRoot, 'mds');
const outputDir = path.join(projectRoot, "mds");
const outputFile = path.join(
outputDir,
'suggest-bet-platform.postman_collection.json',
"suggest-bet-platform.postman_collection.json",
);
process.env.REDIS_ENABLED = 'true';
process.env.REDIS_ENABLED = "true";
const app = await NestFactory.create(AppModule, { logger: false });
app.setGlobalPrefix('api');
app.setGlobalPrefix("api");
const swaggerConfig = new DocumentBuilder()
.setTitle('Suggest Bet Backend API')
.setDescription('Postman collection export source')
.setVersion('1.0')
.setTitle("Suggest Bet Backend API")
.setDescription("Postman collection export source")
.setVersion("1.0")
.addBearerAuth()
.build();
@@ -600,25 +605,25 @@ async function run(): Promise<void> {
const collection: JsonRecord = {
info: {
name: 'Suggest-Bet Platform API',
name: "Suggest-Bet Platform API",
description:
'Auto-generated Postman collection for Nest backend and AI engine endpoints.',
"Auto-generated Postman collection for Nest backend and AI engine endpoints.",
schema:
'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
"https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
},
variable: [
{ key: 'beBaseUrl', value: 'http://localhost:3005' },
{ key: 'aiBaseUrl', value: 'http://localhost:8000' },
{ key: 'accessToken', value: '' },
{ key: 'match_id', value: 'sample-match-id' },
{ key: "beBaseUrl", value: "http://localhost:3005" },
{ key: "aiBaseUrl", value: "http://localhost:8000" },
{ key: "accessToken", value: "" },
{ key: "match_id", value: "sample-match-id" },
],
auth: {
type: 'bearer',
bearer: [{ key: 'token', value: '{{accessToken}}', type: 'string' }],
type: "bearer",
bearer: [{ key: "token", value: "{{accessToken}}", type: "string" }],
},
item: [
{
name: 'Nest API',
name: "Nest API",
item: buildNestFolders(document),
},
buildAiFolder(),
@@ -626,7 +631,7 @@ async function run(): Promise<void> {
};
mkdirSync(outputDir, { recursive: true });
writeFileSync(outputFile, JSON.stringify(collection, null, 2), 'utf8');
writeFileSync(outputFile, JSON.stringify(collection, null, 2), "utf8");
await app.close();
+88 -88
View File
@@ -1,20 +1,20 @@
import 'reflect-metadata';
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import * as path from 'node:path';
import ts from 'typescript';
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from '../app.module';
import "reflect-metadata";
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import * as path from "node:path";
import ts from "typescript";
import { NestFactory } from "@nestjs/core";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { AppModule } from "../app.module";
type HttpMethod =
| 'get'
| 'post'
| 'put'
| 'patch'
| 'delete'
| 'options'
| 'head'
| 'all';
| "get"
| "post"
| "put"
| "patch"
| "delete"
| "options"
| "head"
| "all";
interface TsDecoratorMeta {
name: string;
@@ -42,14 +42,14 @@ interface TsMethodMeta {
}
const HTTP_DECORATOR_TO_METHOD: Record<string, HttpMethod> = {
Get: 'get',
Post: 'post',
Put: 'put',
Patch: 'patch',
Delete: 'delete',
Options: 'options',
Head: 'head',
All: 'all',
Get: "get",
Post: "post",
Put: "put",
Patch: "patch",
Delete: "delete",
Options: "options",
Head: "head",
All: "all",
};
function getDecorators(node: ts.Node): readonly ts.Decorator[] {
@@ -105,7 +105,7 @@ function collectControllerFiles(dirPath: string): string[] {
continue;
}
if (entry.isFile() && entry.name.endsWith('.controller.ts')) {
if (entry.isFile() && entry.name.endsWith(".controller.ts")) {
files.push(absolutePath);
}
}
@@ -115,9 +115,9 @@ function collectControllerFiles(dirPath: string): string[] {
function normalizeRoutePart(value: string | undefined): string {
if (!value || value === "''" || value === '""') {
return '';
return "";
}
return value.trim().replace(/^\/+|\/+$/g, '');
return value.trim().replace(/^\/+|\/+$/g, "");
}
function buildSwaggerPath(
@@ -131,18 +131,18 @@ function buildSwaggerPath(
normalizeRoutePart(routePath),
].filter(Boolean);
return `/${parts.join('/')}`;
return `/${parts.join("/")}`;
}
function collectTsEndpointMetadata(
projectRoot: string,
): Map<string, TsMethodMeta> {
const modulesDir = path.join(projectRoot, 'src', 'modules');
const modulesDir = path.join(projectRoot, "src", "modules");
const controllerFiles = collectControllerFiles(modulesDir);
const metadataByOperationId = new Map<string, TsMethodMeta>();
for (const filePath of controllerFiles) {
const sourceText = readFileSync(filePath, 'utf8');
const sourceText = readFileSync(filePath, "utf8");
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
@@ -164,7 +164,7 @@ function collectTsEndpointMetadata(
);
const controllerDecorator = classDecorators.find(
(decorator) => decorator.name === 'Controller',
(decorator) => decorator.name === "Controller",
);
if (!controllerDecorator) {
return;
@@ -221,7 +221,7 @@ function collectTsEndpointMetadata(
routePath,
returnType,
hasPublicDecorator: methodDecorators.some(
(decorator) => decorator.name === 'Public',
(decorator) => decorator.name === "Public",
),
methodDecorators: methodDecorators.map((decorator) => decorator.name),
params,
@@ -235,10 +235,10 @@ function collectTsEndpointMetadata(
}
function refName(ref?: string): string | null {
if (!ref || typeof ref !== 'string') {
if (!ref || typeof ref !== "string") {
return null;
}
const parts = ref.split('/');
const parts = ref.split("/");
return parts[parts.length - 1] ?? null;
}
@@ -246,13 +246,13 @@ function collectSchemaRefs(
value: unknown,
refs = new Set<string>(),
): Set<string> {
if (!value || typeof value !== 'object') {
if (!value || typeof value !== "object") {
return refs;
}
const recordValue = value as Record<string, unknown>;
const maybeRef = recordValue.$ref;
if (typeof maybeRef === 'string') {
if (typeof maybeRef === "string") {
const name = refName(maybeRef);
if (name) {
refs.add(name);
@@ -267,22 +267,22 @@ function collectSchemaRefs(
}
function schemaTypeSummary(schema: unknown): string {
if (!schema || typeof schema !== 'object') {
return 'unknown';
if (!schema || typeof schema !== "object") {
return "unknown";
}
const schemaObj = schema as Record<string, unknown>;
if (typeof schemaObj.$ref === 'string') {
return refName(schemaObj.$ref) ?? 'unknown';
if (typeof schemaObj.$ref === "string") {
return refName(schemaObj.$ref) ?? "unknown";
}
const type = typeof schemaObj.type === 'string' ? schemaObj.type : 'object';
if (type === 'array') {
const type = typeof schemaObj.type === "string" ? schemaObj.type : "object";
if (type === "array") {
return `array<${schemaTypeSummary(schemaObj.items)}>`;
}
if (Array.isArray(schemaObj.enum) && schemaObj.enum.length > 0) {
return `${type}(${schemaObj.enum.join(' | ')})`;
return `${type}(${schemaObj.enum.join(" | ")})`;
}
return type;
@@ -295,35 +295,35 @@ function normalizeParameters(parameters: unknown[] = []) {
.map((parameter) => {
const schema = (parameter.schema ?? {}) as Record<string, unknown>;
return {
name: typeof parameter.name === 'string' ? parameter.name : '',
in: typeof parameter.in === 'string' ? parameter.in : '',
name: typeof parameter.name === "string" ? parameter.name : "",
in: typeof parameter.in === "string" ? parameter.in : "",
required: Boolean(parameter.required),
description:
typeof parameter.description === 'string'
typeof parameter.description === "string"
? parameter.description
: null,
type: schemaTypeSummary(schema),
enum: Array.isArray(schema.enum) ? schema.enum : [],
default: schema.default ?? null,
format: typeof schema.format === 'string' ? schema.format : null,
format: typeof schema.format === "string" ? schema.format : null,
};
});
return {
path: parsed.filter((item) => item.in === 'path'),
query: parsed.filter((item) => item.in === 'query'),
header: parsed.filter((item) => item.in === 'header'),
cookie: parsed.filter((item) => item.in === 'cookie'),
path: parsed.filter((item) => item.in === "path"),
query: parsed.filter((item) => item.in === "query"),
header: parsed.filter((item) => item.in === "header"),
cookie: parsed.filter((item) => item.in === "cookie"),
};
}
function normalizeRequestBody(requestBody: unknown) {
if (!requestBody || typeof requestBody !== 'object') {
if (!requestBody || typeof requestBody !== "object") {
return null;
}
const requestBodyObj = requestBody as Record<string, unknown>;
if (typeof requestBodyObj.$ref === 'string') {
if (typeof requestBodyObj.$ref === "string") {
return {
required: false,
contentTypes: [],
@@ -378,9 +378,9 @@ function normalizeResponses(responses: Record<string, unknown>) {
return {
status: Number(statusCode),
description:
typeof responseObj.description === 'string'
typeof responseObj.description === "string"
? responseObj.description
: '',
: "",
contentTypes,
schemaTypes,
schemaRefs: [...refs].sort(),
@@ -392,25 +392,25 @@ function normalizeResponses(responses: Record<string, unknown>) {
async function run() {
const projectRoot = process.cwd();
const outputDir = path.join(projectRoot, 'mds');
const outputDir = path.join(projectRoot, "mds");
const outputFile = path.join(
outputDir,
'backend_endpoints_swagger_summary.json',
"backend_endpoints_swagger_summary.json",
);
// Predictions module is conditionally loaded with REDIS_ENABLED in AppModule.
// Force-enable here to include all backend endpoints in one Swagger export.
process.env.REDIS_ENABLED = 'true';
process.env.REDIS_ENABLED = "true";
const tsMetadata = collectTsEndpointMetadata(projectRoot);
const app = await NestFactory.create(AppModule, { logger: false });
app.setGlobalPrefix('api');
app.setGlobalPrefix("api");
const swaggerConfig = new DocumentBuilder()
.setTitle('Suggest Bet Backend API')
.setDescription('Auto-generated endpoint summary from Swagger document')
.setVersion('1.0')
.setTitle("Suggest Bet Backend API")
.setDescription("Auto-generated endpoint summary from Swagger document")
.setVersion("1.0")
.addBearerAuth()
.build();
@@ -419,7 +419,7 @@ async function run() {
const endpoints: Array<Record<string, unknown>> = [];
const seenOperationIds = new Set<string>();
const globalPrefix = 'api';
const globalPrefix = "api";
const sortedPaths = Object.keys(paths).sort((a, b) => a.localeCompare(b));
for (const endpointPath of sortedPaths) {
@@ -427,7 +427,7 @@ async function run() {
const methods = Object.keys(pathItem)
.filter((method) =>
['get', 'post', 'put', 'patch', 'delete', 'options', 'head'].includes(
["get", "post", "put", "patch", "delete", "options", "head"].includes(
method,
),
)
@@ -436,7 +436,7 @@ async function run() {
for (const method of methods) {
const operation = pathItem[method] as Record<string, unknown>;
const operationId =
typeof operation.operationId === 'string' ? operation.operationId : '';
typeof operation.operationId === "string" ? operation.operationId : "";
if (operationId) {
seenOperationIds.add(operationId);
@@ -464,13 +464,13 @@ async function run() {
const tsBodyParams =
tsMeta?.params
.filter((param) =>
param.decorators.some((decorator) => decorator.name === 'Body'),
param.decorators.some((decorator) => decorator.name === "Body"),
)
.map((param) => ({
name: param.name,
type: param.type,
bodyKey:
param.decorators.find((decorator) => decorator.name === 'Body')
param.decorators.find((decorator) => decorator.name === "Body")
?.firstArg ?? null,
})) ?? [];
@@ -482,9 +482,9 @@ async function run() {
tag: tags[0] ?? null,
tags,
summary:
typeof operation.summary === 'string' ? operation.summary : null,
typeof operation.summary === "string" ? operation.summary : null,
description:
typeof operation.description === 'string'
typeof operation.description === "string"
? operation.description
: null,
auth: {
@@ -527,10 +527,10 @@ async function run() {
tsMeta.controllerRoute,
tsMeta.routePath,
),
tag: tsMeta.controller.replace(/Controller$/, ''),
tags: [tsMeta.controller.replace(/Controller$/, '')],
tag: tsMeta.controller.replace(/Controller$/, ""),
tags: [tsMeta.controller.replace(/Controller$/, "")],
summary: null,
description: 'Not present in generated Swagger document',
description: "Not present in generated Swagger document",
auth: {
swaggerSecurityRequired: null,
swaggerSecuritySchemes: [],
@@ -546,13 +546,13 @@ async function run() {
body: null,
tsBodyParams: tsMeta.params
.filter((param) =>
param.decorators.some((decorator) => decorator.name === 'Body'),
param.decorators.some((decorator) => decorator.name === "Body"),
)
.map((param) => ({
name: param.name,
type: param.type,
bodyKey:
param.decorators.find((decorator) => decorator.name === 'Body')
param.decorators.find((decorator) => decorator.name === "Body")
?.firstArg ?? null,
})),
},
@@ -569,19 +569,19 @@ async function run() {
}
endpoints.sort((a, b) => {
const pathA = typeof a.path === 'string' ? a.path : '';
const pathB = typeof b.path === 'string' ? b.path : '';
const pathA = typeof a.path === "string" ? a.path : "";
const pathB = typeof b.path === "string" ? b.path : "";
if (pathA !== pathB) {
return pathA.localeCompare(pathB);
}
return (typeof a.method === 'string' ? a.method : '').localeCompare(
typeof b.method === 'string' ? b.method : '',
return (typeof a.method === "string" ? a.method : "").localeCompare(
typeof b.method === "string" ? b.method : "",
);
});
const tagStats = new Map<string, number>();
for (const endpoint of endpoints) {
const tag = typeof endpoint.tag === 'string' ? endpoint.tag : 'Unknown';
const tag = typeof endpoint.tag === "string" ? endpoint.tag : "Unknown";
tagStats.set(tag, (tagStats.get(tag) ?? 0) + 1);
}
@@ -591,7 +591,7 @@ async function run() {
.body as Record<string, unknown> | null;
if (requestBody && Array.isArray(requestBody.schemaRefs)) {
for (const schemaName of requestBody.schemaRefs) {
if (typeof schemaName === 'string') {
if (typeof schemaName === "string") {
referencedSchemas.add(schemaName);
}
}
@@ -604,7 +604,7 @@ async function run() {
continue;
}
for (const schemaName of status.schemaRefs) {
if (typeof schemaName === 'string') {
if (typeof schemaName === "string") {
referencedSchemas.add(schemaName);
}
}
@@ -626,16 +626,16 @@ async function run() {
const summary = {
generatedAt: new Date().toISOString(),
generatedBy: 'src/scripts/export-swagger-endpoints-summary.ts',
project: 'Suggest-Bet-BE',
generatedBy: "src/scripts/export-swagger-endpoints-summary.ts",
project: "Suggest-Bet-BE",
swagger: {
docsPath: '/api/docs',
globalPrefix: '/api',
docsPath: "/api/docs",
globalPrefix: "/api",
endpointCountInSwagger: endpoints.filter((item) => item.inSwagger).length,
endpointCountTotal: endpoints.length,
warnings: [
'Swagger output reflects loaded modules for current environment.',
'This export forces REDIS_ENABLED=true to include conditional Prediction endpoints.',
"Swagger output reflects loaded modules for current environment.",
"This export forces REDIS_ENABLED=true to include conditional Prediction endpoints.",
],
},
stats: {
@@ -668,7 +668,7 @@ async function run() {
};
mkdirSync(outputDir, { recursive: true });
writeFileSync(outputFile, JSON.stringify(summary, null, 2), 'utf8');
writeFileSync(outputFile, JSON.stringify(summary, null, 2), "utf8");
await app.close();
@@ -680,7 +680,7 @@ async function run() {
}
void run().catch((error: unknown) => {
console.error('❌ Failed to export Swagger endpoint summary');
console.error("❌ Failed to export Swagger endpoint summary");
console.error(error);
process.exit(1);
+41 -41
View File
@@ -16,7 +16,7 @@
* Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/populate-feature-store.ts
*/
import { PrismaClient } from '@prisma/client';
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
@@ -180,16 +180,16 @@ function buildFormIndex(
const homeResult =
match.scoreHome > match.scoreAway
? 'W'
? "W"
: match.scoreHome < match.scoreAway
? 'L'
: 'D';
? "L"
: "D";
const awayResult =
match.scoreAway > match.scoreHome
? 'W'
? "W"
: match.scoreAway < match.scoreHome
? 'L'
: 'D';
? "L"
: "D";
homeState.results.unshift(homeResult);
awayState.results.unshift(awayResult);
@@ -222,14 +222,14 @@ function extractFormFeatures(formState: TeamFormState): {
let winStreak = 0;
for (const r of formState.results) {
if (r === 'W') winStreak++;
if (r === "W") winStreak++;
else break;
}
// Form score: (W=3, D=1, L=0) over last 5, normalized to 0-100
const last5Results = formState.results.slice(0, 5);
const points = last5Results.reduce(
(sum, r) => sum + (r === 'W' ? 3 : r === 'D' ? 1 : 0),
(sum, r) => sum + (r === "W" ? 3 : r === "D" ? 1 : 0),
0,
);
const maxPoints = last5Results.length * 3 || 1;
@@ -302,20 +302,20 @@ async function loadOddsIndex(): Promise<Map<string, OddsData>> {
let bttsY = 0;
for (const s of selections) {
if (s.cat === 'Maç Sonucu') {
if (s.sel === '1') msH = s.odds;
else if (s.sel === 'X' || s.sel === '0') msD = s.odds;
else if (s.sel === '2') msA = s.odds;
} else if (s.cat === 'Alt/Üst 2,5') {
if (s.cat === "Maç Sonucu") {
if (s.sel === "1") msH = s.odds;
else if (s.sel === "X" || s.sel === "0") msD = s.odds;
else if (s.sel === "2") msA = s.odds;
} else if (s.cat === "Alt/Üst 2,5") {
if (
s.sel.toLowerCase().includes('üst') ||
s.sel.toLowerCase().includes('over')
s.sel.toLowerCase().includes("üst") ||
s.sel.toLowerCase().includes("over")
)
ou25O = s.odds;
} else if (s.cat === 'Karşılıklı Gol') {
} else if (s.cat === "Karşılıklı Gol") {
if (
s.sel.toLowerCase().includes('var') ||
s.sel.toLowerCase().includes('yes')
s.sel.toLowerCase().includes("var") ||
s.sel.toLowerCase().includes("yes")
)
bttsY = s.odds;
}
@@ -411,7 +411,7 @@ function buildLeagueIndex(matches: MatchRow[]): Map<string, LeagueStats> {
const leagueMap = new Map<string, LeagueStats>();
for (const match of matches) {
const key = match.leagueId ?? 'unknown';
const key = match.leagueId ?? "unknown";
let stats = leagueMap.get(key);
if (!stats) {
stats = { totalMatches: 0, totalGoals: 0, homeWins: 0, over25Count: 0 };
@@ -520,15 +520,15 @@ async function populateFeatureStore(): Promise<void> {
const startTime = Date.now();
try {
console.log('🧠 Feature Store Population — Starting...');
console.log('─'.repeat(60));
console.log("🧠 Feature Store Population — Starting...");
console.log("─".repeat(60));
// Load all finished football matches
console.log('📥 Loading matches...');
console.log("📥 Loading matches...");
const rawMatches = await prisma.match.findMany({
where: {
sport: 'football',
status: 'FT',
sport: "football",
status: "FT",
scoreHome: { not: null },
scoreAway: { not: null },
homeTeamId: { not: null },
@@ -543,7 +543,7 @@ async function populateFeatureStore(): Promise<void> {
scoreAway: true,
mstUtc: true,
},
orderBy: { mstUtc: 'asc' },
orderBy: { mstUtc: "asc" },
});
const matches: MatchRow[] = rawMatches.map((m) => ({
@@ -559,31 +559,31 @@ async function populateFeatureStore(): Promise<void> {
console.log(` 📊 Matches loaded: ${matches.length.toLocaleString()}`);
// Pre-compute all indexes
console.log('\n📊 Building feature indexes...');
console.log("\n📊 Building feature indexes...");
console.log(' 🏅 Pillar 1: Loading ELO ratings...');
console.log(" 🏅 Pillar 1: Loading ELO ratings...");
const eloMap = await loadEloMap();
console.log(' 📈 Pillar 2: Building form index...');
console.log(" 📈 Pillar 2: Building form index...");
const formIndex = buildFormIndex(matches);
console.log(' 💰 Pillar 3: Loading odds data...');
console.log(" 💰 Pillar 3: Loading odds data...");
const oddsIndex = await loadOddsIndex();
console.log(' ⚔️ Pillar 5: Building H2H index...');
console.log(" ⚔️ Pillar 5: Building H2H index...");
const h2hIndex = buildH2HIndex(matches);
console.log(' 📋 Pillar 6: Loading referee data...');
console.log(" 📋 Pillar 6: Loading referee data...");
const refereeIndex = await loadRefereeIndex(matches);
console.log(' 🏟️ Pillar 7: Building league DNA...');
console.log(" 🏟️ Pillar 7: Building league DNA...");
const leagueIndex = buildLeagueIndex(matches);
console.log('\n✅ All indexes built!');
console.log('─'.repeat(60));
console.log("\n✅ All indexes built!");
console.log("─".repeat(60));
// Build feature vectors and batch upsert
console.log('💾 Writing features to database...');
console.log("💾 Writing features to database...");
const BATCH_SIZE = 1000;
let processed = 0;
@@ -651,7 +651,7 @@ async function populateFeatureStore(): Promise<void> {
const refTotal = refStats?.totalMatches ?? 0;
// Pillar 7: League DNA
const leagueKey = match.leagueId ?? 'unknown';
const leagueKey = match.leagueId ?? "unknown";
const leagueStats = leagueIndex.get(leagueKey) ?? {
totalMatches: 1,
totalGoals: 0,
@@ -730,7 +730,7 @@ async function populateFeatureStore(): Promise<void> {
),
// Meta
missingPlayersImpact: 0,
calculatorVer: 'v2.0',
calculatorVer: "v2.0",
});
}
@@ -749,7 +749,7 @@ async function populateFeatureStore(): Promise<void> {
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log('─'.repeat(60));
console.log("─".repeat(60));
console.log(`✅ Feature Store population complete!`);
console.log(` Features written: ${processed.toLocaleString()}`);
console.log(` Skipped: ${skipped}`);
@@ -758,9 +758,9 @@ async function populateFeatureStore(): Promise<void> {
// Verify
const count = await prisma.footballAiFeature.count();
console.log(` DB row count: ${count.toLocaleString()}`);
console.log('─'.repeat(60));
console.log("─".repeat(60));
} catch (error) {
console.error('❌ Feature store population failed:', error);
console.error("❌ Feature store population failed:", error);
process.exit(1);
} finally {
await prisma.$disconnect();
+4 -3
View File
@@ -1,5 +1,6 @@
process.env.PORT = process.env.PORT || '3005';
process.env.AI_ENGINE_URL = process.env.AI_ENGINE_URL || 'http://127.0.0.1:8000';
process.env.PORT = process.env.PORT || "3005";
process.env.AI_ENGINE_URL =
process.env.AI_ENGINE_URL || "http://127.0.0.1:8000";
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('./run-full-stack');
require("./run-full-stack");

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