first (part 3: src directory)
Deploy Iddaai Backend / build-and-deploy (push) Successful in 33s

This commit is contained in:
2026-04-16 15:12:27 +03:00
parent 2f0b85a0c7
commit 182f4aae16
125 changed files with 22552 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
+255
View File
@@ -0,0 +1,255 @@
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';
// Config
import {
appConfig,
databaseConfig,
jwtConfig,
redisConfig,
i18nConfig,
featuresConfig,
throttleConfig,
} 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';
// Database
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';
// 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';
// Services and Tasks
import { ServicesModule } from './services/services.module';
import { TasksModule } from './tasks/tasks.module';
// Feeder Module (Historical Data Scraping)
import { FeederModule } from './modules/feeder/feeder.module';
// Guards
import {
JwtAuthGuard,
RolesGuard,
PermissionsGuard,
} from './modules/auth/guards';
// Queue
import { QueueModule } from './common/queues/queue.module';
const redisEnabled = process.env.REDIS_ENABLED === 'true';
const historicalFeederMode = process.env.FEEDER_MODE === 'historical';
@Module({
imports: [
// Configuration
ConfigModule.forRoot({
isGlobal: true,
validate: validateEnv,
load: [
appConfig,
databaseConfig,
jwtConfig,
redisConfig,
i18nConfig,
featuresConfig,
throttleConfig,
geminiConfig,
],
}),
// Global Queue Configuration (optional)
...(redisEnabled ? [QueueModule] : []),
// Static Assets (Images, Uploads)
ServeStaticModule.forRoot({
rootPath: path.join(__dirname, '..', 'public'),
serveRoot: '/', // This means public/uploads/x.png -> /uploads/x.png
}),
// Logger (Structured Logging with Pino)
LoggerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
return {
pinoHttp: {
level: configService.get('app.isDevelopment') ? 'debug' : 'info',
transport: configService.get('app.isDevelopment')
? {
target: 'pino-pretty',
options: {
singleLine: true,
},
}
: undefined,
},
};
},
}),
// i18n
I18nModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
fallbackLanguage: configService.get('i18n.fallbackLanguage', 'en'),
loaderOptions: {
path: path.join(__dirname, '../i18n/'),
watch: configService.get('app.isDevelopment', true),
},
}),
resolvers: [
new HeaderResolver(['x-lang']),
new QueryResolver(['lang']),
AcceptLanguageResolver,
],
inject: [ConfigService],
}),
// Throttling
ThrottlerModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => [
{
ttl: configService.get('throttle.ttl', 60000),
limit: configService.get('throttle.limit', 100),
},
],
}),
// Caching (Redis with in-memory fallback)
CacheModule.registerAsync({
isGlobal: true,
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
// FORCE DISABLE REDIS if user doesn't want it
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),
},
ttl: 60 * 1000, // 1 minute default
});
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');
}
}
// Fallback to in-memory cache
console.log('📦 Using in-memory cache');
return {
ttl: 60 * 1000,
};
},
inject: [ConfigService],
}),
// Database
DatabaseModule,
// Scheduling (for cron jobs)
...(historicalFeederMode ? [] : [ScheduleModule.forRoot()]),
// Core Modules
AuthModule,
UsersModule,
AdminModule,
// Sports Domain Modules
MatchesModule,
PredictionsModule,
LeaguesModule,
AnalysisModule,
CouponsModule,
SporTotoModule,
// Services and Scheduled Tasks
ServicesModule,
...(historicalFeederMode ? [] : [TasksModule]),
// Optional Modules (controlled by env variables)
GeminiModule,
HealthModule,
SocialPosterModule,
// Feeder Module (Historical Data Scraping)
FeederModule,
],
providers: [
// Global Exception Filter
{
provide: APP_FILTER,
useClass: GlobalExceptionFilter,
},
// Global Response Interceptor
{
provide: APP_INTERCEPTOR,
useClass: ResponseInterceptor,
},
// Global Rate Limiting
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
// Global JWT Auth Guard
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
// Global Roles Guard
{
provide: APP_GUARD,
useClass: RolesGuard,
},
// Global Permissions Guard
{
provide: APP_GUARD,
useClass: PermissionsGuard,
},
],
})
export class AppModule {}
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
+128
View File
@@ -0,0 +1,128 @@
import {
Get,
Post,
Put,
Delete,
Param,
Query,
Body,
HttpCode,
ParseUUIDPipe,
} from '@nestjs/common';
import {
ApiOperation,
ApiOkResponse,
ApiNotFoundResponse,
ApiBadRequestResponse,
} from '@nestjs/swagger';
import { BaseService } from './base.service';
import { PaginationDto } from '../dto/pagination.dto';
import {
ApiResponse,
createSuccessResponse,
createPaginatedResponse,
} from '../types/api-response.type';
/**
* Generic base controller with common CRUD endpoints
* Extend this class for entity-specific controllers
*
* Note: Use decorators like @Controller() on the child class
*/
export abstract class BaseController<T, CreateDto, UpdateDto> {
constructor(
protected readonly service: BaseService<T, CreateDto, UpdateDto>,
protected readonly entityName: string,
) {}
@Get()
@HttpCode(200)
@ApiOperation({ summary: 'Get all records with pagination' })
@ApiOkResponse({ description: 'Records retrieved successfully' })
async findAll(
@Query() pagination: PaginationDto,
): Promise<ApiResponse<{ items: T[]; meta: any }>> {
const result = await this.service.findAll(pagination);
return createPaginatedResponse(
result.items,
result.meta.total,
result.meta.page,
result.meta.limit,
`${this.entityName} list retrieved successfully`,
);
}
@Get(':id')
@HttpCode(200)
@ApiOperation({ summary: 'Get a record by ID' })
@ApiOkResponse({ description: 'Record retrieved successfully' })
@ApiNotFoundResponse({ description: 'Record not found' })
async findOne(
@Param('id', ParseUUIDPipe) id: string,
): Promise<ApiResponse<T>> {
const result = await this.service.findOne(id);
return createSuccessResponse(
result,
`${this.entityName} retrieved successfully`,
);
}
@Post()
@HttpCode(200)
@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(
result,
`${this.entityName} created successfully`,
201,
);
}
@Put(':id')
@HttpCode(200)
@ApiOperation({ summary: 'Update an existing record' })
@ApiOkResponse({ description: 'Record updated successfully' })
@ApiNotFoundResponse({ description: 'Record not found' })
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateDto: UpdateDto,
): Promise<ApiResponse<T>> {
const result = await this.service.update(id, updateDto);
return createSuccessResponse(
result,
`${this.entityName} updated successfully`,
);
}
@Delete(':id')
@HttpCode(200)
@ApiOperation({ summary: 'Delete a record (soft delete)' })
@ApiOkResponse({ description: 'Record deleted successfully' })
@ApiNotFoundResponse({ description: 'Record not found' })
async delete(
@Param('id', ParseUUIDPipe) id: string,
): Promise<ApiResponse<T>> {
const result = await this.service.delete(id);
return createSuccessResponse(
result,
`${this.entityName} deleted successfully`,
);
}
@Post(':id/restore')
@HttpCode(200)
@ApiOperation({ summary: 'Restore a soft-deleted record' })
@ApiOkResponse({ description: 'Record restored successfully' })
async restore(
@Param('id', ParseUUIDPipe) id: string,
): Promise<ApiResponse<T>> {
const result = await this.service.restore(id);
return createSuccessResponse(
result,
`${this.entityName} restored successfully`,
);
}
}
+165
View File
@@ -0,0 +1,165 @@
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
* Extend this class for entity-specific services
*/
export abstract class BaseService<T, CreateDto, UpdateDto> {
protected readonly logger: Logger;
constructor(
protected readonly prisma: PrismaService,
protected readonly modelName: string,
) {
this.logger = new Logger(`${modelName}Service`);
}
/**
* Get the Prisma model delegate
*/
protected get model() {
return (this.prisma as any)[this.modelName.toLowerCase()];
}
/**
* Find all records with pagination
*/
async findAll(
pagination: PaginationDto,
where?: any,
): Promise<{ items: T[]; meta: PaginationMeta }> {
const { skip, take, orderBy } = pagination;
const [items, total] = await Promise.all([
this.model.findMany({
where,
skip,
take,
orderBy,
}),
this.model.count({ where }),
]);
const totalPages = Math.ceil(total / take);
return {
items,
meta: {
total,
page: pagination.page || 1,
limit: pagination.limit || 10,
totalPages,
hasNextPage: (pagination.page || 1) < totalPages,
hasPreviousPage: (pagination.page || 1) > 1,
},
};
}
/**
* Find a single record by ID
*/
async findOne(id: string, include?: any): Promise<T> {
const record = await this.model.findUnique({
where: { id },
include,
});
if (!record) {
throw new NotFoundException(`${this.modelName} not found`);
}
return record;
}
/**
* Find a single record by custom criteria
*/
findOneBy(where: any, include?: any): Promise<T | null> {
return this.model.findFirst({
where,
include,
});
}
/**
* Create a new record
*/
create(data: CreateDto, include?: any): Promise<T> {
return this.model.create({
data,
include,
});
}
/**
* Update an existing record
*/
async update(id: string, data: UpdateDto, include?: any): Promise<T> {
// Check if record exists
await this.findOne(id);
return this.model.update({
where: { id },
data,
include,
});
}
/**
* Soft delete a record (sets deletedAt)
*/
async delete(id: string): Promise<T> {
// Check if record exists
await this.findOne(id);
return this.model.delete({
where: { id },
});
}
/**
* Hard delete a record (permanently removes)
*/
async hardDelete(id: string): Promise<T> {
// Check if record exists
await this.findOne(id);
return this.prisma.hardDelete(this.modelName, { id });
}
/**
* Restore a soft-deleted record
*/
async restore(id: string): Promise<T> {
return this.prisma.restore(this.modelName, { id });
}
/**
* Check if a record exists
*/
async exists(id: string): Promise<boolean> {
const count = await this.model.count({
where: { id },
});
return count > 0;
}
/**
* Count records matching criteria
*/
count(where?: any): Promise<number> {
return this.model.count({ where });
}
/**
* Execute a transaction
*/
transaction<R>(fn: (prisma: PrismaService) => Promise<R>): Promise<R> {
return this.prisma.$transaction(async (tx) => {
return fn(tx as unknown as PrismaService);
});
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './base.service';
export * from './base.controller';
+60
View File
@@ -0,0 +1,60 @@
import {
createParamDecorator,
ExecutionContext,
SetMetadata,
} from '@nestjs/common';
/**
* Get the current authenticated user from request
*/
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
if (data) {
return user?.[data];
}
return user;
},
);
/**
* Mark a route as public (no authentication required)
*/
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 = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
/**
* Require specific permissions to access a route
*/
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
/**
* Get tenant ID from request (for multi-tenancy)
*/
export const CurrentTenant = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.tenantId;
},
);
/**
* Get the current language from request headers
*/
export const CurrentLang = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.headers['accept-language'] || 'en';
},
);
+65
View File
@@ -0,0 +1,65 @@
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' })
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({
default: 10,
minimum: 1,
maximum: 100,
description: 'Items per page',
})
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsInt()
@Min(1)
@Max(100)
limit?: number = 10;
@ApiPropertyOptional({ description: 'Field to sort by' })
@IsOptional()
@IsString()
sortBy?: string = 'createdAt';
@ApiPropertyOptional({
enum: ['asc', 'desc'],
default: 'desc',
description: 'Sort order',
})
@IsOptional()
@IsIn(['asc', 'desc'])
sortOrder?: 'asc' | 'desc' = 'desc';
@ApiPropertyOptional({ description: 'Search query' })
@IsOptional()
@IsString()
search?: string;
/**
* Get skip value for Prisma
*/
get skip(): number {
return ((this.page || 1) - 1) * (this.limit || 10);
}
/**
* Get take value for Prisma
*/
get take(): number {
return this.limit || 10;
}
/**
* Get orderBy object for Prisma
*/
get orderBy(): Record<string, 'asc' | 'desc'> {
return { [this.sortBy || 'createdAt']: this.sortOrder || 'desc' };
}
}
+109
View File
@@ -0,0 +1,109 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
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';
/**
* Global exception filter that catches all exceptions
* and returns a standardized ApiResponse with HTTP 200
*/
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
constructor(private readonly i18n?: I18nService) {}
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// Determine status and message
let status = HttpStatus.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') {
message = exceptionResponse;
} 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';
}
}
} else if (exception instanceof Error) {
message = exception.message;
}
// Try to translate the message
if (this.i18n) {
try {
const i18nContext = I18nContext.current();
let lang = i18nContext?.lang;
if (!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 = lang || 'en';
// Translate validation error specially
if (message === 'VALIDATION_FAILED') {
message = this.i18n.translate('errors.VALIDATION_FAILED', { lang });
} else {
// Try dynamic translation
const translatedMessage = this.i18n.translate(`errors.${message}`, {
lang,
});
// Only update if translation exists (key is different from result)
if (translatedMessage !== `errors.${message}`) {
message = translatedMessage;
}
}
} catch {
// Keep original message if translation fails
}
}
// Log the error
this.logger.error(
`${request.method} ${request.url} - ${status} - ${message}`,
exception instanceof Error ? exception.stack : undefined,
);
// Build response
const isDevelopment = process.env.NODE_ENV === 'development';
const errorResponse: ApiResponse<null> = createErrorResponse(
message,
status,
errors,
isDevelopment && exception instanceof Error ? exception.stack : undefined,
);
// Always return HTTP 200, actual status in body
response.status(200).json(errorResponse);
}
}
+147
View File
@@ -0,0 +1,147 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} 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';
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<
T,
ApiResponse<T>
> {
constructor(private readonly i18n: I18nService) {}
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data: unknown) => {
const request = context.switchToHttp().getRequest();
// Determine language
const i18nContext = I18nContext.current();
let lang = i18nContext?.lang;
if (!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 = 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
if (this.isApiResponse(data)) {
if (data !== null) {
try {
this.translateReasons(data, lang);
} catch {
// Ignore if object is not extensible
}
}
return data as ApiResponse<T>;
}
// Recursively translate reasons arrays in the response body
if (data !== null) {
try {
this.translateReasons(data, lang);
} catch {
// Ignore if object is not extensible
}
}
const message = this.i18n.translate('common.success', {
lang,
});
// Wrap in success response
return createSuccessResponse(data as T, message);
}),
);
}
private translateReasons(data: any, lang: string) {
if (!data || typeof data !== 'object') {
return;
}
if (Array.isArray(data)) {
data.forEach((item) => this.translateReasons(item, lang));
return;
}
Object.keys(data).forEach((key) => {
const val = data[key];
if (
(key === 'reasons' ||
key === 'decision_reasons' ||
key === 'reasoning_factors') &&
Array.isArray(val)
) {
data[key] = val.map((r: any) => {
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') {
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)) {
data[key] = val.map((r: any) => {
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)) {
data[key] = val.map((r: any) => {
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) {
this.translateReasons(val, lang);
}
});
}
private isApiResponse(data: unknown): boolean {
return (
data !== null &&
typeof data === 'object' &&
'success' in data &&
'status' in data &&
'message' in data &&
'data' in data
);
}
}
@@ -0,0 +1,48 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
/**
* Strips HTML/script tags from all string values in the request body.
* Applied globally to prevent stored XSS via API inputs.
*/
@Injectable()
export class SanitizeInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
if (request.body && typeof request.body === 'object') {
request.body = this.sanitize(request.body);
}
return next.handle();
}
private sanitize(value: unknown): unknown {
if (typeof value === 'string') {
return this.stripTags(value);
}
if (Array.isArray(value)) {
return value.map((item) => this.sanitize(item));
}
if (value !== null && typeof value === 'object') {
const sanitized: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
sanitized[key] = this.sanitize(val);
}
return sanitized;
}
return value;
}
private stripTags(input: string): string {
return input.replace(/<[^>]*>/g, '');
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Module, Global } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Global()
@Module({
imports: [
BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
connection: {
host: configService.get('redis.host', 'localhost'),
port: configService.get('redis.port', 6379),
password: configService.get('redis.password'),
},
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
removeOnComplete: true,
removeOnFail: false,
},
}),
inject: [ConfigService],
}),
],
exports: [BullModule],
})
export class QueueModule {}
+96
View File
@@ -0,0 +1,96 @@
/**
* Standard API Response Type
* All responses return HTTP 200 with this structure
*/
export type ApiResponse<T = unknown> = {
errors: unknown[];
stack?: string;
message: string;
success: boolean;
status: number;
data: T;
};
/**
* Paginated response wrapper
*/
export interface PaginatedData<T> {
items: T[];
meta: PaginationMeta;
}
export interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
/**
* Create a successful API response
*/
export function createSuccessResponse<T>(
data: T,
message = 'Success',
status = 200,
): ApiResponse<T> {
return {
success: true,
status,
message,
data,
errors: [],
};
}
/**
* Create an error API response
*/
export function createErrorResponse(
message: string,
status = 400,
errors: any[] = [],
stack?: string,
): ApiResponse<null> {
return {
success: false,
status,
message,
data: null,
errors,
stack,
};
}
/**
* Create a paginated API response
*/
export function createPaginatedResponse<T>(
items: T[],
total: number,
page: number,
limit: number,
message = 'Success',
): ApiResponse<PaginatedData<T>> {
const totalPages = Math.ceil(total / limit);
return {
success: true,
status: 200,
message,
data: {
items,
meta: {
total,
page,
limit,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
},
errors: [],
};
}
+59
View File
@@ -0,0 +1,59 @@
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');
/**
* Downloads an image from a URL and saves it to a local path.
* Skips download if file already exists.
*/
static async downloadImage(url: string, localPath: string): Promise<boolean> {
try {
// Check if file exists
if (existsSync(localPath)) {
return true;
}
// Ensure directory exists
const dir = dirname(localPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Download
const response = await axios({
url,
method: 'GET',
responseType: 'stream',
timeout: 5000,
validateStatus: (status) => status === 200, // Only save if 200 OK
});
const writer = createWriteStream(localPath);
response.data.pipe(writer);
return new Promise((resolve, reject) => {
writer.on('finish', () => resolve(true));
writer.on('error', (err) => {
this.logger.warn(
`Failed to write image to ${localPath}: ${err.message}`,
);
reject(new Error(`Failed to write image to ${localPath}`));
});
});
} catch (error: any) {
// Log warning but don't break the application
// 404s are common for missing logos
if (error.response?.status !== 404) {
this.logger.warn(
`Failed to download image from ${url}: ${error.message}`,
);
}
return false;
}
}
}
+58
View File
@@ -0,0 +1,58 @@
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 databaseConfig = registerAs('database', () => ({
url: process.env.DATABASE_URL,
}));
export const jwtConfig = registerAs('jwt', () => ({
secret: process.env.JWT_SECRET,
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),
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 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', () => ({
host: process.env.MAIL_HOST,
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', () => ({
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',
}));
export const throttleConfig = registerAs('throttle', () => ({
ttl: parseInt(process.env.THROTTLE_TTL || '60000', 10),
limit: parseInt(process.env.THROTTLE_LIMIT || '100', 10),
}));
+107
View File
@@ -0,0 +1,107 @@
import { z } from 'zod';
/**
* Helper to parse boolean from string
*/
const booleanString = z
.string()
.optional()
.default('false')
.transform((val) => val === 'true');
/**
* Environment variables schema validation using Zod
*/
export const envSchema = z.object({
// Environment
NODE_ENV: z
.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'),
// JWT
JWT_SECRET: z.string().min(32),
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'),
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'),
// Gemini AI
ENABLE_GEMINI: z
.string()
.transform((val) => val === 'true')
.default('false' as any),
GOOGLE_API_KEY: z.string().optional(),
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),
TWITTER_API_KEY: z.string().optional(),
TWITTER_API_SECRET: z.string().optional(),
TWITTER_ACCESS_TOKEN: z.string().optional(),
TWITTER_ACCESS_SECRET: z.string().optional(),
META_PAGE_ACCESS_TOKEN: z.string().optional(),
META_PAGE_ID: z.string().optional(),
META_IG_USER_ID: z.string().optional(),
// Optional Features
ENABLE_MAIL: booleanString,
ENABLE_S3: booleanString,
ENABLE_WEBSOCKET: booleanString,
ENABLE_MULTI_TENANCY: booleanString,
// Mail (Optional)
MAIL_HOST: z.string().optional(),
MAIL_PORT: z.coerce.number().optional(),
MAIL_USER: z.string().optional(),
MAIL_PASSWORD: z.string().optional(),
MAIL_FROM: z.string().optional(),
// S3 (Optional)
S3_ENDPOINT: z.string().optional(),
S3_ACCESS_KEY: z.string().optional(),
S3_SECRET_KEY: z.string().optional(),
S3_BUCKET: z.string().optional(),
S3_REGION: z.string().optional(),
// Throttle
THROTTLE_TTL: z.coerce.number().default(60000),
THROTTLE_LIMIT: z.coerce.number().default(100),
});
export type EnvConfig = z.infer<typeof envSchema>;
/**
* Validate environment variables
*/
export function validateEnv(config: Record<string, unknown>): EnvConfig {
const result = envSchema.safeParse(config);
if (!result.success) {
const errors = result.error.issues.map(
(err) => `${err.path.join('.')}: ${err.message}`,
);
throw new Error(`Environment validation failed:\n${errors.join('\n')}`);
}
return result.data;
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class DatabaseModule {}
+134
View File
@@ -0,0 +1,134 @@
import {
Injectable,
OnModuleInit,
OnModuleDestroy,
Logger,
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
// Models that support soft delete
const SOFT_DELETE_MODELS = ['user', 'role', 'tenant'];
// Type for Prisma model delegate with common operations
interface PrismaDelegate {
delete: (args: { where: Record<string, unknown> }) => Promise<unknown>;
findMany: (args?: Record<string, unknown>) => Promise<unknown[]>;
update: (args: {
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => Promise<unknown>;
}
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(PrismaService.name);
constructor() {
super({
log: [
{ 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]}`,
); // Mask password
try {
await this.$connect();
this.logger.log('✅ Database connected successfully');
} catch (error) {
this.logger.error(
`❌ Database connection failed: ${error.message}`,
error.stack,
);
throw error;
}
}
async onModuleDestroy() {
await this.$disconnect();
this.logger.log('🔌 Database disconnected');
}
/**
* Check if model has soft delete (deletedAt field)
*/
hasSoftDelete(model: string | undefined): boolean {
return model ? SOFT_DELETE_MODELS.includes(model.toLowerCase()) : false;
}
/**
* Hard delete - actually remove from database
*/
hardDelete<T>(model: string, where: Record<string, unknown>): Promise<T> {
const delegate = this.getModelDelegate(model);
return delegate.delete({ where }) as Promise<T>;
}
/**
* Find including soft deleted records
*/
findWithDeleted<T>(
model: string,
args?: Record<string, unknown>,
): Promise<T[]> {
const delegate = this.getModelDelegate(model);
return delegate.findMany(args) as Promise<T[]>;
}
/**
* Restore a soft deleted record
*/
restore<T>(model: string, where: Record<string, unknown>): Promise<T> {
const delegate = this.getModelDelegate(model);
return delegate.update({
where,
data: { deletedAt: null },
}) as Promise<T>;
}
/**
* Soft delete - set deletedAt to current date
*/
softDelete<T>(model: string, where: Record<string, unknown>): Promise<T> {
const delegate = this.getModelDelegate(model);
return delegate.update({
where,
data: { deletedAt: new Date() },
}) as Promise<T>;
}
/**
* Find many excluding soft deleted records
*/
findManyActive<T>(
model: string,
args?: Record<string, unknown>,
): Promise<T[]> {
const delegate = this.getModelDelegate(model);
const whereWithDeleted = {
...args,
where: {
...(args?.where as Record<string, unknown> | undefined),
deletedAt: null,
},
};
return delegate.findMany(whereWithDeleted) as Promise<T[]>;
}
/**
* Get Prisma model delegate by name
*/
private getModelDelegate(model: string): PrismaDelegate {
const modelKey = model.charAt(0).toLowerCase() + model.slice(1);
return (this as any)[modelKey] as PrismaDelegate;
}
}
+276
View File
@@ -0,0 +1,276 @@
const axios = require('axios');
const he = require('he'); // HTML entity decode etmek için (npm install he)
const cheerio = require('cheerio'); // HTML içinden data-settings almak için
const MATCH_ID = '18rkqb1lhon6ne1hdb6d15as'; // Barcelona - Real Madrid
// 1. ADIM: TANIMLARI (METADATA) ÇEK VE HARİTALAMA YAP
async function createMarketMap() {
// Bu URL bize HTML döndürür, ama içinde data-settings JSON'u vardır.
const url = `https://www.mackolik.com/ajax/iddaa/markets/soccer/all/${MATCH_ID}?template=all`;
const response = await axios.get(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': 'Mozilla/5.0' }
});
// Cheerio ile HTML'i yükle
const $ = cheerio.load(response.data.data.html);
// data-settings özniteliğini bul (Genelde "all" marketlerinde bulunur)
// Not: Bazen widget-iddaa-markets--all bazen başka class olabilir, genel arıyoruz:
const settingsRaw = $('.widget-iddaa-markets').first().attr('data-settings');
if (!settingsRaw) {
console.error("Data settings bulunamadı!");
return null;
}
// HTML Entity'lerini temizle (&quot; -> ")
const settingsJson = JSON.parse(he.decode(settingsRaw));
// Şimdi elimizde ALTIN DEĞERİNDE bir sözlük var: marketCollection
const definitions = settingsJson.iddaaEventId.marketCollection;
// Bizim kullanacağımız Harita (Dictionary)
let marketMap = {};
// Sözlüğü oluşturuyoruz: ID -> İSİM
for (const key in definitions) {
const market = definitions[key];
const marketId = market.iddaaId || market.id; // Bazen iddaaId, bazen id kullanılır
marketMap[marketId] = {
name: market.name, // "Maç Sonucu"
outcomes: {}
};
// Seçenekleri de haritalayalım (1, X, 2, Alt, Üst)
// selectionCollectionAll varsa onu, yoksa selectionCollection kullan
const selections = market.selectionCollectionAll || market.selectionCollection;
for (const selKey in selections) {
const selection = selections[selKey];
// Outcome shortcode (Örn: 1.1) veya iddaaMarketId ile eşleşme yapabiliriz
// Live JSON'da outcome ID'leri "1.1", "1.2" gibi shortcode olarak geliyor.
marketMap[marketId].outcomes[selection.shortcode] = selection.name;
}
}
console.log("✅ Market Haritası Başarıyla Oluşturuldu (HTML'den).");
return marketMap;
}
// 2. ADIM: CANLI VERİYİ ÇEK VE HARİTA İLE BİRLEŞTİR
async function fetchLiveOdds(marketMap) {
// Bu URL sadece sayıları (oranları) verir, çok hızlıdır.
const url = `https://www.mackolik.com/ajax/iddaa/outcomes/soccer/all/${MATCH_ID}`;
const response = await axios.get(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': 'Mozilla/5.0' }
});
const liveMarkets = response.data.data.markets;
console.log("\n📊 GÜNCEL ORANLAR (Dinamik Eşleştirme İle):\n");
console.log(`| ${"BAHİS TÜRÜ".padEnd(35)} | ${"SEÇENEK".padEnd(15)} | ${"ORAN".padEnd(6)} |`);
console.log("-".repeat(65));
// Canlı veriyi dönüyoruz
for (const [marketId, data] of Object.entries(liveMarkets)) {
// Haritamızdan bu ID'nin adını buluyoruz
// JSON'daki marketId bazen Mackolik ID'si bazen Iddaa ID'si olabiliyor.
// Genelde 'code' alanı ile bizim map'teki ID eşleşir veya key ile.
// Senin attığın JSON'da keyler (örn: 1, 3, 184.5) Mapping ID'si olarak kullanılıyor.
// DİKKAT: Senin attığın JSON'da Keyler (1, 184.5) bizim Map'teki ID'ler olmayabilir.
// HTML JSON'undaki "id" alanı (örn: 1) ile Canlı JSON'daki Key (örn: 1) eşleşir.
// Eşleşme Algoritması:
// HTML'deki `market.id` == LiveJSON'daki `key`
// HTML Map'imizi ID bazlı (1, 3, 184.5 gibi) tekrar düzenlememiz gerekebilir ama
// senin HTML JSON'una baktığımda "id": 18, "iddaaId": 55512092 var.
// Live JSON key'i "184.5" (Bu aslında 18 nolu marketin 4.5 barajı).
// Basit Eşleştirme Denemesi (ID üzerinden):
// Live JSON'daki key (örn "1" veya "184.5") HTML map'te yoksa, "code" (50465) üzerinden arama yapabiliriz.
// Ama en garantisi HTML'deki marketCollection içindeki key yapısıdır.
// Şimdilik basit ID eşleştirmesi yapalım, eğer map'te yoksa "Bilinmeyen" yazarız.
// Live JSON Key'i ile Map arıyoruz.
// Ancak HTML'deki "id" (örn: 1) ile Live JSON key (1) tutuyor.
// Fakat "184.5" gibi olanlar HTML'de "id: 18, sov: 4.5" olarak geçiyor.
let definitions = findDefinition(marketMap, marketId, data);
let marketName = definitions ? definitions.name : `Bilinmeyen (${marketId})`;
// Eğer barajlı bir bahisse (184.5 gibi) isme barajı ekle
// (Zaten HTML'den gelen isimde "4,5 Alt/Üst" yazıyor olacak)
for (const [outcomeKey, outcomeData] of Object.entries(data.outcomes)) {
if (outcomeData.outcome !== '-') {
let label = outcomeData.label;
// Eğer label mapping'den gelirse daha doğru olur ama outcomeData.label da genelde doğrudur.
console.log(`| ${marketName.padEnd(35)} | ${label.padEnd(15)} | ${outcomeData.outcome.padEnd(6)} |`);
}
}
}
}
// Yardımcı Fonksiyon: Live JSON Key'ini HTML Map içinde bulma
function findDefinition(marketMap, liveKey, liveData) {
// 1. Doğrudan ID eşleşmesi (Örn: "1" == "1")
// HTML'i parse ederken ID'leri key olarak ayarlamalıyız.
// Yukarıdaki createMarketMap fonksiyonunu buna göre revize ettim aşağıda.
// Asıl sorun: HTML JSON'da keyler "0", "1", "2" diye gidiyor array indexi gibi.
// Ama içlerinde "id": 1, "id": 18 var.
// Biz map'i oluştururken liveKey ile eşleşecek şekilde kurmalıyız.
// LiveKey "184.5" ise -> id=18 ve sov=4.5 olanı bulmalıyız.
// Bu karmaşıklığı çözmek için MarketMap'i bir Array olarak tutup find ile aramak en iyisi.
for (const def of Object.values(marketMap)) {
// Eğer ID tutuyorsa (Örn: 1 == 1)
if (def.rawId == liveKey) return def;
// Eğer ID ve Baraj (sov) tutuyorsa (Örn: LiveKey "184.5", Def id=18, sov=4.5)
if (liveKey.includes('.')) {
const [mainId, sov] = liveKey.split('.');
// Tamam float problemleri olabilir ama string olarak "18" == def.rawId
// Bu kısım biraz manuel mapping gerektirebilir ama HTML içindeki "name" zaten barajı içeriyor.
// HTML'deki iddaaMarketNo veya iddaaId ile LiveData'daki code eşleşebilir!
if (def.iddaaCode == liveData.code) return def; // EN GARANTİ YÖNTEM BU!
}
if (def.iddaaCode == liveData.code) return def;
}
return null;
}
// ------------------------------------------------------------------
// REVIZE EDİLMİŞ HARİTA OLUŞTURUCU (EN SAĞLAMI)
// ------------------------------------------------------------------
async function main() {
const url = `https://www.mackolik.com/ajax/iddaa/markets/soccer/all/${MATCH_ID}?template=all`;
const response = await axios.get(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': 'Mozilla/5.0' } });
const $ = cheerio.load(response.data.data.html);
const settingsRaw = $('.widget-iddaa-markets').first().attr('data-settings');
if (!settingsRaw) return;
const settingsJson = JSON.parse(he.decode(settingsRaw));
const definitions = settingsJson.iddaaEventId.marketCollection;
// Haritamızı bir dizi (array) yapalım, içinde arama yapacağız.
let marketDefinitions = [];
for (const key in definitions) {
const m = definitions[key];
marketDefinitions.push({
name: m.name, // Örn: "Maç Sonucu" veya "4,5 Alt/Üst"
rawId: m.id, // Örn: 1 veya 18
iddaaCode: m.iddaaNo, // Örn: "10313" (HTML'deki code) -> LiveData'da "code" ile eşleşecek mi bakacağız.
iddaaMarketId: m.iddaaId, // Örn: 21983276 -> LiveData'da code olarak gelebilir.
sov: m.sov // Baraj değeri (4.5)
});
}
// --- LIVE DATA ÇEK ---
const liveUrl = `https://www.mackolik.com/ajax/iddaa/outcomes/soccer/all/${MATCH_ID}`;
const liveRes = await axios.get(liveUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': 'Mozilla/5.0' } });
const liveMarkets = liveRes.data.data.markets;
// --- EŞLEŞTİRME VE YAZDIRMA ---
console.log(`\nMAÇ: ${MATCH_ID} | DATA ANALİZİ SONUCU`);
console.log("=".repeat(70));
for (const [liveKey, liveData] of Object.entries(liveMarkets)) {
// EŞLEŞTİRME MANTIĞI:
// Live Data içindeki "code" (Örn: 50465) ile HTML'deki "iddaaId" (Örn: 55507607) veya "iddaaNo" (Örn: 18660) eşleşmeli.
// Senin attığın son örneklerde:
// HTML JSON: "iddaaId": 55507607, "iddaaNo": "18660"
// LIVE JSON: "code": "50465"
// HATA: Kodlar (Code) maç başladığında (Live) ve başlamadan önce (Pre-match) değişiyor olabilir!
// Bu durumda en güvenilir eşleşme "Market Tipi" (ID) ve "Baraj" (SOV) üzerinden olur.
let matchedDef = marketDefinitions.find(def => {
// 1. Ana ID eşleşiyor mu? (1 == 1)
if (def.rawId == liveKey) return true;
// 2. Barajlı ID kontrolü (184.5 -> id:18, sov:4.5)
if (liveKey.includes('.')) {
// Float çevirmeden string karşılaştırması riskli olabilir, dikkat.
// LiveKey "184.5"
// Def: rawId=18, sov=4.5
// 18 == 18 AND 4.5 == 4.5
if (def.rawId == Math.floor(parseFloat(liveKey)) && def.sov == parseFloat(liveKey.split('.')[1] + '.' + (liveKey.split('.')[2] || '0'))) {
// Basitçe string match daha güvenli olabilir
return def.rawId == 18 && def.sov == 4.5 && liveKey == "184.5"; // Örnek mantık
}
// Daha basit: Mackolik Live Key yapısı: {MARKET_ID}{BARAJ}
// "18" + "4.5" -> "184.5"
if (def.rawId == 18 && liveKey == `18${def.sov}`) return true;
if (def.rawId == 19 && liveKey == `19${def.sov}`) return true; // 1. Yarı Alt üst
}
return false;
});
// Eğer yukarıdaki ID mantığı tutmazsa manuel düzeltme:
// Mackolik LiveKey formatı: {ID} veya {ID}{SOV}
// Örn: Market 1 -> Key "1"
// Örn: Market 18 (Alt/Üst), Sov 4.5 -> Key "184.5"
// Hızlı çözüm için bir Map oluşturuyorum:
const name = getMarketNameFromKey(liveKey, marketDefinitions);
for (const [k, v] of Object.entries(liveData.outcomes)) {
if (v.outcome !== '-') {
console.log(`| ${name.padEnd(35)} | ${v.label.padEnd(10)} | ${v.outcome} |`);
}
}
}
}
function getMarketNameFromKey(key, definitions) {
// 1. Tam eşleşme (ID 1, 3, 11 vb.)
let exact = definitions.find(d => d.rawId == key);
if (exact) return exact.name;
// 2. Noktalı Eşleşme (184.5, 191.5 vb.)
if (key.includes('.')) {
// key: 184.5 -> id: 18, sov: 4.5
// key: 190.5 -> id: 19, sov: 0.5
// Püf nokta: String olarak sov'u ayıklamak.
// Genelde sonu .5 ile biter.
// Ama ID 18, 19, 28, 29 gibi alt/üst türleri var.
// Bu kısmı senin için basitleştiriyorum:
// HTML'deki isimleri tara, hangisinin sov değeri key ile uyuşuyorsa onu al.
for (let def of definitions) {
if (def.sov !== null) {
// Basit bir string contains kontrolü bile çoğu zaman yeter.
// Örneğin key="184.5", def.rawId=18, def.sov=4.5 -> Eşleşir.
// key="280.5", def.rawId=28, def.sov=0.5 -> Eşleşir.
// Formül: Key, Def.ID ile başlıyor mu VE Key, Def.Sov ile bitiyor mu?
if (key.startsWith(def.rawId) && key.endsWith(def.sov)) {
return def.name;
}
}
}
}
return `Bilinmeyen Market (${key})`;
}
main();
+6
View File
@@ -0,0 +1,6 @@
{
"registered": "User registered successfully",
"login_success": "Login successful",
"refresh_success": "Token refreshed successfully",
"logout_success": "Logout successful"
}
+13
View File
@@ -0,0 +1,13 @@
{
"welcome": "Welcome",
"success": "Operation completed successfully",
"created": "Resource created successfully",
"updated": "Resource updated successfully",
"deleted": "Resource deleted successfully",
"restored": "Resource restored successfully",
"notFound": "Resource not found",
"serverError": "An unexpected error occurred",
"unauthorized": "You are not authorized to perform this action",
"forbidden": "Access denied",
"badRequest": "Invalid request"
}
+14
View File
@@ -0,0 +1,14 @@
{
"USER_NOT_FOUND": "User not found",
"INVALID_CREDENTIALS": "Invalid email or password",
"EMAIL_ALREADY_EXISTS": "This email is already registered",
"INVALID_REFRESH_TOKEN": "Invalid or expired refresh token",
"ACCOUNT_DISABLED": "Your account has been disabled",
"TOKEN_EXPIRED": "Your session has expired, please login again",
"PERMISSION_DENIED": "You do not have permission to perform this action",
"ROLE_NOT_FOUND": "Role not found",
"TENANT_NOT_FOUND": "Tenant not found",
"VALIDATION_FAILED": "Validation failed",
"INTERNAL_ERROR": "An internal error occurred, please try again later",
"AUTH_REQUIRED": "Authentication required, please provide a valid token"
}
+41
View File
@@ -0,0 +1,41 @@
{
"reasons": {
"below_calibrated_conf_threshold": "Confidence score is below the minimum threshold",
"market_odds_missing": "Market odds are missing or too low",
"high_risk_low_data_quality": "High risk combined with low data quality",
"lineup_insufficient_for_market": "Lineup info is insufficient for this market",
"lineup_not_confirmed": "Starting lineups are not confirmed yet",
"negative_model_edge": "Model edge (EV) is negative",
"insufficient_play_score": "Prediction play score is insufficient",
"market_passed_all_gates": "Passed all safety gates successfully",
"market_signal_dominant": "Market signal is dominant",
"team_form_signal_dominant": "Team form signal is dominant",
"lineup_signal_strong": "Starting lineup signal is strong",
"lineup_signal_weak": "Starting lineup signal is weak",
"lineup_probable_xi_used": "Probable starting XI was used",
"upset_risk_detected": "Upset risk detected",
"player_form_signal_strong": "Player form signal is strong",
"player_form_signal_limited": "Player form signal is limited",
"limited_data_confidence": "Limited data confidence",
"basketball_points_model": "Basketball points model used"
},
"flags": {
"missing_full_ms_odds": "Missing match result odds",
"lineup_probable_not_confirmed": "Probable lineup is not confirmed",
"lineup_unavailable": "Lineup data is completely unavailable",
"lineup_incomplete": "Lineup data is incomplete",
"missing_referee": "Referee information is missing",
"missing_moneyline_odds": "Missing moneyline odds",
"missing_total_odds": "Missing total goals/points odds",
"missing_spread_odds": "Missing point spread odds",
"missing_team_ids": "Missing team identification",
"missing_ai_features": "Missing AI feature data",
"missing_home_stats": "Missing home team statistics",
"missing_away_stats": "Missing away team statistics",
"missing_odds": "General odds are missing"
},
"warnings": {
"Very tight ELO difference — coin-flip territory": "Very tight ELO difference — coin-flip territory",
"Upset potential: bookmaker odds suggest heavy favorite but ELO says the match is closer than the market thinks": "Upset potential: bookmaker odds suggest heavy favorite but ELO says the match is closer than the market thinks"
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"email": {
"required": "Email is required",
"invalid": "Please enter a valid email address"
},
"password": {
"required": "Password is required",
"minLength": "Password must be at least 8 characters long",
"weak": "Password is too weak"
},
"firstName": {
"required": "First name is required"
},
"lastName": {
"required": "Last name is required"
},
"generic": {
"required": "This field is required",
"invalid": "Invalid value",
"minLength": "Must be at least {min} characters",
"maxLength": "Must be at most {max} characters"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"registered": "Kullanıcı başarıyla kaydedildi",
"login_success": "Giriş başarılı",
"refresh_success": "Token başarıyla yenilendi",
"logout_success": "Çıkış başarılı"
}
+13
View File
@@ -0,0 +1,13 @@
{
"welcome": "Hoş geldiniz",
"success": "İşlem başarıyla tamamlandı",
"created": "Kayıt başarıyla oluşturuldu",
"updated": "Kayıt başarıyla güncellendi",
"deleted": "Kayıt başarıyla silindi",
"restored": "Kayıt başarıyla geri yüklendi",
"notFound": "Kayıt bulunamadı",
"serverError": "Beklenmeyen bir hata oluştu",
"unauthorized": "Bu işlemi yapmaya yetkiniz yok",
"forbidden": "Erişim reddedildi",
"badRequest": "Geçersiz istek"
}
+14
View File
@@ -0,0 +1,14 @@
{
"USER_NOT_FOUND": "Kullanıcı bulunamadı",
"INVALID_CREDENTIALS": "Geçersiz e-posta veya şifre",
"EMAIL_ALREADY_EXISTS": "Bu e-posta adresi zaten kayıtlı",
"INVALID_REFRESH_TOKEN": "Geçersiz veya süresi dolmuş yenileme token'ı",
"ACCOUNT_DISABLED": "Hesabınız devre dışı bırakılmış",
"TOKEN_EXPIRED": "Oturumunuz sona erdi, lütfen tekrar giriş yapın",
"PERMISSION_DENIED": "Bu işlemi gerçekleştirme izniniz yok",
"ROLE_NOT_FOUND": "Rol bulunamadı",
"TENANT_NOT_FOUND": "Kiracı bulunamadı",
"VALIDATION_FAILED": "Doğrulama başarısız",
"INTERNAL_ERROR": "Bir iç hata oluştu, lütfen daha sonra tekrar deneyin",
"AUTH_REQUIRED": "Kimlik doğrulama gerekli, lütfen geçerli bir token sağlayın"
}
+48
View File
@@ -0,0 +1,48 @@
{
"reasons": {
"no_strategy_fit": "Seçilen kupon stratejisine uyan uygun bahis bulunamadı",
"match_not_found": "Maç verisi bulunamadı",
"unsupported_sport": "Desteklenmeyen spor türü",
"out_of_training_scope": "Bu maç modelin eğitim kapsamının dışında",
"missing_critical_data": "Tahmin için kritik veriler eksik",
"playable_pick_found": "Oynanabilir seçim bulundu",
"no_bet_conditions_met": "Bahis için gerekli koşullar oluşmadı",
"below_calibrated_conf_threshold": "Bulunan güven skoru minimum barajın altında",
"market_odds_missing": "Bu bahis için oranlar eksik veya pazar kapalı",
"high_risk_low_data_quality": "Yüksek risk ve düşük veri kalitesi nedeniyle oynanamaz",
"lineup_insufficient_for_market": "Kadro bilgisi bu bahis türü için yetersiz",
"lineup_not_confirmed": "İlk onbirler henüz resmi olarak doğrulanmadı",
"negative_model_edge": "Yapay zeka bu bahsi değerli (EV+) bulmadı",
"insufficient_play_score": "Oynanabilirlik puanı gereksinimleri karşılamıyor",
"market_passed_all_gates": "Bahis güvenlik testlerinden başarıyla geçti",
"market_signal_dominant": "Bahis piyasalarındaki sinyaller çok baskın",
"team_form_signal_dominant": "Takım formuna dayalı sinyaller çok baskın",
"lineup_signal_strong": "İlk onbir bilgisi güçlü bir sinyal yaratıyor",
"lineup_signal_weak": "İlk onbir bilgisi eksik olduğu için sinyal zayıf",
"lineup_probable_xi_used": "Resmi olmayan muhtemel onbir bilgisi kullanıldı",
"upset_risk_detected": "Sürpriz potansiyeli tespit edildi",
"player_form_signal_strong": "Oyuncu form değerleri tahmini güçlü destekliyor",
"player_form_signal_limited": "Oyuncu form değerlerinin etkisi sınırlı",
"limited_data_confidence": "Veri kalitesi ve geçmiş veriler sınırlı",
"basketball_points_model": "Basketbol özel sayı tahmini modeli kullanıldı"
},
"flags": {
"missing_full_ms_odds": "Maç sonucu oranları eksik",
"lineup_probable_not_confirmed": "Muhtemel ilk onbir henüz doğrulanmadı",
"lineup_unavailable": "Takım kadro bilgisi şu an için tamamen eksik",
"lineup_incomplete": "Takım kadro verisinde eksiklikler var",
"missing_referee": "Hakem bilgisi bulunamadı",
"missing_moneyline_odds": "Taraf bahsi oranları eksik",
"missing_total_odds": "Alt/Üst toplam sayı bahis oranları eksik",
"missing_spread_odds": "Handikap oranları eksik",
"missing_team_ids": "Takım kimlik bilgileri eksik",
"missing_ai_features": "Yapay zeka analiz verileri eksik",
"missing_home_stats": "Ev sahibi takım istatistikleri eksik",
"missing_away_stats": "Deplasman takım istatistikleri eksik",
"missing_odds": "Genel bahis oranları eksik"
},
"warnings": {
"Very tight ELO difference — coin-flip territory": "Çok yakın ELO kalitesi — tamamen yazı-tura (50/50) maçı",
"Upset potential: bookmaker odds suggest heavy favorite but ELO says the match is closer than the market thinks": "Sürpriz potansiyeli: Bahis büroları büyük bir favori gösteriyor fakat yapay zeka ELO verileri maçın çok daha başa baş geçeceğini öngörüyor"
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"email": {
"required": "E-posta adresi gereklidir",
"invalid": "Lütfen geçerli bir e-posta adresi girin"
},
"password": {
"required": "Şifre gereklidir",
"minLength": "Şifre en az 8 karakter olmalıdır",
"weak": "Şifre çok zayıf"
},
"firstName": {
"required": "Ad gereklidir"
},
"lastName": {
"required": "Soyad gereklidir"
},
"generic": {
"required": "Bu alan gereklidir",
"invalid": "Geçersiz değer",
"minLength": "En az {min} karakter olmalıdır",
"maxLength": "En fazla {max} karakter olmalıdır"
}
}
Executable
+119
View File
@@ -0,0 +1,119 @@
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 () {
return this.toString();
};
async function bootstrap() {
const logger = new NestLogger('Bootstrap');
logger.log('🔄 Starting application...');
const app = await NestFactory.create(AppModule, { bufferLogs: false });
// Use Pino Logger
app.useLogger(app.get(Logger));
app.useGlobalInterceptors(
new LoggerErrorInterceptor(),
new SanitizeInterceptor(),
);
// Security Headers
app.use(helmet());
// Request payload size limit
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');
// Enable CORS
app.enableCors({
origin:
nodeEnv === 'production'
? [
'https://ui-suggestbet.bilgich.com',
'https://suggestbet.bilgich.com',
'https://iddaai.com',
'https://www.iddaai.com',
]
: true,
credentials: true,
});
// Global prefix
app.setGlobalPrefix('api');
// Validation pipe (Strict)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// Swagger setup — hidden in production
if (nodeEnv !== 'production') {
const swaggerConfig = new DocumentBuilder()
.setTitle('Suggest-Bet API')
.setDescription(
'AI-driven sports betting prediction engine with smart coupon generation',
)
.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')
.build();
logger.log('Initializing Swagger...');
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document, {
swaggerOptions: {
persistAuthorization: true,
},
});
logger.log('Swagger initialized');
}
logger.log(`Attempting to listen on port ${port}...`);
await app.listen(port, '0.0.0.0');
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('═══════════════════════════════════════════════════════════');
if (nodeEnv === 'development') {
logger.warn('⚠️ Running in development mode');
}
}
void bootstrap();
+280
View File
@@ -0,0 +1,280 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Param,
Body,
Query,
UseInterceptors,
Inject,
NotFoundException,
} 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';
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';
@ApiTags('Admin')
@ApiBearerAuth()
@Controller('admin')
@Roles('superadmin')
export class AdminController {
constructor(
private readonly prisma: PrismaService,
@Inject(CACHE_MANAGER) private cacheManager: cacheManager.Cache,
) {}
// ================== Users Management ==================
@Get('users')
@ApiOperation({ summary: 'Get all users (admin)' })
async getAllUsers(
@Query() pagination: PaginationDto,
): Promise<ApiResponse<PaginatedData<UserResponseDto>>> {
const { skip, take, orderBy } = pagination;
const [users, total] = await Promise.all([
this.prisma.user.findMany({
skip,
take,
orderBy,
}),
this.prisma.user.count(),
]);
const dtos = plainToInstance(
UserResponseDto,
users,
) as unknown as UserResponseDto[];
return createPaginatedResponse(
dtos,
total,
pagination.page || 1,
pagination.limit || 10,
);
}
@Get('users/:id')
@ApiOperation({ summary: 'Get user by ID' })
async getUserById(
@Param('id') id: string,
): Promise<ApiResponse<UserResponseDto>> {
const user = await this.prisma.user.findUnique({
where: { id },
include: {
usageLimit: true,
analyses: {
take: 5,
orderBy: { createdAt: 'desc' },
},
},
});
if (!user) {
throw new NotFoundException('User not found');
}
return createSuccessResponse(plainToInstance(UserResponseDto, user));
}
@Put('users/:id/toggle-active')
@ApiOperation({ summary: 'Toggle user active status' })
async toggleUserActive(
@Param('id') id: string,
): Promise<ApiResponse<UserResponseDto>> {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException('User not found');
}
const updated = await this.prisma.user.update({
where: { id },
data: { isActive: !user.isActive },
});
return createSuccessResponse(
plainToInstance(UserResponseDto, updated),
'User status updated',
);
}
@Put('users/:id/role')
@ApiOperation({ summary: 'Update user role' })
async updateUserRole(
@Param('id') id: string,
@Body() data: { role: UserRole },
): Promise<ApiResponse<UserResponseDto>> {
const user = await this.prisma.user.update({
where: { id },
data: { role: data.role },
});
return createSuccessResponse(
plainToInstance(UserResponseDto, user),
'User role updated',
);
}
@Put('users/:id/subscription')
@ApiOperation({ summary: 'Update user subscription' })
async updateUserSubscription(
@Param('id') id: string,
@Body()
data: { subscriptionStatus: string; subscriptionExpiresAt?: string },
): Promise<ApiResponse<UserResponseDto>> {
const user = await this.prisma.user.update({
where: { id },
data: {
subscriptionStatus: data.subscriptionStatus as any,
subscriptionExpiresAt: data.subscriptionExpiresAt
? new Date(data.subscriptionExpiresAt)
: null,
},
});
return createSuccessResponse(
plainToInstance(UserResponseDto, user),
'User subscription updated',
);
}
@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');
}
// ================== App Settings ==================
@Get('settings')
@UseInterceptors(CacheInterceptor)
@CacheKey('app_settings')
@CacheTTL(60 * 1000)
@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 || '';
}
return createSuccessResponse(settingsMap);
}
@Put('settings/:key')
@ApiOperation({ summary: 'Update an app setting' })
async updateSetting(
@Param('key') key: string,
@Body() data: { value: string },
): Promise<ApiResponse<{ key: string; value: string }>> {
const setting = await this.prisma.appSetting.upsert({
where: { key },
update: { value: data.value },
create: { key, value: data.value },
});
await this.cacheManager.del('app_settings');
return createSuccessResponse(
{ key: setting.key, value: setting.value || '' },
'Setting updated',
);
}
// ================== Usage Limits ==================
@Get('usage-limits')
@ApiOperation({ summary: 'Get all usage limits' })
async getAllUsageLimits(@Query() pagination: PaginationDto) {
const { skip, take } = pagination;
const [limits, total] = await Promise.all([
this.prisma.usageLimit.findMany({
skip,
take,
include: {
user: {
select: { id: true, email: true, firstName: true, lastName: true },
},
},
orderBy: { lastResetDate: 'desc' },
}),
this.prisma.usageLimit.count(),
]);
return createPaginatedResponse(
limits,
total,
pagination.page || 1,
pagination.limit || 10,
);
}
@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: {
analysisCount: 0,
couponCount: 0,
lastResetDate: new Date(),
},
});
return createSuccessResponse(
{ count: result.count },
'All usage limits reset',
);
}
// ================== Analytics ==================
@Get('analytics/overview')
@ApiOperation({ summary: 'Get system analytics overview' })
async getAnalyticsOverview() {
const [
totalUsers,
activeUsers,
premiumUsers,
totalMatches,
totalPredictions,
] = await Promise.all([
this.prisma.user.count(),
this.prisma.user.count({ where: { isActive: true } }),
this.prisma.user.count({ where: { subscriptionStatus: 'active' } }),
this.prisma.match.count(),
this.prisma.prediction.count(),
]);
return createSuccessResponse({
users: {
total: totalUsers,
active: activeUsers,
premium: premiumUsers,
},
matches: totalMatches,
predictions: totalPredictions,
});
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
@Module({
controllers: [AdminController],
})
export class AdminModule {}
+71
View File
@@ -0,0 +1,71 @@
import { Exclude, Expose, Type } from 'class-transformer';
@Exclude()
export class PermissionResponseDto {
@Expose()
id: string;
@Expose()
name: string;
@Expose()
description: string | null;
@Expose()
resource: string;
@Expose()
action: string;
@Expose()
createdAt: Date;
@Expose()
updatedAt: Date;
}
@Exclude()
export class RoleResponseDto {
@Expose()
id: string;
@Expose()
name: string;
@Expose()
description: string | null;
@Expose()
@Type(() => PermissionResponseDto)
permissions?: PermissionResponseDto[];
@Expose()
createdAt: Date;
@Expose()
updatedAt: Date;
}
@Exclude()
export class UserRoleResponseDto {
@Expose()
userId: string;
@Expose()
roleId: string;
@Expose()
createdAt: Date;
}
@Exclude()
export class RolePermissionResponseDto {
@Expose()
roleId: string;
@Expose()
permissionId: string;
@Expose()
createdAt: Date;
}
+100
View File
@@ -0,0 +1,100 @@
import {
Controller,
Post,
Get,
Body,
HttpCode,
HttpStatus,
ForbiddenException,
} 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';
@ApiTags('Analysis')
@ApiBearerAuth()
@Controller('analysis')
export class AnalysisController {
constructor(private readonly analysisService: AnalysisService) {}
/**
* POST /analysis/analyze-matches
* Analyze multiple matches (coupon generation)
*/
@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' })
async analyzeMatches(
@CurrentUser() user: any,
@Body() dto: AnalyzeMatchesDto,
) {
const { matchIds } = dto;
// Check usage limit
const isCoupon = matchIds.length > 1;
const canProceed = await this.analysisService.checkUsageLimit(
user.id,
isCoupon,
matchIds.length,
);
if (!canProceed) {
throw new ForbiddenException('You have exceeded your daily usage limit');
}
// Run analysis
const result = await this.analysisService.analyzeCoupon(matchIds, user.id);
if (!result) {
return {
success: false,
message: 'None of the provided matches could be analyzed successfully',
};
}
// Record usage
await this.analysisService.recordUsage(user.id, isCoupon);
return {
success: true,
data: result,
};
}
/**
* POST /analysis/analyze (alias for /analyze-matches - frontend compatibility)
*/
@Post('analyze')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Analyze multiple matches for coupon (alias)',
deprecated: true,
})
async analyzeMatchesAlias(
@CurrentUser() user: any,
@Body() dto: AnalyzeMatchesDto,
) {
return this.analyzeMatches(user, dto);
}
/**
* GET /analysis/history
* Get user's analysis history
*/
@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 };
}
}
+13
View File
@@ -0,0 +1,13 @@
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],
controllers: [AnalysisController],
providers: [AnalysisService],
exports: [AnalysisService],
})
export class AnalysisModule {}
+152
View File
@@ -0,0 +1,152 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import {
MatchAnalysisService,
AnalysisResult,
} from '../../services/match-analysis.service';
@Injectable()
export class AnalysisService {
private readonly logger = new Logger(AnalysisService.name);
constructor(
private readonly prisma: PrismaService,
private readonly matchAnalysisService: MatchAnalysisService,
) {}
/**
* Analyze multiple matches (coupon)
*/
async analyzeCoupon(matchIds: string[], userId: string): Promise<any> {
this.logger.log(`Analyzing ${matchIds.length} matches for coupon`);
const results: AnalysisResult[] = [];
for (const matchId of matchIds) {
try {
// Get match from DB
const match = await this.prisma.match.findFirst({
where: {
OR: [{ id: matchId }],
},
include: {
league: true,
homeTeam: true,
awayTeam: true,
},
});
// Try live match if not found
const liveMatch = !match
? await this.prisma.liveMatch.findUnique({
where: { id: matchId },
})
: null;
const targetMatch = match || liveMatch;
if (!targetMatch) {
this.logger.warn(`Match not found: ${matchId}`);
continue;
}
// Build URL for analysis
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}`;
// Run analysis
const result = await this.matchAnalysisService.analyzeMatch(
url,
userId,
);
results.push(result);
} catch (err: any) {
this.logger.warn(`Analysis failed for ${matchId}: ${err.message}`);
}
}
if (results.length === 0) {
return null;
}
// Combine results into coupon format
return {
totalMatches: matchIds.length,
analyzedMatches: results.length,
matches: results.map((r) => ({
matchDetails: r.matchDetails,
predictions: r.aiAnalysis?.predictions || [],
recommendedBets: r.aiAnalysis?.recommendedBets || [],
confidence: r.aiAnalysis?.confidenceScore || 0,
})),
generatedAt: new Date().toISOString(),
};
}
/**
* Check user usage limit
*/
async checkUsageLimit(
userId: string,
isCoupon: boolean,
matchCount: number,
): Promise<boolean> {
const usageLimit = await this.prisma.usageLimit.findUnique({
where: { userId },
});
if (!usageLimit) {
// Create default limit
await this.prisma.usageLimit.create({
data: {
userId,
analysisCount: 0,
couponCount: 0,
lastResetDate: new Date(),
},
});
return true;
}
// 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 maxAnalyses = isPremium ? 50 : 10;
const maxCoupons = isPremium ? 10 : 3;
if (isCoupon) {
return usageLimit.couponCount < maxCoupons;
}
return usageLimit.analysisCount + matchCount <= maxAnalyses;
}
/**
* Record usage
*/
async recordUsage(userId: string, isCoupon: boolean): Promise<void> {
if (isCoupon) {
await this.prisma.usageLimit.update({
where: { userId },
data: { couponCount: { increment: 1 } },
});
} else {
await this.prisma.usageLimit.update({
where: { userId },
data: { analysisCount: { increment: 1 } },
});
}
}
/**
* Get user analysis history
*/
async getAnalysisHistory(userId: string, limit: number = 20) {
return this.prisma.analysis.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: limit,
});
}
}
@@ -0,0 +1,16 @@
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'],
minItems: 1,
maxItems: 20,
})
@IsArray()
@IsString({ each: true })
@ArrayMinSize(1)
@ArrayMaxSize(20)
matchIds: string[];
}
+78
View File
@@ -0,0 +1,78 @@
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';
import {
ApiResponse,
createSuccessResponse,
} from '../../common/types/api-response.type';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@Public()
@HttpCode(200)
@ApiOperation({ summary: 'Register a new user' })
@ApiOkResponse({
description: 'User registered successfully',
type: TokenResponseDto,
})
async register(
@Body() dto: RegisterDto,
@I18n() i18n: I18nContext,
): Promise<ApiResponse<TokenResponseDto>> {
const result = await this.authService.register(dto);
return createSuccessResponse(result, i18n.t('auth.registered'), 201);
}
@Post('login')
@Public()
@HttpCode(200)
@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'));
}
@Post('refresh')
@Public()
@HttpCode(200)
@ApiOperation({ summary: 'Refresh access token' })
@ApiOkResponse({
description: 'Token refreshed successfully',
type: TokenResponseDto,
})
async refreshToken(
@Body() dto: RefreshTokenDto,
@I18n() i18n: I18nContext,
): Promise<ApiResponse<TokenResponseDto>> {
const result = await this.authService.refreshToken(dto.refreshToken);
return createSuccessResponse(result, i18n.t('auth.refresh_success'));
}
@Post('logout')
@HttpCode(200)
@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'));
}
}
+37
View File
@@ -0,0 +1,37 @@
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' }),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService): JwtModuleOptions => {
const expiresIn =
configService.get<string>('JWT_ACCESS_EXPIRATION') || '15m';
return {
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: expiresIn as any,
},
};
},
}),
],
controllers: [AuthController],
providers: [
AuthService,
JwtStrategy,
JwtAuthGuard,
RolesGuard,
PermissionsGuard,
],
exports: [AuthService, JwtAuthGuard, RolesGuard, PermissionsGuard],
})
export class AuthModule {}
+248
View File
@@ -0,0 +1,248 @@
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';
export interface JwtPayload {
sub: string;
email: string;
role: string;
tenantId?: string;
}
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
/**
* Register a new user
*/
async register(dto: RegisterDto): Promise<TokenResponseDto> {
// Check if email already exists
const existingUser = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (existingUser) {
throw new ConflictException('EMAIL_ALREADY_EXISTS');
}
// Hash password
const hashedPassword = await this.hashPassword(dto.password);
// Create user with default role
const user = await this.prisma.user.create({
data: {
email: dto.email,
passwordHash: hashedPassword,
firstName: dto.firstName,
lastName: dto.lastName,
role: UserRole.user,
},
});
// Create usage limit for user
await this.prisma.usageLimit.create({
data: {
userId: user.id,
analysisCount: 0,
couponCount: 0,
lastResetDate: new Date(),
},
});
return this.generateTokens(user);
}
/**
* Login with email and password
*/
async login(dto: LoginDto): Promise<TokenResponseDto> {
// Find user by email
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (!user) {
throw new UnauthorizedException('INVALID_CREDENTIALS');
}
// Verify password
const isPasswordValid = await this.comparePassword(
dto.password,
user.passwordHash,
);
if (!isPasswordValid) {
throw new UnauthorizedException('INVALID_CREDENTIALS');
}
if (!user.isActive) {
throw new UnauthorizedException('ACCOUNT_DISABLED');
}
return this.generateTokens(user);
}
/**
* Refresh access token using refresh token
*/
async refreshToken(refreshToken: string): Promise<TokenResponseDto> {
// Find refresh token
const storedToken = await this.prisma.refreshToken.findUnique({
where: { token: refreshToken },
include: {
user: true,
},
});
if (!storedToken) {
throw new UnauthorizedException('INVALID_REFRESH_TOKEN');
}
if (storedToken.expiresAt < new Date()) {
// Delete expired token
await this.prisma.refreshToken.delete({
where: { id: storedToken.id },
});
throw new UnauthorizedException('INVALID_REFRESH_TOKEN');
}
// Delete old refresh token
await this.prisma.refreshToken.delete({
where: { id: storedToken.id },
});
return this.generateTokens(storedToken.user);
}
/**
* Logout - invalidate refresh token
*/
async logout(refreshToken: string): Promise<void> {
await this.prisma.refreshToken.deleteMany({
where: { token: refreshToken },
});
}
/**
* Validate user by ID (used by JWT strategy)
*/
async validateUser(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user || !user.isActive) {
return null;
}
// Remove password from user object
const { passwordHash: _, ...result } = user;
return result;
}
/**
* Generate access and refresh tokens
*/
private async generateTokens(user: User): Promise<TokenResponseDto> {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
role: user.role,
tenantId: undefined,
};
// Generate access token
const accessToken = this.jwtService.sign(payload, {
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'),
);
// Store refresh token
await this.prisma.refreshToken.create({
data: {
token: refreshTokenValue,
userId: user.id,
expiresAt: new Date(Date.now() + refreshExpiration),
},
});
return {
accessToken,
refreshToken: refreshTokenValue,
expiresIn:
this.parseExpiration(
this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
) / 1000, // Convert to seconds
user: {
id: user.id,
email: user.email,
firstName: user.firstName || undefined,
lastName: user.lastName || undefined,
roles: [user.role], // Single role as array for backwards compatibility
},
};
}
/**
* Hash password using bcrypt
*/
private async hashPassword(password: string): Promise<string> {
const saltRounds = 12;
return bcrypt.hash(password, saltRounds);
}
/**
* Compare password with hash
*/
private async comparePassword(
password: string,
hashedPassword: string,
): Promise<boolean> {
return bcrypt.compare(password, hashedPassword);
}
/**
* Parse expiration string to milliseconds
*/
private parseExpiration(expiration: string): number {
const match = expiration.match(/^(\d+)([smhd])$/);
if (!match) {
return 15 * 60 * 1000; // Default 15 minutes
}
const value = parseInt(match[1], 10);
const unit = match[2];
switch (unit) {
case 's':
return value * 1000;
case 'm':
return value * 60 * 1000;
case 'h':
return value * 60 * 60 * 1000;
case 'd':
return value * 24 * 60 * 60 * 1000;
default:
return 15 * 60 * 1000;
}
}
}
+70
View File
@@ -0,0 +1,70 @@
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'password123', minLength: 8 })
@IsString()
@MinLength(8)
password: string;
@ApiPropertyOptional({ example: 'John' })
@IsOptional()
@IsString()
firstName?: string;
@ApiPropertyOptional({ example: 'Doe' })
@IsOptional()
@IsString()
lastName?: string;
}
export class LoginDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'password123' })
@IsString()
password: string;
}
export class RefreshTokenDto {
@ApiProperty()
@IsString()
refreshToken: string;
}
export class UserInfoDto {
@ApiProperty()
id: string;
@ApiProperty()
email: string;
@ApiProperty({ required: false })
firstName?: string;
@ApiProperty({ required: false })
lastName?: string;
@ApiProperty()
roles: string[];
}
export class TokenResponseDto {
@ApiProperty()
accessToken: string;
@ApiProperty()
refreshToken: string;
@ApiProperty()
expiresIn: number;
@ApiProperty({ type: UserInfoDto })
user: UserInfoDto;
}
+142
View File
@@ -0,0 +1,142 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
ForbiddenException,
} 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';
interface AuthenticatedUser {
id: string;
email: string;
roles: string[];
permissions: string[];
}
/**
* JWT Auth Guard - Validates JWT token
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<Request>();
if (request?.method === 'OPTIONS') {
return true;
}
// Check if route is public
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
handleRequest<TUser = AuthenticatedUser>(
err: Error | null,
user: TUser | false,
info: any,
): TUser {
if (err || !user) {
if (info?.name === 'TokenExpiredError') {
throw new UnauthorizedException('TOKEN_EXPIRED');
}
throw err || new UnauthorizedException('AUTH_REQUIRED');
}
return user;
}
}
/**
* Roles Guard - Check if user has required roles
*/
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
if (req?.method === 'OPTIONS') {
return true;
}
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const user = req.user as AuthenticatedUser | undefined;
if (!user || !user.roles) {
return false;
}
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
if (!hasRole) {
throw new ForbiddenException('PERMISSION_DENIED');
}
return true;
}
}
/**
* Permissions Guard - Check if user has required permissions
*/
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
if (req?.method === 'OPTIONS') {
return true;
}
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const user = req.user as AuthenticatedUser | undefined;
if (!user || !user.permissions) {
return false;
}
const hasPermission = requiredPermissions.every((permission) =>
user.permissions.includes(permission),
);
if (!hasPermission) {
throw new ForbiddenException('PERMISSION_DENIED');
}
return true;
}
}
+1
View File
@@ -0,0 +1 @@
export * from './auth.guards';
+37
View File
@@ -0,0 +1,37 @@
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) {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
const secret = configService.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('JWT_SECRET is not defined');
}
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: secret,
});
}
async validate(payload: JwtPayload) {
const user = await this.authService.validateUser(payload.sub);
if (!user) {
return null;
}
return {
...user,
role: payload.role,
};
}
}
+238
View File
@@ -0,0 +1,238 @@
import {
Controller,
Post,
Get,
Body,
Query,
HttpCode,
HttpStatus,
UseGuards,
Req,
Logger,
} 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';
import {
UserCouponService,
CreateCouponDto,
} 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';
@ApiTags('Coupon')
@Controller('coupon')
export class CouponsController {
private readonly logger = new Logger(CouponsController.name);
constructor(
private readonly couponsService: CouponsService,
private readonly smartCouponService: SmartCouponService,
private readonly userCouponService: UserCouponService,
private readonly matchesService: MatchesService,
) {}
/**
* POST /coupon/analyze-match
* Analyze a single match with V20+ single-match package
*/
@Post('analyze-match')
@Public()
@HttpCode(HttpStatus.OK)
@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: true, data: analysis };
}
/**
* POST /coupon/analyze (alias for /analyze-match - frontend compatibility)
*/
@Post('analyze')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Analyze single match with V20 model (alias)',
deprecated: true,
})
async analyzeMatchAlias(@Body() dto: AnalyzeMatchDto) {
return this.analyzeMatch(dto);
}
/**
* POST /coupon
* Alias for /coupon/create - frontend compatibility
*/
@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create and save a user coupon (alias)' })
async createCouponAlias(@Body() dto: CreateCouponDto, @Req() req: any) {
return this.createCoupon(dto, req);
}
/**
* POST /coupon/daily-banko
* Generate a high-confidence banko combo (2 matches)
*/
@Post('daily-banko')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({
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)
let candidateMatches = dto.matchIds || [];
if (candidateMatches.length === 0) {
candidateMatches = await this.matchesService.findUpcomingMatches(
Sport.FOOTBALL,
20,
);
this.logger.debug(
`Auto-fetched ${candidateMatches.length} matches for daily-banko`,
);
} else {
candidateMatches = await this.matchesService.filterUpcomingMatchIds(
candidateMatches,
Sport.FOOTBALL,
);
this.logger.debug(
`Sanitized candidate matches for daily-banko: ${candidateMatches.length}`,
);
}
if (candidateMatches.length === 0) {
return {
success: false,
message: 'Kupon için uygun, henüz başlamamış maç bulunamadı.',
};
}
const coupon =
await this.smartCouponService.generateDailyBankoCoupon(candidateMatches);
if (!coupon) {
return {
success: false,
message: 'Kriterlere uygun (80%+ güvenli) yeterli maç bulunamadı.',
};
}
return { success: true, data: coupon };
}
/**
* POST /coupon/suggest
* Generate Smart Coupon
*/
@Post('suggest')
@Public()
@HttpCode(HttpStatus.OK)
@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 || [];
if (candidateMatches.length === 0) {
candidateMatches = await this.matchesService.findUpcomingMatches(
Sport.FOOTBALL,
20,
);
this.logger.debug(
`Auto-fetched ${candidateMatches.length} matches for suggest`,
);
} else {
candidateMatches = await this.matchesService.filterUpcomingMatchIds(
candidateMatches,
Sport.FOOTBALL,
);
this.logger.debug(
`Sanitized candidate matches for suggest: ${candidateMatches.length}`,
);
}
if (candidateMatches.length === 0) {
return {
success: false,
message: 'Tahmin için uygun, henüz başlamamış maç bulunamadı.',
};
}
const coupon = await this.smartCouponService.getSmartCoupon(
candidateMatches,
dto.strategy,
{
maxMatches: dto.maxMatches,
minConfidence: dto.minConfidence,
},
);
if (!coupon) {
return { success: false, message: 'Kupon oluşturulamadı.' };
}
return { success: true, data: coupon };
}
// ============================================
// USER COUPON ENDPOINTS (NEW)
// ============================================
/**
* POST /coupon/create
* Save a user generated coupon
*/
@Post('create')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@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);
return { success: true, data: coupon };
}
/**
* GET /coupon/my-stats
* Get user betting statistics (ROI, Win Rate)
*/
@Get('my-stats')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@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 };
}
/**
* GET /coupon/history
* Get coupon history (Public/System coupons)
*/
@Get('history')
@ApiBearerAuth()
@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,
);
return { success: true, data: results };
}
}
+16
View File
@@ -0,0 +1,16 @@
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],
controllers: [CouponsController],
providers: [CouponsService, SmartCouponService, UserCouponService],
exports: [CouponsService, SmartCouponService, UserCouponService],
})
export class CouponsModule {}
+38
View File
@@ -0,0 +1,38 @@
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 interface CouponMatch {
matchId: string;
matchName: string;
prediction: string;
confidence: number;
odd: number;
}
export interface GeneratedCoupon {
id: string;
matches: CouponMatch[];
totalOdd: number;
riskLevel: RiskLevel;
generatedAt: string;
}
@Injectable()
export class CouponsService {
private readonly logger = new Logger(CouponsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly aiService: AiService,
) {}
/**
* Legacy history/history methods...
*/
getCouponHistory(_limit: number = 10) {
return [];
}
}
@@ -0,0 +1,76 @@
import {
IsArray,
IsString,
IsOptional,
IsNotEmpty,
IsNumber,
IsEnum,
ArrayMaxSize,
Min,
Max,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum CouponStrategyEnum {
SAFE = 'SAFE',
BALANCED = 'BALANCED',
AGGRESSIVE = 'AGGRESSIVE',
VALUE = 'VALUE',
MIRACLE = 'MIRACLE',
}
export class AnalyzeMatchDto {
@ApiProperty({ description: 'Match ID to analyze' })
@IsString()
@IsNotEmpty()
matchId: string;
}
export class DailyBankoDto {
@ApiPropertyOptional({
description: 'Optional match IDs — system fetches if empty',
example: ['match-1', 'match-2'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(50)
matchIds?: string[];
}
export class SuggestCouponDto {
@ApiPropertyOptional({
description: 'Match IDs — system fetches if empty',
example: ['match-1', 'match-2'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(50)
matchIds?: string[];
@ApiPropertyOptional({
enum: CouponStrategyEnum,
default: CouponStrategyEnum.BALANCED,
})
@IsOptional()
@IsEnum(CouponStrategyEnum)
strategy?: CouponStrategyEnum;
@ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 })
@IsOptional()
@IsNumber()
@Min(1)
@Max(20)
maxMatches?: number;
@ApiPropertyOptional({
description: 'Minimum confidence threshold (0-100)',
example: 60,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
minConfidence?: number;
}
+248
View File
@@ -0,0 +1,248 @@
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 interface PredictionPickRow {
market: string;
pick: string;
probability: number;
confidence: number;
odds: number;
raw_confidence: number;
calibrated_confidence: number;
min_required_confidence: number;
edge: number;
play_score: number;
playable: boolean;
bet_grade: BetGrade;
stake_units: number;
decision_reasons: string[];
}
export interface PredictionBetSummaryRow {
market: string;
pick: string;
raw_confidence: number;
calibrated_confidence: number;
bet_grade: BetGrade;
playable: boolean;
stake_units: number;
play_score: number;
reasons: string[];
}
export interface SingleMatchPredictionPackage {
model_version: string;
match_info: {
match_id: string;
match_name: string;
home_team: string;
away_team: string;
league: string;
match_date_ms: number;
};
data_quality: {
label: PredictionDataQuality;
score: number;
flags: string[];
home_lineup_count: number;
away_lineup_count: number;
};
risk: {
level: PredictionRiskLevel;
score: number;
is_surprise_risk: boolean;
surprise_type: string | null;
warnings: string[];
};
engine_breakdown: {
team: number;
player: number;
odds: number;
referee: number;
};
main_pick: PredictionPickRow | null;
value_pick: PredictionPickRow | null;
bet_advice: {
playable: boolean;
suggested_stake_units: number;
reason: string;
};
bet_summary: PredictionBetSummaryRow[];
supporting_picks: PredictionPickRow[];
aggressive_pick: {
market: string;
pick: string;
probability: number;
confidence: number;
odds: number | null;
} | null;
scenario_top5: Array<{
score: string;
prob: number;
[key: string]: unknown;
}>;
score_prediction: {
ft: string;
ht: string;
xg_home: number;
xg_away: number;
xg_total: number;
};
market_board: Record<string, unknown>;
reasoning_factors: string[];
ai_commentary?: string | null;
}
export interface SmartCouponResult {
strategy: string;
generated_at: string;
match_count: number;
bets: Array<{
match_id: string;
match_name: string;
market: string;
pick: string;
probability: number;
confidence: number;
odds: number;
risk_level: PredictionRiskLevel;
data_quality: PredictionDataQuality;
}>;
total_odds: number;
expected_win_rate: number;
rejected_matches: Array<{
match_id: string;
reason: string;
threshold?: number;
}>;
}
@Injectable()
export class SmartCouponService {
private readonly logger = new Logger(SmartCouponService.name);
private readonly aiEngineUrl: string;
constructor(private readonly geminiService: GeminiService) {
this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000';
}
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
let prediction: SingleMatchPredictionPackage;
try {
const response = await axios.post<SingleMatchPredictionPackage>(
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
);
prediction = response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const detail = error.response?.data?.detail || error.message;
throw new HttpException(
`AI analyze failed: ${detail}`,
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
);
}
throw new HttpException(
'AI analyze failed',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Generate AI commentary (non-blocking — fail-safe)
prediction.ai_commentary = await this.generateMatchCommentary(prediction);
return prediction;
}
private async generateMatchCommentary(
prediction: SingleMatchPredictionPackage,
): Promise<string | null> {
if (!this.geminiService.isAvailable()) {
return null;
}
try {
const result = await this.geminiService.generateText(
JSON.stringify(prediction, null, 2),
{
model: 'gemini-2.0-flash',
temperature: 0.7,
maxTokens: 600,
systemPrompt: MATCH_COMMENTARY_SYSTEM_PROMPT,
},
);
return result.text || null;
} catch (error) {
this.logger.warn('AI commentary generation failed, skipping', error);
return null;
}
}
async generateDailyBankoCoupon(
matchIds: string[],
): Promise<SmartCouponResult | null> {
if (matchIds.length === 0) {
return null;
}
return this.getSmartCoupon(matchIds, 'SAFE', {
maxMatches: 2,
minConfidence: 78,
});
}
async getSmartCoupon(
matchIds: string[],
strategy:
| 'SAFE'
| 'BALANCED'
| 'AGGRESSIVE'
| 'VALUE'
| 'MIRACLE' = 'BALANCED',
options: { maxMatches?: number; minConfidence?: number } = {},
): Promise<SmartCouponResult> {
try {
const response = await axios.post<SmartCouponResult>(
`${this.aiEngineUrl}/v20plus/coupon`,
{
match_ids: matchIds,
strategy,
max_matches: options.maxMatches,
min_confidence: options.minConfidence,
},
);
return response.data;
} catch (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(
`Coupon generation failed: ${detail}`,
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
);
}
throw new HttpException(
'Coupon generation failed',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}
const MATCH_COMMENTARY_SYSTEM_PROMPT = `Sen uzman bir futbol bahis analistisin. Sana verilen model çıktısını analiz edip kısa, net ve aksiyon odaklı Türkçe bir yorum yaz.
Kurallar:
- Max 3-4 kısa paragraf, gereksiz uzatma
- Playable olan marketleri ve nedenlerini açıkla
- Edge pozitif olan marketleri vurgula (bahisçiden daha iyi biliyoruz)
- Tüm edge'ler negatifse "trap maç" olarak uyar
- xG ve skor senaryolarına göre strateji öner
- Bahis grade'lerini açıkla: A = güvenli, B = iyi, PASS = oynama
- Data quality ve risk seviyesini yorumla (kadro onaylı mı, probable XI mi)
- "Ben olsam..." formatında kişisel tavsiye ver
- Emoji kullan: ⚽ ✅ ⚠️ 🎯 ❌ 💰
- Markdown formatı KULLANMA, düz metin yaz
- Bahis terminolojisi kullan: edge, value, implied odds, xG`;
+189
View File
@@ -0,0 +1,189 @@
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'
items: {
matchId: string;
selection: string; // 'MS 1', '2.5 UST'
odd: number;
}[];
isPublic?: boolean;
}
export interface UserStatsDto {
totalCoupons: number;
wonCoupons: number;
winRate: number; // Percentage
totalInvested: number; // Unit based (1 unit per coupon)
totalReturn: number;
roi: number; // Return on Investment %
}
@Injectable()
export class UserCouponService {
private readonly logger = new Logger(UserCouponService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Kullanıcı için yeni bir kupon oluşturur ve kaydeder.
*/
async createCoupon(user: User, dto: CreateCouponDto): Promise<UserCoupon> {
const totalOdds = dto.items.reduce((acc, item) => acc * item.odd, 1);
const coupon = await this.prisma.userCoupon.create({
data: {
userId: user.id,
strategy: dto.strategy,
totalOdds: parseFloat(totalOdds.toFixed(2)),
isPublic: dto.isPublic || false,
status: 'PENDING',
couponItems: {
create: dto.items.map((item) => ({
matchId: item.matchId,
selection: item.selection,
oddAtTime: item.odd,
})),
},
},
include: {
couponItems: true,
},
});
this.logger.log(
`Coupon created for user ${user.email} with odds ${totalOdds}`,
);
return coupon;
}
/**
* Bekleyen kuponların sonuçlarını kontrol eder ve günceller.
* Bu metod bir Cron Job tarafından periyodik olarak çağrılmalıdır.
*/
async updatePendingCoupons(): Promise<void> {
// Sadece bitmiş (FT) maçları içeren PENDING kuponları çek
const pendingCoupons = await this.prisma.userCoupon.findMany({
where: { status: 'PENDING' },
include: {
couponItems: {
include: { match: true },
},
},
});
for (const coupon of pendingCoupons) {
let isCouponWon = true;
let isCouponLost = false;
let allMatchesFinished = true;
for (const item of coupon.couponItems) {
if (item.match.status !== 'FT') {
allMatchesFinished = false;
break; // Henüz bitmemiş maç var, kuponu güncelleme
}
const isItemWon = this.checkSelection(item.selection, item.match);
// Sonucu item bazında güncelle
if (item.isCorrect !== isItemWon) {
await this.prisma.userCouponItem.update({
where: { id: item.id },
data: { isCorrect: isItemWon },
});
}
if (!isItemWon) {
isCouponLost = true;
isCouponWon = false;
}
}
if (isCouponLost) {
await this.prisma.userCoupon.update({
where: { id: coupon.id },
data: { status: 'LOST' },
});
} else if (allMatchesFinished && isCouponWon) {
await this.prisma.userCoupon.update({
where: { id: coupon.id },
data: { status: 'WON' },
});
}
}
}
/**
* Basit bir kural seti ile bahsin tutup tutmadığını kontrol eder.
* Gerçek dünyada bu daha karmaşık bir 'BetSettlementService' olmalıdır.
*/
private checkSelection(selection: string, match: Match): boolean {
const home = match.scoreHome ?? 0;
const away = match.scoreAway ?? 0;
const total = home + away;
switch (selection) {
case 'MS 1':
return home > away;
case 'MS X':
return home === away;
case 'MS 2':
return away > home;
case '1.5 UST':
return total > 1.5;
case '2.5 UST':
return total > 2.5;
case '3.5 UST':
return total > 3.5;
case '2.5 ALT':
return total < 2.5;
case 'KG VAR':
return home > 0 && away > 0;
case 'KG YOK':
return home === 0 || away === 0;
default:
return false; // Bilinmeyen market
}
}
/**
* Kullanıcının bahis performans istatistiklerini getirir.
*/
async getUserStatistics(userId: string): Promise<UserStatsDto> {
const coupons = await this.prisma.userCoupon.findMany({
where: {
userId,
status: { in: ['WON', 'LOST'] },
},
});
const totalCoupons = coupons.length;
if (totalCoupons === 0) {
return {
totalCoupons: 0,
wonCoupons: 0,
winRate: 0,
totalInvested: 0,
totalReturn: 0,
roi: 0,
};
}
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;
const roi = ((totalReturn - totalInvested) / totalInvested) * 100;
return {
totalCoupons,
wonCoupons: wonCoupons.length,
winRate: parseFloat(winRate.toFixed(2)),
totalInvested,
totalReturn: parseFloat(totalReturn.toFixed(2)),
roi: parseFloat(roi.toFixed(2)),
};
}
}
+987
View File
@@ -0,0 +1,987 @@
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* Feeder Persistence Service - Senior Level Implementation
* Database operations using Prisma
*/
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import {
Sport,
MatchSummary,
Competition,
TransformedPlayer,
MatchParticipation,
TransformedMatchStats,
MatchOfficial,
ParsedMatchHeader,
BasketballPlayerStats,
DbEventPayload,
DbMarketPayload,
BasketballTeamStats,
} from './feeder.types';
import { ImageUtils } from '../../common/utils/image.util';
@Injectable()
export class FeederPersistenceService {
private readonly logger = new Logger(FeederPersistenceService.name);
constructor(private readonly prisma: PrismaService) {}
// ============================================
// HELPER FUNCTIONS
// ============================================
private safeString(value: any): string | null {
return value === null || value === undefined || value === ''
? null
: String(value);
}
private safeInt(value: any): number | null {
const num = parseInt(String(value), 10);
return isNaN(num) ? null : num;
}
private safeFloat(value: any): number | null {
const num = parseFloat(String(value));
return isNaN(num) ? null : num;
}
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';
return null;
}
// ============================================
// ODDS HELPER (TRANSACTION SAFE)
// ============================================
private async saveOddsInTransaction(
tx: any,
matchId: string,
oddsArray: DbMarketPayload[],
): Promise<void> {
if (oddsArray.length === 0) return;
const existingCategories = await tx.oddCategory.findMany({
where: { matchId },
include: { selections: true },
});
for (const market of oddsArray) {
if (!market || !market.name || !market.selectionCollection) continue;
let category = existingCategories.find((c) => c.name === market.name);
if (!category) {
category = await tx.oddCategory.create({
data: {
matchId,
categoryJsonId: this.safeInt(market.id),
name: market.name,
},
include: { selections: true },
});
existingCategories.push(category);
}
for (const s of market.selectionCollection) {
if (!s || s.odd === '-' || s.odd === '') continue;
const sName = this.safeString(s.name);
const sValue = this.safeString(s.odd);
const sPos = this.safeString(s.position);
if (!sName || !sValue) continue;
const existingSel = category.selections.find(
(sel) => sel.name === sName,
);
if (existingSel) {
if (existingSel.oddValue !== sValue) {
const oldVal = parseFloat(existingSel.oddValue || '0');
const newVal = parseFloat(sValue);
if (!isNaN(oldVal) && !isNaN(newVal)) {
await tx.oddsHistory.create({
data: {
selectionId: existingSel.dbId,
matchId: matchId,
previousValue: oldVal,
newValue: newVal,
},
});
}
await tx.oddSelection.update({
where: { dbId: existingSel.dbId },
data: { oddValue: sValue, position: sPos },
});
}
} else {
const newSel = await tx.oddSelection.create({
data: {
categoryId: category.dbId,
name: sName,
oddValue: sValue,
position: sPos,
},
});
category.selections.push(newSel);
}
}
}
}
// ============================================
// MAIN SAVE FUNCTION
// ============================================
async saveMatch(
sport: Sport,
matchId: string,
matchSummary: MatchSummary,
league: Competition,
homeTeamId: string,
awayTeamId: string,
headerData: ParsedMatchHeader | null,
playersMap: Map<string, TransformedPlayer>,
participationData: MatchParticipation[],
eventData: DbEventPayload[],
stats: TransformedMatchStats | null,
basketballTeamStats: BasketballTeamStats | null,
basketballPlayerStats: Partial<BasketballPlayerStats>[],
oddsArray: DbMarketPayload[],
officialsData: MatchOfficial[],
): Promise<boolean> {
// START IMAGE DOWNLOADS (NON-BLOCKING)
const imageDownloads: Promise<void>[] = [];
const leagueId = this.safeString(league.id);
if (leagueId) {
const logoUrl = `https://file.mackolikfeeds.com/areas/${leagueId}`;
const localPath = `public/uploads/competitions/${leagueId}.png`;
imageDownloads.push(
ImageUtils.downloadImage(logoUrl, localPath)
.then(() => void 0)
.catch((err) => {
this.logger.error(
`Failed to download league logo ${leagueId}: ${err}`,
);
}),
);
}
const teamsToUpsert = [
{
id: homeTeamId,
name: matchSummary.homeTeam?.name || 'Unknown',
slug: matchSummary.homeTeam?.slug || homeTeamId,
sport: sport,
},
{
id: awayTeamId,
name: matchSummary.awayTeam?.name || 'Unknown',
slug: matchSummary.awayTeam?.slug || awayTeamId,
sport: sport,
},
];
for (const team of teamsToUpsert) {
const teamLogoUrl = `https://file.mackolikfeeds.com/teams/${team.id}`;
const teamLocalPath = `public/uploads/teams/${team.id}.png`;
imageDownloads.push(
ImageUtils.downloadImage(teamLogoUrl, teamLocalPath)
.then(() => void 0)
.catch((err) => {
this.logger.error(
`Failed to download team logo ${team.id}: ${err}`,
);
}),
);
}
// DATABASE TRANSACTION
try {
await this.prisma.$transaction(
async (tx) => {
// 1. Save Country
const countryId = this.safeString(league.country?.id);
if (countryId) {
try {
await tx.country.upsert({
where: { id: countryId },
update: {},
create: {
id: countryId,
name: league.country.name || 'Unknown',
},
});
} catch (error: any) {
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';
// Check if league exists by unique constraint (name + country + sport)
const existingLeague = await tx.league.findUnique({
where: {
name_countryId_sport: {
name: leagueName,
countryId: countryId,
sport: sport,
},
},
});
if (existingLeague) {
// If exists with different ID, use existing ID to prevent constraint errors
finalLeagueId = existingLeague.id;
} else {
// Create new league
await tx.league.create({
data: {
id: finalLeagueId,
name: leagueName,
countryId: countryId,
sport: sport,
competitionSlug: league.competitionSlug,
logoUrl: `/uploads/competitions/${finalLeagueId}.png`,
},
});
}
}
// 3. Save Teams (BULK OPTIMIZED)
const existingTeams = await tx.team.findMany({
where: {
id: { in: [homeTeamId, awayTeamId] },
},
select: { id: true },
});
const existingTeamIds = new Set(existingTeams.map((t) => t.id));
const teamsToCreate = teamsToUpsert.filter(
(t) => !existingTeamIds.has(t.id),
);
const teamsToUpdate = teamsToUpsert.filter((t) =>
existingTeamIds.has(t.id),
);
if (teamsToCreate.length > 0) {
await tx.team.createMany({
data: teamsToCreate.map((t) => ({
...t,
logoUrl: `/uploads/teams/${t.id}.png`,
})),
skipDuplicates: true,
});
}
for (const team of teamsToUpdate) {
await tx.team.update({
where: { id: team.id },
data: {
name: team.name,
logoUrl: `/uploads/teams/${team.id}.png`,
},
});
}
// 4. Save Match
const finalScoreHome =
headerData?.scoreHome ?? this.safeInt(matchSummary.score?.home);
const finalScoreAway =
headerData?.scoreAway ?? this.safeInt(matchSummary.score?.away);
const htScoreHome =
headerData?.htScoreHome ??
this.safeInt(matchSummary.score?.ht?.home);
const htScoreAway =
headerData?.htScoreAway ??
this.safeInt(matchSummary.score?.ht?.away);
let status = 'NS';
if (headerData?.matchStatus) {
if (
headerData.matchStatus === 'postGame' ||
headerData.matchStatus === 'post'
) {
status = 'FT';
} else if (
headerData.matchStatus === 'live' ||
headerData.matchStatus === 'liveGame'
) {
status = 'LIVE';
}
}
// Handle Postponed Matches (ERT)
if (matchSummary.statusBoxContent === 'ERT') {
status = 'POSTPONED';
}
if (
status === 'NS' &&
finalScoreHome !== null &&
finalScoreAway !== null
) {
status = 'FT';
}
await tx.match.upsert({
where: { id: matchId },
update: {
scoreHome: finalScoreHome,
scoreAway: finalScoreAway,
htScoreHome: htScoreHome,
htScoreAway: htScoreAway,
status: status,
state: headerData?.matchStatus || null,
},
create: {
id: matchId,
leagueId: finalLeagueId || undefined,
homeTeamId: homeTeamId,
awayTeamId: awayTeamId,
sport: sport,
matchName: matchSummary.matchName,
matchSlug: matchSummary.matchSlug,
mstUtc: BigInt(matchSummary.mstUtc || 0),
status: status,
state: headerData?.matchStatus || null,
scoreHome: finalScoreHome,
scoreAway: finalScoreAway,
htScoreHome: htScoreHome,
htScoreAway: htScoreAway,
winner: matchSummary.winner || null,
iddaaCode: this.safeString(matchSummary.iddaaCode),
},
});
// 5. Save Players (BULK OPTIMIZED)
const playersArray = Array.from(playersMap.values());
if (playersArray.length > 0) {
const existingPlayers = await tx.player.findMany({
where: {
id: { in: playersArray.map((p) => p.id) },
},
select: { id: true },
});
const existingPlayerIds = new Set(existingPlayers.map((p) => p.id));
const playersToCreate = playersArray.filter(
(p) => !existingPlayerIds.has(p.id),
);
const playersToUpdate = playersArray.filter((p) =>
existingPlayerIds.has(p.id),
);
if (playersToCreate.length > 0) {
await tx.player.createMany({
data: playersToCreate.map((p) => ({
id: p.id,
name: p.name,
slug: p.slug,
})),
skipDuplicates: true,
});
}
if (playersToUpdate.length > 0) {
await Promise.all(
playersToUpdate.map((p) =>
tx.player.update({
where: { id: p.id },
data: { name: p.name },
}),
),
);
}
}
// 6. Save Participation
if (participationData.length > 0) {
await tx.matchPlayerParticipation.deleteMany({
where: { matchId: matchId },
});
await tx.matchPlayerParticipation.createMany({
data: participationData.map((p) => ({
matchId: p.matchId,
playerId: p.playerId,
teamId: p.teamId,
position: this.mapPositionToEnum(p.position),
shirtNumber: p.shirtNumber,
isStarting: p.isStarting,
})),
skipDuplicates: true,
});
}
// 7. Save Events
if (eventData.length > 0) {
await tx.matchPlayerEvents.deleteMany({
where: { matchId: matchId },
});
await tx.matchPlayerEvents.createMany({
data: eventData.map((e) => ({
matchId: e.match_id,
playerId: e.player_id,
teamId: e.team_id,
eventType: e.event_type,
eventSubtype: e.event_subtype,
timeMinute: e.time_minute,
timeSeconds: e.time_seconds,
periodId: e.period_id,
assistPlayerId: e.assist_player_id,
scoreAfter: e.score_after,
playerOutId: e.player_out_id,
position: e.position,
})),
skipDuplicates: true,
});
}
// 8. Save Team Stats (Football)
if (stats && sport === 'football') {
const statsRows = [
{
matchId,
teamId: homeTeamId,
possessionPercentage: stats.home.possesionPercentage,
shotsOnTarget: stats.home.shotsOnTarget,
shotsOffTarget: stats.home.shotsOffTarget,
totalShots:
(stats.home.shotsOnTarget || 0) +
(stats.home.shotsOffTarget || 0) || null,
totalPasses: stats.home.totalPasses,
corners: stats.home.corners,
fouls: stats.home.fouls,
offsides: stats.home.offsides,
},
{
matchId,
teamId: awayTeamId,
possessionPercentage: stats.away.possesionPercentage,
shotsOnTarget: stats.away.shotsOnTarget,
shotsOffTarget: stats.away.shotsOffTarget,
totalShots:
(stats.away.shotsOnTarget || 0) +
(stats.away.shotsOffTarget || 0) || null,
totalPasses: stats.away.totalPasses,
corners: stats.away.corners,
fouls: stats.away.fouls,
offsides: stats.away.offsides,
},
];
for (const row of statsRows) {
await tx.footballTeamStats.upsert({
where: {
matchId_teamId: { matchId: row.matchId, teamId: row.teamId },
},
update: row,
create: row,
});
}
}
// 8b. Save Team Stats (Basketball)
if (basketballTeamStats && sport === 'basketball') {
const teams = [
{ id: homeTeamId, data: basketballTeamStats.home },
{ id: awayTeamId, data: basketballTeamStats.away },
];
for (const t of teams) {
if (!t.data) continue;
await tx.basketballTeamStats.upsert({
where: {
matchId_teamId: { matchId, teamId: t.id },
},
update: {
points: t.data.points,
rebounds: t.data.rebounds,
assists: t.data.assists,
fgMade: t.data.fgMade,
fgAttempted: t.data.fgAttempted,
threePtMade: t.data.threePtMade,
threePtAttempted: t.data.threePtAttempted,
ftMade: t.data.ftMade,
ftAttempted: t.data.ftAttempted,
steals: t.data.steals,
blocks: t.data.blocks,
turnovers: t.data.turnovers,
fouls: t.data.fouls,
q1Score: t.data.q1,
q2Score: t.data.q2,
q3Score: t.data.q3,
q4Score: t.data.q4,
otScore: t.data.ot,
},
create: {
matchId,
teamId: t.id,
points: t.data.points,
rebounds: t.data.rebounds,
assists: t.data.assists,
fgMade: t.data.fgMade,
fgAttempted: t.data.fgAttempted,
threePtMade: t.data.threePtMade,
threePtAttempted: t.data.threePtAttempted,
ftMade: t.data.ftMade,
ftAttempted: t.data.ftAttempted,
steals: t.data.steals,
blocks: t.data.blocks,
turnovers: t.data.turnovers,
fouls: t.data.fouls,
q1Score: t.data.q1,
q2Score: t.data.q2,
q3Score: t.data.q3,
q4Score: t.data.q4,
otScore: t.data.ot,
},
});
}
}
// 8c. Save Player Stats (Basketball)
if (basketballPlayerStats.length > 0 && sport === 'basketball') {
await tx.basketballPlayerStats.deleteMany({ where: { matchId } });
for (const p of basketballPlayerStats) {
if (!p.id || !p.teamId) continue;
await tx.basketballPlayerStats.create({
data: {
matchId,
playerId: p.id,
teamId: p.teamId,
minutes: p.minutes,
points: p.points,
rebounds: p.rebounds,
assists: p.assists,
fgMade: p.fgMade,
fgAttempted: p.fgAttempted,
threePtMade: p.threePtMade,
threePtAttempted: p.threePtAttempted,
ftMade: p.ftMade,
ftAttempted: p.ftAttempted,
steals: p.steals,
blocks: p.blocks,
turnovers: p.turnovers,
fouls: p.fouls,
},
});
}
}
// 9. Save Odds (USING HELPER)
await this.saveOddsInTransaction(tx, matchId, oddsArray);
// 10. Save Officials
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 uniqueKey = `${o.name}_${roleName}`;
if (processedOfficials.has(uniqueKey)) continue;
processedOfficials.add(uniqueKey);
const role = await tx.officialRole.upsert({
where: { name: roleName },
update: {},
create: { name: roleName },
});
await tx.matchOfficial.create({
data: {
matchId,
name: o.name,
roleId: role.id,
},
});
}
}
},
{ maxWait: 40000, timeout: 40000 },
);
// WAIT FOR IMAGES AFTER TRANSACTION
await Promise.allSettled(imageDownloads);
this.logger.log(`✅ SAVED: [${matchId}] ${matchSummary.matchName}`);
return true;
} catch (error: any) {
this.logger.error(`❌ SAVE FAILED [${matchId}]: ${error.message}`);
return false;
}
}
// ============================================
// SELECTIVE UPDATE: LINEUPS ONLY
// ============================================
async saveLineups(
matchId: string,
playersMap: Map<string, TransformedPlayer>,
participationData: MatchParticipation[],
homeTeamId: string,
awayTeamId: string,
): Promise<boolean> {
try {
await this.prisma.$transaction(
async (tx) => {
const matchInMainDb = await tx.match.findUnique({
where: { id: matchId },
select: { id: true },
});
if (matchInMainDb) {
const playersArray = Array.from(playersMap.values());
if (playersArray.length > 0) {
const existingPlayers = await tx.player.findMany({
where: {
id: { in: playersArray.map((p) => p.id) },
},
select: { id: true },
});
const existingPlayerIds = new Set(
existingPlayers.map((p) => p.id),
);
const playersToCreate = playersArray.filter(
(p) => !existingPlayerIds.has(p.id),
);
if (playersToCreate.length > 0) {
await tx.player.createMany({
data: playersToCreate.map((p) => ({
id: p.id,
name: p.name,
slug: p.slug,
})),
skipDuplicates: true,
});
}
}
if (participationData.length > 0) {
await tx.matchPlayerParticipation.deleteMany({
where: { matchId: matchId },
});
await tx.matchPlayerParticipation.createMany({
data: participationData.map((p) => ({
matchId: p.matchId,
playerId: p.playerId,
teamId: p.teamId,
position: this.mapPositionToEnum(p.position),
shirtNumber: p.shirtNumber,
isStarting: p.isStarting,
})),
skipDuplicates: true,
});
}
}
},
{ maxWait: 15000, timeout: 15000 },
);
this.logger.log(`✅ LINEUPS REFRESHED & SYNCED: [${matchId}]`);
return true;
} catch (error: any) {
this.logger.error(`❌ LINEUP SAVE FAILED [${matchId}]: ${error.message}`);
return false;
}
}
// ============================================
// SELECTIVE UPDATE: ODDS ONLY (HISTORY-AWARE)
// ============================================
async saveOdds(
matchId: string,
oddsArray: DbMarketPayload[],
): Promise<boolean> {
try {
await this.prisma.$transaction(
async (tx) => {
// 1. MAIN DB LOGIC
const matchInMainDb = await tx.match.findUnique({
where: { id: matchId },
select: { id: true },
});
if (matchInMainDb && oddsArray.length > 0) {
await this.saveOddsInTransaction(tx, matchId, oddsArray);
}
// 2. LIVE MATCH DB LOGIC
const liveMatch = await tx.liveMatch.findUnique({
where: { id: matchId },
select: { id: true },
});
if (liveMatch && oddsArray.length > 0) {
const oddsJson: Record<string, Record<string, number>> = {};
for (const m of oddsArray) {
oddsJson[m.name] = {};
for (const s of m.selectionCollection) {
const val = parseFloat(s.odd);
if (!isNaN(val)) oddsJson[m.name][s.name] = val;
}
}
await tx.liveMatch.update({
where: { id: matchId },
data: {
odds: oddsJson as any,
oddsUpdatedAt: new Date(),
},
});
}
},
{ maxWait: 15000, timeout: 15000 },
);
this.logger.log(`✅ ODDS REFRESHED: [${matchId}]`);
return true;
} catch (error: any) {
this.logger.error(`❌ ODDS SAVE FAILED [${matchId}]: ${error.message}`);
return false;
}
}
// ============================================
// FULL DATA FETCH FOR AI
// ============================================
async getMatchFullDetails(matchId: string) {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
include: {
homeTeam: true,
awayTeam: true,
league: true,
oddCategories: {
include: { selections: true },
},
playerParticipations: {
select: { playerId: true, teamId: true, isStarting: true },
},
},
});
if (!match) return null;
const homeLineup = match.playerParticipations
.filter((p) => p.teamId === match.homeTeamId)
.map((p) => p.playerId);
const awayLineup = match.playerParticipations
.filter((p) => p.teamId === match.awayTeamId)
.map((p) => p.playerId);
const getForm = async (teamId: string) => {
const history = await this.prisma.match.findMany({
where: {
OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }],
status: 'FT',
mstUtc: { lt: match.mstUtc },
},
orderBy: { mstUtc: 'desc' },
take: 5,
});
if (history.length === 0) return { avg_gf: 1.2, avg_ga: 1.2 };
let totalGF = 0;
let totalGA = 0;
for (const m of history) {
if (m.homeTeamId === teamId) {
totalGF += m.scoreHome ?? 0;
totalGA += m.scoreAway ?? 0;
} else {
totalGF += m.scoreAway ?? 0;
totalGA += m.scoreHome ?? 0;
}
}
return {
avg_gf: totalGF / history.length,
avg_ga: totalGA / history.length,
};
};
const homeForm = await getForm(match.homeTeamId!);
const awayForm = await getForm(match.awayTeamId!);
const odds: any[] = [];
for (const cat of match.oddCategories) {
for (const sel of cat.selections) {
odds.push({
category: cat.name,
selection: sel.name,
odd_value: this.safeFloat(sel.oddValue),
});
}
}
return {
match_id: match.id,
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,
league_name: match.league?.name,
date: match.mstUtc.toString(),
score_home: match.scoreHome,
score_away: match.scoreAway,
status: match.status,
odds: odds,
home_form: homeForm,
away_form: awayForm,
home_lineup: homeLineup,
away_lineup: awayLineup,
};
}
// ============================================
// CHECKERS
// ============================================
async matchExists(matchId: string): Promise<boolean> {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
select: { id: true },
});
return !!match;
}
async getExistingMatchIds(matchIds: string[]): Promise<string[]> {
// Only consider matches "existing" if they have ALL key data points
// This allows re-fetching matches that exist but have missing data
const matches = await this.prisma.match.findMany({
where: {
id: { in: matchIds },
AND: [
{ oddCategories: { some: {} } },
{ playerEvents: { some: {} } },
{ officials: { some: {} } },
{
OR: [
{ footballTeamStats: { some: {} } },
{ basketballTeamStats: { some: {} } },
],
},
],
},
select: { id: true },
});
return matches.map((m) => m.id);
}
async hasOdds(matchId: string): Promise<boolean> {
const category = await this.prisma.oddCategory.findFirst({
where: { matchId },
});
if (category) return true;
const live = await this.prisma.liveMatch.findUnique({
where: { id: matchId },
select: { odds: true },
});
return !!(live?.odds && Object.keys(live.odds as any).length > 0);
}
async getMatch(matchId: string): Promise<any | null> {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
include: {
homeTeam: true,
awayTeam: true,
},
});
if (match) return match;
const liveMatch = await this.prisma.liveMatch.findUnique({
where: { id: matchId },
include: {
homeTeam: true,
awayTeam: true,
league: true,
},
});
if (liveMatch) {
return {
...liveMatch,
leagueId: liveMatch.leagueId,
homeTeamId: liveMatch.homeTeamId,
awayTeamId: liveMatch.awayTeamId,
scoreHome: liveMatch.scoreHome,
scoreAway: liveMatch.scoreAway,
mstUtc: liveMatch.mstUtc,
sport: liveMatch.sport || 'football',
};
}
return null;
}
async getPlayerCount(matchId: string): Promise<number> {
const relationalCount = await this.prisma.matchPlayerParticipation.count({
where: { matchId },
});
if (relationalCount > 0) return relationalCount;
const liveMatch = await this.prisma.liveMatch.findUnique({
where: { id: matchId },
select: { lineups: true },
});
if (liveMatch?.lineups) {
try {
const lineups = liveMatch.lineups as any;
const homeXi = lineups.home?.xi?.length || 0;
const awayXi = lineups.away?.xi?.length || 0;
return homeXi + awayXi;
} catch (e) {
return 0;
}
}
return 0;
}
// ============================================
// STATE MANAGEMENT
// ============================================
async getState(key: string): Promise<string | null> {
const setting = await this.prisma.appSetting.findUnique({
where: { key },
});
return setting?.value || null;
}
async setState(key: string, value: string): Promise<void> {
await this.prisma.appSetting.upsert({
where: { key },
update: { value, updatedAt: new Date() },
create: { key, value },
});
}
}
+746
View File
@@ -0,0 +1,746 @@
/**
* Feeder Scraper Service - Senior Level Implementation
* 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 {
Sport,
SPORTS_CONFIG,
DEFAULT_HEADERS,
DEFAULT_TIMEOUT,
KeyEventsResponse,
MatchStatsResponse,
GameStatsResponse,
ManagerResponse,
IddaaMarketsHtmlResponse,
BasketballBoxScoreResponse,
ParsedMatchHeader,
ParsedMarket,
ParsedSelection,
BasketballPlayerStats,
LivescoresApiResponse,
SidelinedResponse,
SidelinedTeamData,
SidelinedPlayer,
} from './feeder.types';
@Injectable()
export class FeederScraperService {
private readonly logger = new Logger(FeederScraperService.name);
private readonly axios: AxiosInstance;
constructor() {
// Create axios instance with default config
this.axios = axios.create({
headers: DEFAULT_HEADERS,
timeout: DEFAULT_TIMEOUT,
});
// Add response interceptor for logging
this.axios.interceptors.response.use(
(response) => {
this.logger.debug(
`✅ [${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';
this.logger.error(`❌ [${url}] Status: ${status} - ${error.message}`);
throw error;
},
);
}
// ============================================
// Historical source endpoint (match list)
// ============================================
async fetchLivescores(
dateString: string,
sport: Sport,
): Promise<LivescoresApiResponse> {
const { sportParam } = SPORTS_CONFIG[sport];
const url = `https://www.mackolik.com/perform/p0/ajax/components/competition/livescores/json`;
this.logger.log(
`📡 [${sport}] Fetching historical source snapshot for ${dateString}`,
);
const response = await this.axios.get(url, {
params: {
'sports[]': sportParam,
matchDate: dateString,
},
});
const payload = response.data as unknown;
if (
!payload ||
typeof payload !== 'object' ||
!('status' in payload) ||
!('data' in payload)
) {
throw new Error('Historical source payload has invalid shape');
}
return payload as LivescoresApiResponse;
}
// ============================================
// MATCH HEADER (Score, Status, HT Score)
// ============================================
async fetchMatchHeader(matchId: string): Promise<ParsedMatchHeader> {
const url = `https://www.mackolik.com/perform/p0/ajax/components/match/matchHeader`;
this.logger.debug(`📡 [${matchId}] Fetching match header`);
const response = await this.axios.get(url, {
params: {
matchId,
sdapiLanguageCode: 'tr-mk',
ajaxViewName: 'match-details',
ajaxPartialViewName: 'match-details-status',
displayMode: 'all',
},
});
return this.parseMatchHeader(response.data.data?.html || '');
}
private parseMatchHeader(html: string): ParsedMatchHeader {
const $ = cheerio.load(html);
// Extract match-status from data attribute
const matchStatus =
($('[data-match-status]').attr('data-match-status') as any) || 'postGame';
// Extract scores
const scoreHome = this.safeInt($('[data-slot="score-home"]').text().trim());
const scoreAway = this.safeInt($('[data-slot="score-away"]').text().trim());
// Extract HT score from detailed score (İY X - X)
let htScoreHome: number | null = null;
let htScoreAway: number | null = null;
const detailedScore = $('.p0c-soccer-match-details-header__detailed-score')
.text()
.trim();
const htMatch = detailedScore.match(/\(İY\s*(\d+)\s*-\s*(\d+)\)/);
if (htMatch) {
htScoreHome = parseInt(htMatch[1], 10);
htScoreAway = parseInt(htMatch[2], 10);
}
return { matchStatus, scoreHome, scoreAway, htScoreHome, htScoreAway };
}
// ============================================
// KEY EVENTS (Goals, Cards, Substitutes)
// ============================================
async fetchKeyEvents(
matchId: string,
): Promise<KeyEventsResponse['data'] | null> {
const url = `https://www.mackolik.com/ajax/football/key-events`;
this.logger.debug(`📡 [${matchId}] Fetching key events`);
try {
const response = await this.axios.get<KeyEventsResponse>(url, {
params: {
ajaxViewName: 'events',
matchId,
seasonId: matchId, // Same as matchId
},
});
return response.data.data;
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Key events not found (404)`);
return null;
}
throw error;
}
}
// ============================================
// MATCH STATS - STARTING FORMATION (İlk 11)
// ============================================
async fetchStartingFormation(
matchId: string,
): Promise<MatchStatsResponse['data'] | null> {
const url = `https://www.mackolik.com/ajax/football/match-stats`;
this.logger.debug(`📡 [${matchId}] Fetching starting formation`);
try {
const response = await this.axios.get<MatchStatsResponse>(url, {
params: {
ajaxViewName: 'starting-formation',
matchId,
seasonId: matchId,
},
});
return response.data.data;
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Starting formation not found (404)`);
return null;
}
throw error;
}
}
// ============================================
// MATCH STATS - SUBSTITUTIONS (Yedekler)
// ============================================
async fetchSubstitutions(
matchId: string,
): Promise<MatchStatsResponse['data'] | null> {
const url = `https://www.mackolik.com/ajax/football/match-stats`;
this.logger.debug(`📡 [${matchId}] Fetching substitutions`);
try {
const response = await this.axios.get<MatchStatsResponse>(url, {
params: {
ajaxViewName: 'substitutions',
matchId,
seasonId: matchId,
},
});
return response.data.data;
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Substitutions not found (404)`);
return null;
}
throw error;
}
}
// ============================================
// GAME STATS (Possession, Shots, Passes)
// ============================================
async fetchGameStats(
matchId: string,
): Promise<GameStatsResponse['data'] | null> {
const url = `https://www.mackolik.com/ajax/soccer/match/gameStats`;
this.logger.debug(`📡 [${matchId}] Fetching game stats`);
try {
const response = await this.axios.get<GameStatsResponse>(url, {
params: { matchId },
});
return response.data.data;
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Game stats not found (404)`);
return null;
}
throw error;
}
}
// ============================================
// MANAGER
// ============================================
async fetchManager(matchId: string): Promise<ManagerResponse['data'] | null> {
const url = `https://www.mackolik.com/ajax/football/match-stats`;
this.logger.debug(`📡 [${matchId}] Fetching manager`);
try {
const response = await this.axios.get<ManagerResponse>(url, {
params: {
ajaxViewName: 'manager',
matchId,
seasonId: matchId,
},
});
return response.data.data;
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Manager not found (404)`);
return null;
}
throw error;
}
}
// ============================================
// IDDAA MARKETS (HTML with odds + names)
// ============================================
async fetchIddaaMarkets(matchId: string): Promise<ParsedMarket[]> {
const url = `https://www.mackolik.com/ajax/iddaa/markets/soccer/all/${matchId}`;
this.logger.debug(`📡 [${matchId}] Fetching iddaa markets`);
try {
const response = await this.axios.get<IddaaMarketsHtmlResponse>(url, {
params: { template: 'all' },
});
return this.parseIddaaMarketsHtml(response.data.data?.html || '');
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Iddaa markets not found (404)`);
return [];
}
throw error;
}
}
private parseIddaaMarketsHtml(html: string): ParsedMarket[] {
if (!html) return [];
const $ = cheerio.load(html);
const markets: ParsedMarket[] = [];
$('.widget-iddaa-markets__market-item').each((_, marketEl) => {
const $market = $(marketEl);
const marketId = $market.attr('data-market') || '';
const marketName = $market
.find('.widget-iddaa-markets__header-text')
.text()
.trim();
const iddaaCode = $market
.find('.widget-iddaa-markets__iddaa-code')
.text()
.trim();
const mbc = $market.find('.widget-iddaa-markets__mbc').text().trim();
const selections: ParsedSelection[] = [];
$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(),
});
});
if (marketId && marketName) {
markets.push({ marketId, marketName, iddaaCode, mbc, selections });
}
});
this.logger.debug(`Parsed ${markets.length} iddaa markets`);
return markets;
}
// ============================================
// BASKETBALL BOX SCORE
// ============================================
async fetchBasketballBoxScore(
matchId: string,
): Promise<BasketballBoxScoreResponse['data'] | null> {
// Updated URL based on user request
const url = `https://www.mackolik.com/ajax/basketball/match/box-score`;
this.logger.debug(`📡 [${matchId}] Fetching basketball box score`);
try {
const response = await this.axios.get<BasketballBoxScoreResponse>(url, {
params: { matchId },
headers: {
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': DEFAULT_HEADERS['User-Agent'],
},
});
return response.data.data;
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Basketball box score not found (404)`);
return null;
}
throw error;
}
}
parseBasketballBoxScore(html: string): {
teamTotals: any;
players: Partial<BasketballPlayerStats>[];
} {
if (!html) return { teamTotals: {}, players: [] };
const $ = cheerio.load(html);
const players: Partial<BasketballPlayerStats>[] = [];
// Parse individual players from widget rows
$('.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');
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');
// 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');
if (link) {
playerId = this.extractPlayerIdFromUrl(link) || '';
}
players.push({
id: playerId, // Will be generated if empty later
name,
minutes: values.eq(1).text().trim(),
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,
fgAttempted:
this.safeInt(values.eq(5).text().trim().split('/')[1]) || 0,
threePtMade:
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,
ftAttempted:
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,
turnovers: this.safeInt(values.eq(11).text().trim()) || 0,
});
});
// Parse Team Totals from Footer
const footerRow = $('.widget-basketball-match-box-score__footer td');
let teamTotals: any = {};
if (footerRow.length > 5) {
// Indices shift because first cells might be empty matchers
// usually index 2 matches Points column
teamTotals = {
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,
fgAttempted:
this.safeInt(footerRow.eq(5).text().trim().split('/')[1]) || 0,
threePtMade:
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,
ftAttempted:
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,
turnovers: this.safeInt(footerRow.eq(11).text().trim()) || 0,
};
}
return { teamTotals, players };
}
// ============================================
// MATCH PAGE (Main page for officials parsing)
// ============================================
async fetchMatchPage(
matchId: string,
matchSlug: string,
sport: Sport,
): Promise<string> {
const { iddaaUrlPath } = SPORTS_CONFIG[sport];
const url = `https://www.mackolik.com/${iddaaUrlPath}/${matchSlug}/${matchId}`;
this.logger.debug(`📡 [${matchId}] Fetching match page`);
// 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'],
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
// NO X-Requested-With for HTML pages!
},
});
return response.data;
}
// ============================================
// HELPER FUNCTIONS
// ============================================
private safeInt(value: string | undefined): number | null {
if (!value) return null;
const num = parseInt(value, 10);
return isNaN(num) ? null : num;
}
// ============================================
// BASKETBALL DETAILS HEADER (Quarter Scores)
// ============================================
async fetchBasketballDetailsHeader(matchId: string): Promise<any> {
const url = `https://www.mackolik.com/ajax/basketball/match/details-header`;
this.logger.debug(`📡 [${matchId}] Fetching basketball details header`);
try {
const response = await this.axios.get(url, {
params: { matchId },
headers: {
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': DEFAULT_HEADERS['User-Agent'],
},
});
if (response.data?.data?.views?.scoreDetails?.html) {
return this.parseBasketballDetailsHeader(
response.data.data.views.scoreDetails.html,
);
}
return null;
} catch (error: any) {
// 404 is acceptable
if (error.response?.status === 404) return null;
throw error;
}
}
private parseBasketballDetailsHeader(
html: string,
): { home: any; away: any } | null {
if (!html) return null;
const $ = cheerio.load(html);
const rows = $(
'.widget-basketball-match-details-header__score-details tbody tr',
);
if (rows.length < 2) return null;
const parseRow = (row: any) => {
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.
// User HTML shows: <span class="...score-part"> 33 </span>
const getScore = (index: number) => {
const cell = cols.eq(index);
const part = cell.find(
'.widget-basketball-match-details-header__score-part',
);
const val = part.length ? part.text() : cell.text();
return this.safeInt(val.trim());
};
return {
q1: getScore(1),
q2: getScore(2),
q3: getScore(3),
q4: getScore(4),
// If there's OT, it would be column 5, and Final column 6?
// Standard 4 quarters: Col 1,2,3,4. Col 5 is Final.
// If 5 cols (+name), logic holds.
// Let's assume standard for now.
};
};
return {
home: parseRow(rows[0]),
away: parseRow(rows[1]),
};
}
// ============================================
// BASKETBALL MARKETS (Odds)
// ============================================
async fetchBasketballMarkets(matchId: string): Promise<ParsedMarket[]> {
// User provided URL structure: /ajax/iddaa/markets/basketball/all/{matchId}?template=all
const url = `https://www.mackolik.com/ajax/iddaa/markets/basketball/all/${matchId}`;
this.logger.debug(`📡 [${matchId}] Fetching basketball markets`);
try {
const response = await this.axios.get<IddaaMarketsHtmlResponse>(url, {
params: { template: 'all' },
headers: {
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': DEFAULT_HEADERS['User-Agent'],
},
});
if (response.data?.data?.html) {
return this.parseIddaaMarketsHtml(response.data.data.html);
}
return [];
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Basketball markets not found (404)`);
return [];
}
throw error;
}
}
extractPlayerIdFromUrl(url: string | undefined): string | null {
if (!url) return null;
const parts = url.split('/');
return parts[parts.length - 1] || null;
}
// ============================================
// SIDELINED PLAYERS (Injuries & Suspensions)
// ============================================
async fetchSidelinedPlayers(
matchId: string,
matchSlug: string,
): Promise<SidelinedResponse | null> {
const url = `https://www.mackolik.com/mac/${matchSlug}/${matchId}`;
this.logger.debug(`📡 [${matchId}] Fetching sidelined players`);
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',
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',
},
timeout: 10000,
});
const $ = cheerio.load(response.data);
return {
homeTeam: this._parseSidelinedSection($, 0),
awayTeam: this._parseSidelinedSection($, 1),
};
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Match page not found (404)`);
return null;
}
this.logger.warn(
`[${matchId}] Sidelined fetch warning: ${error.message}`,
);
return null;
}
}
private _parseSidelinedSection(
$: cheerio.CheerioAPI,
teamIndex: number,
): SidelinedTeamData {
const sidelinedWidgets = $('.widget-sidelined-players');
if (sidelinedWidgets.length <= teamIndex) {
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 teamName = widget
.find('.widget-sidelined-players__header-text')
.text()
.trim();
const players: SidelinedPlayer[] = [];
widget.find('.widget-sidelined-players__item').each((_, element) => {
const playerData = this._parsePlayerItem($, $(element));
if (playerData) {
players.push(playerData);
}
});
return {
teamName,
teamId,
totalSidelined: players.length,
players,
};
}
private _parsePlayerItem(
$: cheerio.CheerioAPI,
$item: cheerio.Cheerio<any>,
): SidelinedPlayer | null {
try {
const nameElem = $item.find('.widget-sidelined-players__name');
const playerName = nameElem.text().trim();
const playerUrl = nameElem.attr('href') || '';
const playerId = playerUrl.split('/').pop() || '';
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 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() : '';
const matchesMissed = matchesMissedText
? parseInt(matchesMissedText, 10)
: null;
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')
.text()
.trim();
const type = reasonIcon.includes('shortage_1.png')
? 'injury'
: reasonIcon.includes('suspension')
? 'suspension'
: 'other';
return {
playerId,
playerName,
playerUrl: playerUrl.startsWith('http')
? playerUrl
: `https://www.mackolik.com${playerUrl}`,
position,
positionShort,
type,
description,
matchesMissed: isNaN(matchesMissed as number) ? null : matchesMissed,
average: isNaN(average as number) ? null : average,
reasonIcon: reasonIcon.startsWith('http')
? reasonIcon
: `https://www.mackolik.com${reasonIcon}`, // Keep safer URL construction but stick closer to logic
};
} catch {
return null;
}
}
}
+359
View File
@@ -0,0 +1,359 @@
/**
* Feeder Transformer Service - Senior Level Implementation
* Transforms raw API data into database-ready formats
*/
import { Injectable, Logger } from '@nestjs/common';
import * as cheerio from 'cheerio';
import {
RawKeyEvent,
TransformedEvent,
RawPlayerStats,
TransformedPlayer,
MatchParticipation,
TransformedMatchStats,
ParsedMarket,
MatchOfficial,
MatchState,
GameStatsResponse,
DbEventPayload,
DbMarketPayload,
} from './feeder.types';
@Injectable()
export class FeederTransformerService {
private readonly logger = new Logger(FeederTransformerService.name);
// ============================================
// HELPER FUNCTIONS
// ============================================
private safeString(value: any): string | null {
return value === null || value === undefined || value === ''
? null
: String(value);
}
private safeInt(value: any): number | null {
const num = parseInt(String(value), 10);
return isNaN(num) ? null : num;
}
private safeFloat(value: any): number | null {
const num = parseFloat(String(value));
return isNaN(num) ? null : num;
}
private extractPlayerIdFromUrl(url: string | undefined): string | null {
if (!url) return null;
const parts = url.split('/');
return parts[parts.length - 1] || null;
}
// ============================================
// KEY EVENTS TRANSFORMER
// ============================================
transformKeyEvents(
rawEvents: RawKeyEvent[],
homeTeamId: string,
awayTeamId: string,
matchId: string,
): TransformedEvent[] {
return rawEvents.map((e) => {
const playerId = this.extractPlayerIdFromUrl(e.playerUrl) || '';
const assistPlayerId = e.assistPlayerUrl
? this.extractPlayerIdFromUrl(e.assistPlayerUrl)
: null;
const playerOutId = e.playerOutUrl
? this.extractPlayerIdFromUrl(e.playerOutUrl)
: 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';
return {
matchId,
playerId,
playerName: e.playerName,
teamId: e.position === 'home' ? homeTeamId : awayTeamId,
eventType,
eventSubtype: e.subType || null,
timeMinute: e.timeMin,
timeSeconds: e.seconds,
periodId: e.periodId,
assistPlayerId,
assistPlayerName: e.assistPlayerName || null,
scoreAfter: e.score || null,
playerOutId,
playerOutName: e.playerOutName || null,
position: e.position,
};
});
}
// ============================================
// LINEUP PROCESSOR
// ============================================
processLineup(
players: RawPlayerStats[],
teamId: string,
isStarting: boolean,
matchId: string,
playersMap: Map<string, TransformedPlayer>,
participationData: MatchParticipation[],
): void {
if (!players || !Array.isArray(players)) return;
players.forEach((p) => {
const playerId = this.safeString(p.personId);
const playerName = this.safeString(p.matchName);
if (playerId && playerName) {
// Add to players map (for players table insert)
playersMap.set(playerId, {
id: playerId,
name: playerName,
slug: playerId,
teamId,
});
// Add participation record
participationData.push({
matchId,
playerId,
teamId,
position: this.safeString(p.position),
shirtNumber: this.safeInt(p.shirtNumber),
isStarting,
});
}
});
}
// ============================================
// GAME STATS TRANSFORMER
// ============================================
transformGameStats(
data: GameStatsResponse['data'] | null,
): TransformedMatchStats | null {
if (!data || !data.home) return null;
// Away possession can be calculated if not provided
const awayPossession: number | undefined =
data.away.possesionPercentage ??
(data.home.possesionPercentage
? 100 - data.home.possesionPercentage
: undefined);
return {
home: {
possesionPercentage: data.home.possesionPercentage,
shotsOnTarget: data.home.shotsOnTarget,
shotsOffTarget: data.home.shotsOffTarget,
totalPasses: data.home.totalPasses,
corners: data.home.corners,
fouls: data.home.fouls,
offsides: data.home.offsides,
},
away: {
possesionPercentage: awayPossession,
shotsOnTarget: data.away.shotsOnTarget,
shotsOffTarget: data.away.shotsOffTarget,
totalPasses: data.away.totalPasses,
corners: data.away.corners,
fouls: data.away.fouls,
offsides: data.away.offsides,
},
};
}
// ============================================
// MATCH STATE TO STATUS MAPPER
// ============================================
mapMatchStateToStatus(state: MatchState | undefined): string {
if (!state) return 'NS';
switch (state) {
case 'postGame':
case 'post':
return 'FT';
case 'preGame':
case 'pre':
return 'NS';
case 'live':
case 'liveGame':
return 'LIVE';
default:
return 'NS';
}
}
// ============================================
// OFFICIALS PARSER (from match page HTML)
// ============================================
parseOfficials(html: string): MatchOfficial[] {
if (!html) return [];
const $ = cheerio.load(html);
const officials: MatchOfficial[] = [];
// Try standard officials component
$('.p0c-match-officials__official-list-item').each((_, elem) => {
const name = $(elem)
.find('.p0c-match-officials__official-name')
.text()
.trim();
const role = $(elem)
.find('.p0c-match-officials__official-group-title')
.text()
.trim();
if (name) {
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) => {
const name = $(elem).text().trim();
if (name) {
officials.push({ name, role: 'Referee' });
}
});
}
return officials;
}
// ============================================
// IDDAA MARKETS TRANSFORMER
// For converting ParsedMarket[] to database format
// ============================================
transformIddaaMarkets(markets: ParsedMarket[]): DbMarketPayload[] {
return markets.map((market) => ({
id: market.marketId,
name: market.marketName,
iddaaCode: market.iddaaCode,
mbc: market.mbc,
selectionCollection: market.selections.map((s) => ({
shortcode: s.shortcode,
name: s.label,
odd: s.value,
position: s.outcomeNo,
})),
}));
}
/**
* Helper to convert ParsedMarket[] to LiveMatch.odds structure
* Useful for quick JSON storage
*/
transformToOddsJson(
markets: DbMarketPayload[],
): Record<string, Record<string, number>> {
const odds: Record<string, Record<string, number>> = {};
for (const market of markets) {
if (!market.name || !market.selectionCollection) continue;
const marketName = market.name;
odds[marketName] = {};
for (const sel of market.selectionCollection) {
const val = parseFloat(sel.odd);
if (sel.name && !isNaN(val)) {
odds[marketName][sel.name] = val;
}
}
}
return odds;
}
// ============================================
// EXTRACT PLAYERS FROM EVENTS
// (for adding to players map)
// ============================================
extractPlayersFromEvents(
events: TransformedEvent[],
playersMap: Map<string, TransformedPlayer>,
): void {
events.forEach((event) => {
// Main player
if (
event.playerId &&
event.playerName &&
!playersMap.has(event.playerId)
) {
playersMap.set(event.playerId, {
id: event.playerId,
name: event.playerName,
slug: event.playerId,
});
}
// Assist player
if (
event.assistPlayerId &&
event.assistPlayerName &&
!playersMap.has(event.assistPlayerId)
) {
playersMap.set(event.assistPlayerId, {
id: event.assistPlayerId,
name: event.assistPlayerName,
slug: event.assistPlayerId,
});
}
// Player out (substitution)
if (
event.playerOutId &&
event.playerOutName &&
!playersMap.has(event.playerOutId)
) {
playersMap.set(event.playerOutId, {
id: event.playerOutId,
name: event.playerOutName,
slug: event.playerOutId,
});
}
});
}
// ============================================
// PREPARE EVENT DATA FOR DATABASE
// ============================================
prepareEventDataForDb(events: TransformedEvent[]): DbEventPayload[] {
return events
.filter(
(
e,
): e is TransformedEvent & {
eventType: 'goal' | 'card' | 'substitute';
} => e.eventType !== 'other' && !!e.playerId,
)
.map((e) => ({
match_id: e.matchId,
player_id: e.playerId,
team_id: e.teamId,
event_type: e.eventType,
event_subtype: e.eventSubtype,
time_minute: e.timeMinute,
time_seconds: e.timeSeconds,
period_id: e.periodId,
assist_player_id: e.assistPlayerId,
score_after: e.scoreAfter,
player_out_id: e.playerOutId,
position: e.position,
}));
}
// ============================================
// BASKETBALL PLAYER ID GENERATOR
// ============================================
generateBasketballPlayerId(teamId: string, playerName: string): string {
return `${teamId}-${playerName.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}`;
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* 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';
@Module({
imports: [DatabaseModule],
providers: [
FeederService,
FeederScraperService,
FeederTransformerService,
FeederPersistenceService,
],
exports: [FeederService, FeederScraperService, FeederPersistenceService],
})
export class FeederModule {}
+994
View File
@@ -0,0 +1,994 @@
/**
* Feeder Service - Senior Level Implementation
* 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 {
Sport,
MatchSummary,
Competition,
LivescoresApiResponse,
TransformedPlayer,
MatchParticipation,
ProcessResult,
BasketballPlayerStats,
BasketballTeamStats,
TransformedMatchStats,
MatchOfficial,
ParsedMatchHeader,
ParsedMarket,
DbEventPayload,
DbMarketPayload,
} from './feeder.types';
interface ProcessDateOptions {
onlyCompletedMatches?: boolean;
refreshExistingMatches?: boolean;
}
@Injectable()
export class FeederService {
private readonly logger = new Logger(FeederService.name);
// 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 MAX_RETRIES = 50;
private readonly DAILY_SYNC_TIME_ZONE = 'Europe/Istanbul';
constructor(
private readonly scraperService: FeederScraperService,
private readonly transformerService: FeederTransformerService,
private readonly persistenceService: FeederPersistenceService,
) {}
// ============================================
// DELAY HELPER
// ============================================
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private getYesterdayDateString(timeZone: string): string {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
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 tzMidnightUtc = new Date(Date.UTC(year, month - 1, day));
tzMidnightUtc.setUTCDate(tzMidnightUtc.getUTCDate() - 1);
return tzMidnightUtc.toISOString().split('T')[0];
}
private getTimeZoneOffsetMs(date: Date, timeZone: string): number {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone,
timeZoneName: 'shortOffset',
});
const offsetLabel =
formatter.formatToParts(date).find((part) => part.type === 'timeZoneName')
?.value || 'GMT+0';
const match = offsetLabel.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/);
if (!match) return 0;
const sign = match[1] === '-' ? -1 : 1;
const hours = Number(match[2] || '0');
const minutes = Number(match[3] || '0');
return sign * (hours * 60 + minutes) * 60 * 1000;
}
private getDayBoundsForTimeZone(
dateString: string,
timeZone: string,
): { startTs: number; endTs: number } {
const [year, month, day] = dateString.split('-').map(Number);
const startGuess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
const nextDayGuess = new Date(
Date.UTC(year, month - 1, day + 1, 0, 0, 0),
);
const startOffsetMs = this.getTimeZoneOffsetMs(startGuess, timeZone);
const nextDayOffsetMs = this.getTimeZoneOffsetMs(nextDayGuess, timeZone);
const startMs =
Date.UTC(year, month - 1, day, 0, 0, 0) - startOffsetMs;
const nextDayStartMs =
Date.UTC(year, month - 1, day + 1, 0, 0, 0) - nextDayOffsetMs;
return {
startTs: Math.floor(startMs / 1000),
endTs: Math.floor((nextDayStartMs - 1) / 1000),
};
}
private parseScoreValue(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
private isCompletedMatchSummary(match: MatchSummary): boolean {
if (match.statusBoxContent === 'ERT') return false;
const normalizedState = String(match.state || '')
.trim()
.toLowerCase();
const normalizedStatus = String(match.status || '')
.trim()
.toLowerCase();
const normalizedSubstate = String(match.substate || '')
.trim()
.toLowerCase();
if (['postgame', 'post'].includes(normalizedState)) return true;
if (
['played', 'finished', 'ft', 'afterpenalties', 'penalties'].includes(
normalizedStatus,
)
) {
return true;
}
if (['postgame', 'post', 'played', 'finished', 'ft'].includes(normalizedSubstate)) {
return true;
}
const homeScore = this.parseScoreValue(
match.score?.home ?? match.homeScore,
);
const awayScore = this.parseScoreValue(
match.score?.away ?? match.awayScore,
);
return homeScore !== null && awayScore !== null;
}
async runPreviousDayCompletedMatchesScan(
sports: Sport[] = this.SPORTS,
targetDateStr: string = this.getYesterdayDateString(
this.DAILY_SYNC_TIME_ZONE,
),
targetLeagueIds: string[] = [],
): Promise<void> {
this.logger.log(
`🗓️ STARTING DAILY COMPLETED MATCH SYNC [Date: ${targetDateStr}] [Sports: ${sports.join(', ')}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ''}`,
);
for (const sport of sports) {
await this.processDate(targetDateStr, sport, targetLeagueIds, {
onlyCompletedMatches: true,
refreshExistingMatches: true,
});
}
this.logger.log(
`✅ DAILY COMPLETED MATCH SYNC FINISHED [Date: ${targetDateStr}]`,
);
}
// ============================================
// MAIN HISTORICAL SCAN
// ============================================
async runHistoricalScan(
sports: Sport[] = this.SPORTS,
startDateStr: string = this.HISTORICAL_START_DATE,
targetLeagueIds: string[] = [], // NEW: Optional league filter
): Promise<void> {
this.logger.log(
`🚀 STARTING HISTORICAL SCAN [Target: ${sports.join(', ')}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ''}`,
);
const startDate = new Date(startDateStr);
const endDate = new Date();
// Start from 2 days ago to avoid overlap with live_matches table.
// Cron jobs (data-fetcher.task.ts) handle today and yesterday,
// 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`;
let currentDate: Date | null = null;
// Resume from saved state
try {
const savedState = await this.persistenceService.getState(stateKey);
if (savedState) {
const resumeDate = new Date(savedState);
// Ensure resumeDate is valid for reverse scan (<= endDate and >= startDate)
if (resumeDate <= endDate && resumeDate >= startDate) {
currentDate = new Date(resumeDate);
// 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]}`,
);
}
}
} catch {
this.logger.warn('Could not read state, starting from beginning');
}
// Initialize currentDate to endDate if not resuming (or if resume failed)
// Note: If resuming, currentDate is already set above.
// If not resuming, we start from endDate (Today) and go backwards.
if (!currentDate) {
currentDate = new Date(endDate);
}
this.logger.log(
`📊 Scanning (Reverse): ${currentDate.toISOString().split('T')[0]}${startDate.toISOString().split('T')[0]}`,
);
let processedDays = 0;
const scanStartTime = Date.now();
// REVERSE LOOP: Iterate while currentDate is greater than or equal to startDate
while (currentDate >= startDate) {
const dateString = currentDate.toISOString().split('T')[0];
for (const sport of sports) {
await this.processDate(dateString, sport, targetLeagueIds);
}
// Save state
await this.persistenceService.setState(stateKey, dateString);
// --- ETA CALCULATION ---
processedDays++;
const now = Date.now();
const totalElapsed = now - scanStartTime;
const avgTimePerDay = totalElapsed / processedDays;
// Calculate remaining days based on current position for REVERSE scan
// Days left = (currentDate - startDate)
const daysLeft = Math.ceil(
(currentDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24),
);
const estimatedRemainingMs = avgTimePerDay * daysLeft;
// Format time helper
const formatDuration = (ms: number) => {
const seconds = Math.floor((ms / 1000) % 60);
const minutes = Math.floor((ms / (1000 * 60)) % 60);
const hours = Math.floor(ms / (1000 * 60 * 60));
return `${hours}h ${minutes}m ${seconds}s`;
};
this.logger.log(
`⏱️ PROGRESS: [${processedDays} days done] | Avg/Day: ${(avgTimePerDay / 1000).toFixed(1)}s | Remaining: ${daysLeft} days | 🏁 ETA: ${formatDuration(estimatedRemainingMs)}`,
);
// Decrement date for reverse scan
currentDate.setDate(currentDate.getDate() - 1);
}
this.logger.log('🎉 HISTORICAL SCAN COMPLETED');
}
// ============================================
// PROCESS SINGLE DATE
// ============================================
private async processDate(
dateString: string,
sport: Sport,
targetLeagueIds: string[] = [],
options: ProcessDateOptions = {},
): Promise<void> {
const { onlyCompletedMatches = false, refreshExistingMatches = false } =
options;
this.logger.log(`[${sport}] 📅 Processing: ${dateString}`);
try {
// Fetch historical source snapshot for the date with retry.
// The upstream endpoint is named "livescores", but this path is used
// strictly as a historical source and filtered by mstUtc below.
let response: LivescoresApiResponse | null = null;
for (let i = 0; i < 3; i++) {
try {
response = await this.scraperService.fetchLivescores(
dateString,
sport,
);
break; // Success, exit loop
} catch (e: any) {
const is502 =
e.message?.includes('502') ||
e.response?.status === 502 ||
e.message?.includes('Bad Gateway');
if (is502 && i < 2) {
this.logger.warn(
`[${sport}] [${dateString}] Historical source fetch returned 502. Retrying in 5s...`,
);
await this.delay(5000);
continue;
}
throw e; // Rethrow if not 502 or retries exhausted
}
}
const data = response?.data;
if (!data?.matches || !data?.competitions) {
this.logger.warn(`[${sport}] [${dateString}] No data from API`);
return;
}
// Filter matches with iddaa code and deduplicate
const rawMatches = Object.values(
data.matches,
) as unknown as MatchSummary[];
const allMatches = rawMatches.filter((m) => m.iddaaCode);
// CRITICAL FIX: Filter matches by actual match date (mstUtc).
// Mackolik's historical source endpoint can still return current live/upcoming matches
// 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,
);
const dateFilteredMatches = allMatches.filter((m) => {
const matchTs = m.mstUtc;
return matchTs >= targetDateStartTs && matchTs <= targetDateEndTs;
});
const apiReturnedCount = allMatches.length;
const afterDateFilterCount = dateFilteredMatches.length;
if (apiReturnedCount > 0 && afterDateFilterCount === 0) {
this.logger.log(
`[${sport}] [${dateString}] Historical source returned ${apiReturnedCount} matches, but none belong to the target date after mstUtc filtering. Skipping.`,
);
return;
}
if (afterDateFilterCount < apiReturnedCount) {
this.logger.log(
`[${sport}] [${dateString}] Filtered out ${apiReturnedCount - afterDateFilterCount} off-date rows from historical source payload before processing.`,
);
}
let matchesToProcess = Array.from(
new Map(dateFilteredMatches.map((m) => [m.id, m])).values(),
);
if (targetLeagueIds.length > 0) {
matchesToProcess = matchesToProcess.filter((m) =>
targetLeagueIds.includes(m.competitionId),
);
}
if (onlyCompletedMatches) {
const beforeCompletedFilter = matchesToProcess.length;
matchesToProcess = matchesToProcess.filter((m) =>
this.isCompletedMatchSummary(m),
);
if (
beforeCompletedFilter > 0 &&
matchesToProcess.length < beforeCompletedFilter
) {
this.logger.log(
`[${sport}] [${dateString}] Filtered out ${beforeCompletedFilter - matchesToProcess.length} non-completed matches from daily sync payload.`,
);
}
}
// 1. Check if any matches came from source
if (matchesToProcess.length === 0) {
this.logger.log(
`[${sport}] [${dateString}] No iddaa matches found in source`,
);
return;
}
// 2. Filter out already existing matches to skip processing
const allIds = matchesToProcess.map((m) => m.id);
const existingIds =
await this.persistenceService.getExistingMatchIds(allIds);
const totalCount = matchesToProcess.length;
if (!refreshExistingMatches && existingIds.length > 0) {
matchesToProcess = matchesToProcess.filter(
(m) => !existingIds.includes(m.id),
);
}
if (matchesToProcess.length === 0) {
this.logger.log(
`[${sport}] [${dateString}] All ${totalCount} matches already exist. Skipping...`,
);
return;
}
if (refreshExistingMatches) {
this.logger.log(
`[${sport}] [${dateString}] Refreshing ${matchesToProcess.length} completed matches (${existingIds.length} already existed in matches)`,
);
} else {
this.logger.log(
`[${sport}] [${dateString}] Processing ${matchesToProcess.length}/${totalCount} matches (Skipped ${existingIds.length} existing)`,
);
}
let successCount = 0;
const failedMatches: MatchSummary[] = [];
// 1. SEQUENTIAL PROCESSING (Robust Mode)
// Processes matches one by one to avoid 502 errors
let sequentialCount = 0;
for (const match of matchesToProcess) {
sequentialCount++;
// Batch pause: Wait for ~5 matches worth of time every 10 matches
if (sequentialCount > 1 && sequentialCount % 10 === 0) {
this.logger.log(
`[${sport}] ⏸️ Processed 10 matches, pausing for cooldown...`,
);
await this.delay(4000); // Wait 2s (approx 5 * 400ms)
}
await this.delay(300); // 300ms delay between individual matches
try {
const result = await this.processSingleMatch(
match,
data.competitions,
sport,
refreshExistingMatches,
);
if (result.success) {
this.logger.log(
`[${sport}] ✅ successful for ${match.id} ${match.homeTeam.name} vs ${match.awayTeam.name}`,
);
successCount++;
} else if (result.retryable) {
this.logger.log(
`[${sport}] ⚠️ retryable for ${match.id} ${match.homeTeam.name} vs ${match.awayTeam.name}`,
);
failedMatches.push(match);
}
} catch (e: any) {
this.logger.warn(
`[${sport}] Sequential error for ${match.id}: ${e.message}`,
);
failedMatches.push(match);
}
}
// 2. SEQUENTIAL RETRY FOR FAILED (502) MATCHES
if (failedMatches.length > 0) {
this.logger.log(
`[${sport}] ⚠️ Retrying ${failedMatches.length} failed matches sequentially...`,
);
for (const match of failedMatches) {
await this.delay(2000); // Longer delay for retries
try {
const result = await this.processSingleMatch(
match,
data.competitions,
sport,
refreshExistingMatches,
);
if (result.success) {
successCount++;
this.logger.log(`[${sport}] ✅ Retry successful for ${match.id}`);
} else {
this.logger.warn(`[${sport}] ❌ Retry failed for ${match.id}`);
}
} catch (e: any) {
this.logger.warn(
`[${sport}] ❌ Retry exception for ${match.id}: ${e.message}`,
);
}
}
}
this.logger.log(
`[${sport}] [${dateString}] ✓ Saved ${successCount} matches`,
);
} catch (error: any) {
this.logger.error(
`[${sport}] [${dateString}] ❌ Failed: ${error.message}`,
);
}
}
// ============================================
// REFRESH SINGLE MATCH (On-demand)
// ============================================
async refreshMatch(
matchId: string,
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' };
}
// Construct MatchSummary from DB record
const summary: MatchSummary = {
id: matchId,
matchName: matchRecord.matchName,
matchSlug: matchRecord.matchSlug,
competitionId: matchRecord.leagueId,
mstUtc: Number(matchRecord.mstUtc),
iddaaCode: matchRecord.iddaaCode,
homeTeam: {
id: matchRecord.homeTeamId,
name: matchRecord.homeTeam?.name || '',
slug: matchRecord.homeTeam?.slug || '',
},
awayTeam: {
id: matchRecord.awayTeamId,
name: matchRecord.awayTeam?.name || '',
slug: matchRecord.awayTeam?.slug || '',
},
score: {
home: matchRecord.scoreHome,
away: matchRecord.scoreAway,
},
};
const dummyCompetitions: Record<string, Competition> = {
[summary.competitionId]: {
id: summary.competitionId,
name: 'Unknown',
competitionSlug: '',
country: { id: '', name: '' },
},
};
try {
return await this.processSingleMatch(
summary,
dummyCompetitions,
matchRecord.sport as Sport,
true, // FORCE UPDATE
scope,
);
} catch (error: any) {
this.logger.error(`[${matchId}] Refresh exception: ${error.message}`);
return { success: false, retryable: true, error: error.message };
}
}
// ============================================
// PROCESS SINGLE MATCH
// ============================================
private async processSingleMatch(
matchSummary: MatchSummary,
competitions: Record<string, Competition>,
sport: Sport,
force: boolean = false,
scope: 'all' | 'lineups' | 'odds' = 'all', // Add scope flag
): Promise<ProcessResult> {
const matchId = matchSummary.id;
const homeTeamId = matchSummary.homeTeam?.id;
const awayTeamId = matchSummary.awayTeam?.id;
if (!matchId || !homeTeamId || !awayTeamId) {
this.logger.warn(`[${matchId}] Skipped: Missing IDs`);
return { success: false, retryable: false };
}
// Skip postponed matches (ERT = Erteledendi)
if (matchSummary.statusBoxContent === 'ERT') {
this.logger.debug(`[${matchId}] Skipped: Postponed match (ERT)`);
return { success: false, retryable: false };
}
// Track critical errors (502) to trigger retry even if save succeeds
let hasCriticalError = false;
// Helper for resilient fetching with internal retry
const fetchResilient = async <T>(
label: string,
fn: () => Promise<T>,
retries = 3,
baseDelayMs = 1000,
): Promise<T | null> => {
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (e: any) {
const is502 =
e.message?.includes('502') ||
e.response?.status === 502 ||
e.message?.includes('Bad Gateway');
if (i === retries - 1) throw e; // Last attempt failed
if (is502) {
// Exponential backoff: 1s, 2s, 3s
const waitTime = baseDelayMs * (i + 1);
// this.logger.debug(
// `[${matchId}] ${label} failed (502). Retrying in ${waitTime}ms...`,
// );
await this.delay(waitTime);
continue;
}
throw e; // Non-502 error, fail immediately
}
}
return null;
};
try {
// Check if exists
if (!force) {
// Skip exist check if force is true
const exists = await this.persistenceService.matchExists(matchId);
if (exists) {
return { success: true, retryable: false };
}
}
this.logger.debug(
`[${matchId}] Processing (${scope}): ${matchSummary.matchName}`,
);
const league = competitions[matchSummary.competitionId];
const playersMap = new Map<string, TransformedPlayer>();
const participationData: MatchParticipation[] = [];
let eventData: DbEventPayload[] = [];
let stats: TransformedMatchStats | null = null;
let basketballTeamStats: BasketballTeamStats | null = null;
const basketballPlayerStats: Partial<BasketballPlayerStats>[] = [];
let officialsData: MatchOfficial[] = [];
// 1. Fetch Match Header (score, status)
let headerData: ParsedMatchHeader | null = null;
if (scope === 'all') {
try {
headerData = await fetchResilient('Header', () =>
this.scraperService.fetchMatchHeader(matchId),
);
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(`[${matchId}] Header fetch failed: ${e.message}`);
}
}
// 2. Sport-specific data fetching
if (sport === 'basketball') {
// Basketball: Box Score (Always if all or lineups)
if (scope === 'all' || scope === 'lineups') {
try {
const boxData = await fetchResilient('BoxScore', () =>
this.scraperService.fetchBasketballBoxScore(matchId),
);
if (boxData) {
const homeParsed = this.scraperService.parseBasketballBoxScore(
boxData.views?.home?.html || '',
);
const awayParsed = this.scraperService.parseBasketballBoxScore(
boxData.views?.away?.html || '',
);
basketballTeamStats =
scope === 'all'
? {
home: homeParsed.teamTotals,
away: awayParsed.teamTotals,
}
: null;
if (scope === 'all') {
try {
const details = await fetchResilient('QuarterScores', () =>
this.scraperService.fetchBasketballDetailsHeader(matchId),
);
if (details && basketballTeamStats) {
basketballTeamStats.home = {
...basketballTeamStats.home,
...details.home,
};
basketballTeamStats.away = {
...basketballTeamStats.away,
...details.away,
};
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(
`[${matchId}] Quarter scores fetch failed: ${e.message}`,
);
}
}
// Process players (always do if lineups or all)
const processPlayers = (
parsed: typeof homeParsed,
teamId: string,
) => {
parsed.players.forEach((p) => {
if (p.name) {
// Use extracted ID if available, otherwise generate one
const id =
p.id ||
this.transformerService.generateBasketballPlayerId(
teamId,
p.name,
);
basketballPlayerStats.push({ ...p, id, teamId });
playersMap.set(id, {
id,
name: p.name,
slug: id,
teamId,
});
}
});
};
processPlayers(homeParsed, homeTeamId);
processPlayers(awayParsed, awayTeamId);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(`[${matchId}] Box score failed: ${e.message}`);
}
}
} else {
// Football: Events, Lineups, Stats, Officials
// Key Events
if (scope === 'all') {
try {
const eventsData = await fetchResilient('Events', () =>
this.scraperService.fetchKeyEvents(matchId),
);
if (eventsData?.keyEvents) {
const transformedEvents =
this.transformerService.transformKeyEvents(
eventsData.keyEvents,
homeTeamId,
awayTeamId,
matchId,
);
this.transformerService.extractPlayersFromEvents(
transformedEvents,
playersMap,
);
eventData =
this.transformerService.prepareEventDataForDb(
transformedEvents,
);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(`[${matchId}] Events failed: ${e.message}`);
}
await this.delay(300);
}
// Starting Formation & Substitutes (Always for lineups or all)
// V20 OPTIMIZATION: Disabled to speed up feeder and reduce 502 errors.
// We only use Team Stats for V20 model.
/*
if (scope === 'all' || scope === 'lineups') {
// Starting Formation
try {
const formationData =
await this.scraperService.fetchStartingFormation(matchId);
if (formationData?.stats) {
this.transformerService.processLineup(
formationData.stats.home || [],
homeTeamId,
true,
matchId,
playersMap,
participationData,
);
this.transformerService.processLineup(
formationData.stats.away || [],
awayTeamId,
true,
matchId,
playersMap,
participationData,
);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(`[${matchId}] Formation failed: ${e.message}`);
}
// Substitutes
try {
const subsData =
await this.scraperService.fetchSubstitutions(matchId);
if (subsData?.stats) {
this.transformerService.processLineup(
subsData.stats.home || [],
homeTeamId,
false,
matchId,
playersMap,
participationData,
);
this.transformerService.processLineup(
subsData.stats.away || [],
awayTeamId,
false,
matchId,
playersMap,
participationData,
);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(`[${matchId}] Subs failed: ${e.message}`);
}
}
*/
// Game Stats & Officials
if (scope === 'all') {
try {
const gameStats = await fetchResilient('Stats', () =>
this.scraperService.fetchGameStats(matchId),
);
stats = this.transformerService.transformGameStats(gameStats);
} catch (e: any) {
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', () =>
this.scraperService.fetchMatchPage(
matchId,
matchSummary.matchSlug,
sport,
),
);
if (matchPageHtml) {
officialsData =
this.transformerService.parseOfficials(matchPageHtml);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(`[${matchId}] Officials failed: ${e.message}`);
}
}
}
// 3. Fetch Iddaa Odds (Always if all or odds)
let oddsArray: DbMarketPayload[] = [];
if (scope === 'all' || scope === 'odds') {
try {
let markets: ParsedMarket[] = [];
if (sport === 'basketball') {
markets =
((await fetchResilient('BucketOdds', () =>
this.scraperService.fetchBasketballMarkets(matchId),
)) as ParsedMarket[]) || [];
} else {
markets =
((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;
this.logger.warn(`[${matchId}] Odds failed: ${e.message}`);
}
}
// 4. Persist to Database
let saved = false;
if (scope === 'lineups') {
saved = await this.persistenceService.saveLineups(
matchId,
playersMap,
participationData,
homeTeamId,
awayTeamId,
);
} else if (scope === 'odds') {
saved = await this.persistenceService.saveOdds(matchId, oddsArray);
} else {
// Full Update
saved = await this.persistenceService.saveMatch(
sport,
matchId,
matchSummary,
league,
homeTeamId,
awayTeamId,
headerData,
playersMap,
participationData,
eventData,
stats,
basketballTeamStats,
basketballPlayerStats,
oddsArray,
officialsData,
);
}
// === AI FEATURE CALCULATION (V17 - DEPRECATED) ===
// Bu servis V17 modeli içindi. V20 Modeli tamamen Python (ai-engine) tarafında çalışmaktadır.
// Gereksiz kaynak tüketmemesi için devre dışı bırakıldı.
/*
if (saved) {
try {
// Fire and forget - don't block the feeder
this.aiFeatureStoreService
.calculateAndSaveFeatures(matchId)
.catch((err) => {
this.logger.warn(
`[${matchId}] AI Feature calculation failed: ${err.message}`,
);
});
} catch (e) {
// Safety catch
}
}
*/
// ==========================================
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');
this.logger.warn(
`[${matchId}] Saved with MISSING DATA (502). Missing: [${missingParts.join(', ')}]. Scheduled for retry.`,
);
return { success: false, retryable: true };
}
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
if (isRetryable) {
this.logger.warn(`[${matchId}] ${error.message} - Will retry`);
} else {
this.logger.error(`[${matchId}] ${error.message} - Not retryable`);
}
return { success: false, retryable: isRetryable };
}
}
}
+533
View File
@@ -0,0 +1,533 @@
/**
* Feeder Types - Senior Level Implementation
* Based on actual Mackolik API responses
*/
// ============================================
// SPORT TYPES
// ============================================
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' },
};
// ============================================
// MATCH STATUS TYPES
// ============================================
export type MatchStatus = 'Cancelled' | 'Played' | 'Playing' | 'Scheduled';
export type MatchState =
| 'preGame'
| 'postGame'
| 'live'
| 'liveGame'
| 'pre'
| 'post';
// ============================================
// LIVESCORES API RESPONSE
// ============================================
export interface LivescoresApiResponse {
status: string;
data: {
matches: Record<string, MatchSummary>;
competitions: Record<string, Competition>;
};
}
export interface MatchSummary {
id: string;
matchName: string;
matchSlug: string;
competitionId: string;
mstUtc: number;
iddaaCode: string | number | null;
statusBoxContent?: string | null; // ERT = Erteledendi
substate?: string | null;
homeTeam: {
id: string;
name: string;
slug: string;
};
awayTeam: {
id: string;
name: string;
slug: string;
};
score?: {
home: number | string | null;
away: number | string | null;
ht?: {
home: number | string | null;
away: number | string | null;
};
};
homeScore?: number | string | null;
awayScore?: number | string | null;
state?: string | null;
status?: string | null;
winner?: string;
}
export interface Competition {
id: string;
name: string;
competitionSlug: string;
country: {
id: string;
name: string;
};
}
// ============================================
// MATCH HEADER API RESPONSE
// ============================================
export interface MatchHeaderResponse {
status: string;
data: {
html: string; // Contains score, status, HT score
};
}
export interface ParsedMatchHeader {
matchStatus: MatchState;
scoreHome: number | null;
scoreAway: number | null;
htScoreHome: number | null;
htScoreAway: number | null;
}
// ============================================
// KEY EVENTS API RESPONSE
// ============================================
export interface KeyEventsResponse {
status: string;
data: {
keyEvents: RawKeyEvent[];
matchState: MatchState;
matchStartTime: number;
finishedPeriodIds: number[];
};
}
export interface RawKeyEvent {
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;
playerName: string;
playerUrl: string;
assistPlayerName?: string | null;
assistPlayerUrl?: string | null;
playerOutName?: string | null;
playerOutUrl?: string | null;
score?: string; // "1-0" format
}
export interface TransformedEvent {
matchId: string;
playerId: string;
playerName: string;
teamId: string;
eventType: 'goal' | 'card' | 'substitute' | 'other';
eventSubtype: string | null;
timeMinute: string;
timeSeconds: number | null;
periodId: number;
assistPlayerId: string | null;
assistPlayerName: string | null;
scoreAfter: string | null;
playerOutId: string | null;
playerOutName: string | null;
position: 'home' | 'away';
}
// ============================================
// MATCH STATS (LINEUPS) API RESPONSE
// ============================================
export interface MatchStatsResponse {
status: string;
data: {
status: MatchState;
stats: {
home: RawPlayerStats[];
away: RawPlayerStats[];
homeBench?: RawPlayerStats[];
awayBench?: RawPlayerStats[];
homeSubstitutes?: RawPlayerStats[];
awaySubstitutes?: RawPlayerStats[];
};
};
}
export interface RawPlayerStats {
personId: string;
matchName: string;
shirtNumber: number | null;
position: 'goalkeeper' | 'defender' | 'midfielder' | 'striker' | 'Coach' | '';
events: PlayerEvent[] | null;
}
export interface PlayerEvent {
name:
| 'goal'
| 'yellow-card'
| 'red-card'
| 'sub-off'
| 'sub-on'
| 'penalty-missed';
timeMin: string;
count: number;
}
export interface TransformedPlayer {
id: string;
name: string;
slug: string;
teamId?: string;
}
export interface MatchParticipation {
matchId: string;
playerId: string;
teamId: string;
position: string | null;
shirtNumber: number | null;
isStarting: boolean;
}
// ============================================
// GAME STATS API RESPONSE
// ============================================
export interface GameStatsResponse {
status: string;
data: {
status: MatchStatus;
startTime: number;
home: TeamGameStats;
away: Partial<TeamGameStats>;
};
}
export interface TeamGameStats {
possesionPercentage?: number;
shotsOnTarget?: number;
shotsOffTarget?: number;
totalPasses?: number;
corners?: number;
fouls?: number;
offsides?: number;
}
export interface TransformedMatchStats {
home: TeamGameStats;
away: TeamGameStats;
}
// ============================================
// MANAGER API RESPONSE
// ============================================
export interface ManagerResponse {
status: string;
data: {
status: MatchState;
stats: {
home: RawPlayerStats;
away: RawPlayerStats;
};
};
}
export interface TransformedManager {
id: string;
name: string;
role: string;
}
// ============================================
// IDDAA ODDS API RESPONSE (JSON Endpoint)
// ============================================
export interface IddaaOddsResponse {
status: string;
data: {
matchStatus: MatchStatus;
markets: Record<string, IddaaMarket>;
};
}
export interface IddaaMarket {
outcomes: Record<string, IddaaOutcome>;
code: string;
mbc: string;
}
export interface IddaaOutcome {
outcome: string; // The odds value (e.g., "1.78")
handicap: string | null;
state: 'active' | 'suspended';
label: string; // "1", "X", "2", "Alt", "Üst", etc.
}
// ============================================
// IDDAA MARKETS HTML RESPONSE
// ============================================
export interface IddaaMarketsHtmlResponse {
status: string;
data: {
html: string;
matchStatus: MatchStatus;
};
}
export interface ParsedMarket {
marketId: string;
marketName: string;
iddaaCode: string;
mbc: string;
selections: ParsedSelection[];
}
export interface ParsedSelection {
shortcode: string;
outcomeNo: string;
label: string;
value: string; // The odds value
}
// ============================================
// BASKETBALL BOX SCORE
// ============================================
export interface BasketballBoxScoreResponse {
status: string;
data: {
views: {
home: { html: string };
away: { html: string };
};
};
}
export interface BasketballPlayerStats {
id: string;
name: string;
teamId: string;
minutes: string;
points: number;
rebounds: number;
assists: number;
steals: number;
blocks: number;
turnovers: number;
fouls: number;
fgMade: number;
fgAttempted: number;
threePtMade: number;
threePtAttempted: number;
ftMade: number;
ftAttempted: number;
}
export interface BasketballTeamTotals {
points?: number;
rebounds?: number;
assists?: number;
steals?: number;
blocks?: number;
turnovers?: number;
fouls?: number;
fgMade?: number;
fgAttempted?: number;
threePtMade?: number;
threePtAttempted?: number;
ftMade?: number;
ftAttempted?: number;
q1?: number | null;
q2?: number | null;
q3?: number | null;
q4?: number | null;
ot?: number | null;
}
export interface BasketballTeamStats {
home: BasketballTeamTotals;
away: BasketballTeamTotals;
}
// ============================================
// MATCH OFFICIALS
// ============================================
export interface MatchOfficial {
name: string;
role: string;
}
export interface DbEventPayload {
match_id: string;
player_id: string;
team_id: string;
event_type: 'goal' | 'card' | 'substitute';
event_subtype: string | null;
time_minute: string;
time_seconds: number | null;
period_id: number;
assist_player_id: string | null;
score_after: string | null;
player_out_id: string | null;
position: 'home' | 'away';
}
export interface DbMarketSelectionPayload {
shortcode: string;
name: string;
odd: string;
position: string;
}
export interface DbMarketPayload {
id: string;
name: string;
iddaaCode: string;
mbc: string;
selectionCollection: DbMarketSelectionPayload[];
}
// ============================================
// MARKET MAPPING (Static)
// ============================================
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',
// 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',
// 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ı',
// 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',
// 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',
// 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',
// 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',
// İ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',
};
// ============================================
// AXIOS CONFIG
// ============================================
export interface AxiosRequestConfig {
headers: {
'User-Agent': string;
Referer: 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',
};
export const DEFAULT_TIMEOUT = 30000;
// ============================================
// SIDELINED PLAYERS API RESPONSE
// ============================================
export interface SidelinedResponse {
homeTeam: SidelinedTeamData;
awayTeam: SidelinedTeamData;
}
export interface SidelinedTeamData {
teamName: string;
teamId: string;
totalSidelined: number;
players: SidelinedPlayer[];
}
export interface SidelinedPlayer {
playerId: string;
playerName: string;
playerUrl: string;
position: string;
positionShort: string;
type: 'injury' | 'suspension' | 'other';
description: string;
matchesMissed: number | null;
average: number | null;
reasonIcon: string;
}
// ============================================
// PROCESSING RESULT
// ============================================
export interface ProcessResult {
success: boolean;
retryable: boolean;
error?: string;
}
+7
View File
@@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
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',
}));
+18
View File
@@ -0,0 +1,18 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { GeminiService } from './gemini.service';
import { geminiConfig } from './gemini.config';
/**
* Gemini AI Module
*
* Optional module for AI-powered features using Google Gemini API.
* Enable by setting ENABLE_GEMINI=true in your .env file.
*/
@Global()
@Module({
imports: [ConfigModule.forFeature(geminiConfig)],
providers: [GeminiService],
exports: [GeminiService],
})
export class GeminiModule {}
+240
View File
@@ -0,0 +1,240 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenAI } from '@google/genai';
export interface GeminiGenerateOptions {
model?: string;
systemPrompt?: string;
temperature?: number;
maxTokens?: number;
}
export interface GeminiChatMessage {
role: 'user' | 'model';
content: string;
}
/**
* Gemini AI Service
*
* Provides AI-powered text generation using Google Gemini API.
* This service is globally available when ENABLE_GEMINI=true.
*
* @example
* ```typescript
* // Simple text generation
* const response = await geminiService.generateText('Write a poem about coding');
*
* // With options
* const response = await geminiService.generateText('Translate to Turkish', {
* temperature: 0.7,
* systemPrompt: 'You are a professional translator',
* });
*
* // Chat conversation
* const messages = [
* { role: 'user', content: 'Hello!' },
* { role: 'model', content: 'Hi there!' },
* { role: 'user', content: 'What is 2+2?' },
* ];
* const response = await geminiService.chat(messages);
* ```
*/
@Injectable()
export class GeminiService implements OnModuleInit {
private readonly logger = new Logger(GeminiService.name);
private client: GoogleGenAI | null = null;
private isEnabled = false;
private defaultModel: string;
constructor(private readonly configService: ConfigService) {
this.isEnabled = this.configService.get<boolean>('gemini.enabled', false);
this.defaultModel = this.configService.get<string>(
'gemini.defaultModel',
'gemini-2.5-flash',
);
}
onModuleInit() {
if (!this.isEnabled) {
this.logger.log(
'Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.',
);
return;
}
const apiKey = this.configService.get<string>('gemini.apiKey');
if (!apiKey) {
this.logger.warn(
'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');
} catch (error) {
this.logger.error('Failed to initialize Gemini AI', error);
}
}
/**
* Check if Gemini is available and properly configured
*/
isAvailable(): boolean {
return this.isEnabled && this.client !== null;
}
/**
* Generate text content from a prompt
*
* @param prompt - The text prompt to send to the AI
* @param options - Optional configuration for the generation
* @returns Generated text response
*/
async generateText(
prompt: string,
options: GeminiGenerateOptions = {},
): Promise<{ text: string; usage?: any }> {
if (!this.isAvailable()) {
throw new Error('Gemini AI is not available. Check your configuration.');
}
const model = options.model || this.defaultModel;
try {
const contents: any[] = [];
// Add system prompt if provided
if (options.systemPrompt) {
contents.push({
role: 'user',
parts: [{ text: options.systemPrompt }],
});
contents.push({
role: 'model',
parts: [{ text: 'Understood. I will follow these instructions.' }],
});
}
contents.push({
role: 'user',
parts: [{ text: prompt }],
});
const response = await this.client!.models.generateContent({
model,
contents,
config: {
temperature: options.temperature,
maxOutputTokens: options.maxTokens,
},
});
return {
text: (response.text || '').trim(),
usage: response.usageMetadata,
};
} catch (error) {
this.logger.error('Gemini generation failed', error);
throw error;
}
}
/**
* Have a multi-turn chat conversation
*
* @param messages - Array of chat messages
* @param options - Optional configuration for the generation
* @returns Generated text response
*/
async chat(
messages: GeminiChatMessage[],
options: GeminiGenerateOptions = {},
): Promise<{ text: string; usage?: any }> {
if (!this.isAvailable()) {
throw new Error('Gemini AI is not available. Check your configuration.');
}
const model = options.model || this.defaultModel;
try {
const contents = messages.map((msg) => ({
role: msg.role,
parts: [{ text: msg.content }],
}));
// Prepend system prompt if provided
if (options.systemPrompt) {
contents.unshift(
{
role: 'user',
parts: [{ text: options.systemPrompt }],
},
{
role: 'model',
parts: [{ text: 'Understood. I will follow these instructions.' }],
},
);
}
const response = await this.client!.models.generateContent({
model,
contents,
config: {
temperature: options.temperature,
maxOutputTokens: options.maxTokens,
},
});
return {
text: (response.text || '').trim(),
usage: response.usageMetadata,
};
} catch (error) {
this.logger.error('Gemini chat failed', error);
throw error;
}
}
/**
* Generate structured JSON output
*
* @param prompt - The prompt describing what JSON to generate
* @param schema - JSON schema description for the expected output
* @param options - Optional configuration for the generation
* @returns Parsed JSON object
*/
async generateJSON<T = any>(
prompt: string,
schema: string,
options: GeminiGenerateOptions = {},
): Promise<{ data: T; usage?: any }> {
const fullPrompt = `${prompt}
Output the result as valid JSON that matches this schema:
${schema}
IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
const response = await this.generateText(fullPrompt, options);
try {
// Try to extract JSON from the response
let jsonStr = response.text;
// Remove potential markdown code blocks
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
jsonStr = jsonMatch[1].trim();
}
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');
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './gemini.module';
export * from './gemini.service';
export * from './gemini.config';
+44
View File
@@ -0,0 +1,44 @@
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';
@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private prismaHealth: PrismaHealthIndicator,
private prisma: PrismaService,
) {}
@Get()
@Public()
@HealthCheck()
@ApiOperation({ summary: 'Basic health check' })
check() {
return this.health.check([]);
}
@Get('ready')
@Public()
@HealthCheck()
@ApiOperation({ summary: 'Readiness check (includes database)' })
readiness() {
return this.health.check([
() => this.prismaHealth.pingCheck('database', this.prisma),
]);
}
@Get('live')
@Public()
@ApiOperation({ summary: 'Liveness check' })
liveness() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { PrismaHealthIndicator } from '@nestjs/terminus';
import { HealthController } from './health.controller';
@Module({
imports: [TerminusModule],
controllers: [HealthController],
providers: [PrismaHealthIndicator],
})
export class HealthModule {}
+152
View File
@@ -0,0 +1,152 @@
import {
Controller,
Get,
Param,
Query,
NotFoundException,
} 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';
@ApiTags('Leagues')
@Controller('leagues')
export class LeaguesController {
constructor(private readonly leaguesService: LeaguesService) {}
/**
* GET /leagues/countries
* Get all countries
*/
@Get('countries')
@Public()
@ApiOperation({ summary: 'Get all countries' })
@ApiResponse({ status: 200, description: 'List of countries' })
async getCountries() {
return this.leaguesService.findAllCountries();
}
/**
* GET /leagues/countries/:id
* Get country by ID with leagues
*/
@Get('countries/:id')
@Public()
@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');
return country;
}
/**
* GET /leagues
* Get all leagues
*/
@Get()
@Public()
@ApiOperation({ summary: 'Get all leagues' })
@ApiQuery({
name: 'sport',
required: false,
enum: ['football', 'basketball'],
})
async getLeagues(@Query('sport') sport?: string) {
return this.leaguesService.findAllLeagues(sport as Sport);
}
/**
* GET /leagues/teams/h2h
* Get head-to-head matches between two teams
* NOTE: Must come before /teams/:id to avoid route conflict
*/
@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 })
async getHeadToHead(
@Query('team1') team1: string,
@Query('team2') team2: string,
@Query('limit') limit?: string,
) {
return this.leaguesService.getHeadToHead(
team1,
team2,
parseInt(limit || '10', 10),
);
}
/**
* GET /leagues/teams/search
* Search teams by name
*/
@Get('teams/search')
@Public()
@ApiOperation({ summary: 'Search teams by name' })
@ApiQuery({ name: 'q', required: true, description: 'Search query' })
@ApiQuery({
name: 'sport',
required: false,
enum: ['football', 'basketball'],
})
async searchTeams(@Query('q') query: string, @Query('sport') sport?: string) {
return this.leaguesService.searchTeams(query, sport as Sport);
}
/**
* GET /leagues/teams/:id
* Get team by ID
*/
@Get('teams/:id')
@Public()
@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');
return team;
}
/**
* GET /leagues/teams/:id/matches
* Get team's recent 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 })
async getTeamMatches(
@Param('id') id: string,
@Query('limit') limit?: string,
) {
return this.leaguesService.getTeamRecentMatches(
id,
parseInt(limit || '10', 10),
);
}
/**
* GET /leagues/:id
* Get league by ID
*/
@Get(':id')
@Public()
@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');
return league;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { LeaguesController } from './leagues.controller';
import { LeaguesService } from './leagues.service';
import { DatabaseModule } from '../../database/database.module';
@Module({
imports: [DatabaseModule],
controllers: [LeaguesController],
providers: [LeaguesService],
exports: [LeaguesService],
})
export class LeaguesModule {}
+173
View File
@@ -0,0 +1,173 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { Sport } from '@prisma/client';
@Injectable()
export class LeaguesService {
private readonly logger = new Logger(LeaguesService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Get all countries
*/
async findAllCountries() {
return this.prisma.country.findMany({
orderBy: { name: 'asc' },
});
}
/**
* Get country by ID
*/
async findCountryById(id: string) {
return this.prisma.country.findUnique({
where: { id },
include: { leagues: true },
});
}
/**
* Get all leagues
*/
async findAllLeagues(sport?: Sport) {
return this.prisma.league.findMany({
where: sport ? { sport } : undefined,
include: { country: true },
orderBy: { name: 'asc' },
});
}
/**
* Get league by ID
*/
async findLeagueById(id: string) {
return this.prisma.league.findUnique({
where: { id },
include: { country: true },
});
}
/**
* Get leagues by country
*/
async findLeaguesByCountry(countryId: string, sport?: Sport) {
return this.prisma.league.findMany({
where: {
countryId,
...(sport ? { sport } : {}),
},
include: { country: true },
orderBy: { name: 'asc' },
});
}
/**
* Get all teams
*/
async findAllTeams(sport?: Sport, search?: string) {
return this.prisma.team.findMany({
where: {
...(sport ? { sport } : {}),
...(search ? { name: { contains: search, mode: 'insensitive' } } : {}),
},
orderBy: { name: 'asc' },
take: 100,
});
}
/**
* Get team by ID
*/
async findTeamById(id: string) {
return this.prisma.team.findUnique({
where: { id },
});
}
/**
* Search teams by name
*/
async searchTeams(name: string, sport?: Sport) {
return this.prisma.team.findMany({
where: {
name: { contains: name, mode: 'insensitive' },
...(sport ? { sport } : {}),
},
take: 20,
});
}
/**
* Get team's matches (past + upcoming)
*/
async getTeamRecentMatches(teamId: string, limit: number = 50) {
return this.prisma.match.findMany({
where: {
OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }],
},
include: {
homeTeam: true,
awayTeam: true,
league: { include: { country: true } },
},
orderBy: { mstUtc: 'desc' },
take: limit,
});
}
/**
* Get head-to-head matches between two teams
*/
async getHeadToHead(teamId1: string, teamId2: string, limit: number = 10) {
const matches = await this.prisma.match.findMany({
where: {
OR: [
{ homeTeamId: teamId1, awayTeamId: teamId2 },
{ homeTeamId: teamId2, awayTeamId: teamId1 },
],
state: 'postGame', // Finished matches are stored as "postGame"
},
include: {
homeTeam: true,
awayTeam: true,
league: true,
},
orderBy: { mstUtc: 'desc' },
take: limit,
});
// Calculate statistics
let team1Wins = 0;
let team2Wins = 0;
let draws = 0;
matches.forEach((match) => {
const homeScore = Number(match.scoreHome ?? -1);
const awayScore = Number(match.scoreAway ?? -1);
// Skip matches without scores
if (homeScore === -1 || awayScore === -1) return;
const isTeam1Home = match.homeTeamId === teamId1;
if (homeScore === awayScore) {
draws++;
} else if (
(isTeam1Home && homeScore > awayScore) ||
(!isTeam1Home && awayScore > homeScore)
) {
team1Wins++;
} else {
team2Wins++;
}
});
return {
matches,
team1Wins,
team2Wins,
draws,
};
}
}
+219
View File
@@ -0,0 +1,219 @@
import {
IsOptional,
IsString,
IsInt,
IsEnum,
IsDateString,
Min,
Max,
IsArray,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum Sport {
FOOTBALL = 'football',
BASKETBALL = 'basketball',
}
export class OddFilterDto {
@ApiProperty({ example: 'Maç Sonucu' })
@IsString()
categoryName: string;
@ApiProperty({ example: '1' })
@IsString()
selectionName: string;
@ApiProperty({ example: 1.5 })
value: number;
@ApiPropertyOptional({ example: 0.1 })
@IsOptional()
tolerance?: number = 0.1;
}
export class TeamFilterDto {
@ApiProperty()
@IsString()
id: string;
@ApiPropertyOptional({ enum: ['home', 'away', 'any'] })
@IsOptional()
@IsString()
role?: 'home' | 'away' | 'any';
}
export class DateRangeDto {
@ApiProperty()
@IsDateString()
from: string;
@ApiProperty()
@IsDateString()
to: string;
}
export class MatchQueryDto {
@ApiProperty({ enum: Sport, default: Sport.FOOTBALL })
@IsEnum(Sport)
sport: Sport;
@ApiPropertyOptional({ default: 50 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
limit?: number = 50;
@ApiPropertyOptional()
@IsOptional()
@IsString()
leagueId?: string;
@ApiPropertyOptional({
description: 'Filter by status: LIVE, Finished, etc.',
})
@IsOptional()
@IsString()
status?: string;
@ApiPropertyOptional({ description: 'Single date filter (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
date?: string;
@ApiPropertyOptional({ type: TeamFilterDto })
@IsOptional()
@ValidateNested()
@Type(() => TeamFilterDto)
team?: TeamFilterDto;
@ApiPropertyOptional({ type: [OddFilterDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => OddFilterDto)
odds?: OddFilterDto[];
@ApiPropertyOptional({ type: DateRangeDto })
@IsOptional()
@ValidateNested()
@Type(() => DateRangeDto)
dateRange?: DateRangeDto;
}
export class MatchResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
matchName: string;
@ApiPropertyOptional()
matchSlug?: string;
@ApiProperty()
mstUtc: number;
@ApiPropertyOptional()
status?: string;
@ApiPropertyOptional()
state?: string;
@ApiPropertyOptional()
scoreHome?: number;
@ApiPropertyOptional()
scoreAway?: number;
@ApiPropertyOptional()
htScoreHome?: number;
@ApiPropertyOptional()
htScoreAway?: number;
@ApiProperty()
homeTeamName: string;
@ApiPropertyOptional()
homeTeamLogo?: string;
@ApiProperty()
awayTeamName: string;
@ApiPropertyOptional()
awayTeamLogo?: string;
@ApiPropertyOptional()
leagueName?: string;
@ApiPropertyOptional()
countryName?: string;
@ApiPropertyOptional({ type: 'array' })
odds?: any[];
}
export class PaginatedMatchesDto {
@ApiProperty({ type: [MatchResponseDto] })
matches: MatchResponseDto[];
@ApiProperty()
total: number;
@ApiProperty()
page: number;
@ApiProperty()
totalPages: number;
}
export class LeagueWithMatchesDto {
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiPropertyOptional()
code?: string;
@ApiProperty()
country: {
id: string;
name: string;
flagUrl?: string;
};
@ApiProperty()
sport: Sport;
@ApiProperty({ type: [MatchResponseDto] })
matches: MatchResponseDto[];
}
export class ActiveLeagueDto {
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiPropertyOptional()
code?: string;
@ApiPropertyOptional()
countryName?: string;
@ApiPropertyOptional()
countryFlag?: string;
@ApiProperty()
matchCount: number;
@ApiProperty()
liveCount: number;
}
+130
View File
@@ -0,0 +1,130 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
HttpCode,
HttpStatus,
NotFoundException,
BadRequestException,
UseInterceptors,
} 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';
import {
MatchQueryDto,
Sport,
LeagueWithMatchesDto,
ActiveLeagueDto,
} from './dto';
@ApiTags('Matches')
@Controller('matches')
export class MatchesController {
constructor(private readonly matchesService: MatchesService) {}
/**
* POST /matches/query
* Advanced match query with filters
*/
@Public()
@Post('query')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Advanced match query with filters' })
@ApiResponse({ status: 200, type: [LeagueWithMatchesDto] })
async queryMatches(
@Body() queryDto: MatchQueryDto,
): Promise<LeagueWithMatchesDto[]> {
if (!queryDto.sport) {
throw new BadRequestException("'sport' field is required");
}
const matchIds = await this.matchesService.findMatches(queryDto);
if (matchIds.length === 0) {
return [];
}
return this.matchesService.getMatchesAndStructureByIds(
matchIds,
queryDto.sport,
);
}
/**
* GET /matches
* List matches with pagination
*/
@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' })
async listMatches(
@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 sportType = sport || Sport.FOOTBALL;
return this.matchesService.listMatches(sportType, pageNum, limitNum);
}
/**
* GET /matches/leagues/active
* Get active leagues with match counts
*/
@Public()
@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 })
@ApiResponse({ status: 200, type: [ActiveLeagueDto] })
async getActiveLeagues(
@Query('sport') sport?: Sport,
): Promise<ActiveLeagueDto[]> {
return this.matchesService.getActiveLeagues(sport || Sport.FOOTBALL);
}
/**
* GET /matches/:id
* Get full match details
*/
@Public()
@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',
})
@ApiResponse({ status: 404, description: 'Match not found' })
async getMatchDetails(@Param('id') id: string) {
if (!id) {
throw new BadRequestException('Match ID is required');
}
const match = await this.matchesService.getMatchDetailsById(id);
if (!match) {
throw new NotFoundException('Match not found');
}
return match;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { MatchesController } from './matches.controller';
import { MatchesService } from './matches.service';
import { DatabaseModule } from '../../database/database.module';
@Module({
imports: [DatabaseModule],
controllers: [MatchesController],
providers: [MatchesService],
exports: [MatchesService],
})
export class MatchesModule {}
+703
View File
@@ -0,0 +1,703 @@
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';
@Injectable()
export class MatchesService {
private readonly logger = new Logger(MatchesService.name);
private topLeagueIds: string[] = [];
constructor(private readonly prisma: PrismaService) {
this.loadTopLeagues();
}
private loadTopLeagues() {
try {
const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json');
if (fs.existsSync(topLeaguesPath)) {
this.topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8'));
this.logger.log(
`Loaded ${this.topLeagueIds.length} top leagues for filtering.`,
);
}
} catch (e) {
this.logger.warn(`Failed to load top_leagues.json: ${e.message}`);
}
}
private getLiveFilter(): Prisma.LiveMatchWhereInput {
return {
OR: [
{
status: {
in: [
'LIVE',
'1H',
'2H',
'HT',
'1Q',
'2Q',
'3Q',
'4Q',
'Playing',
'Half Time',
],
},
},
{
state: {
in: ['live', 'firsthalf', 'secondhalf'],
},
},
],
};
}
private getFinishedFilter(): Prisma.LiveMatchWhereInput {
return {
OR: [
{
status: {
in: ['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'],
},
},
{
state: {
in: ['Finished', 'post', 'FT', 'postGame'],
},
},
],
};
}
private getUpcomingFilter(
fromTimestampMs: number,
): Prisma.LiveMatchWhereInput {
return {
AND: [
{
mstUtc: {
gte: BigInt(fromTimestampMs),
},
},
{
NOT: {
OR: [this.getLiveFilter(), this.getFinishedFilter()],
},
},
],
};
}
private getBrowseFilter(fromTimestampMs: number): Prisma.LiveMatchWhereInput {
return {
AND: [
{
mstUtc: {
gte: BigInt(fromTimestampMs),
},
},
{
NOT: this.getFinishedFilter(),
},
],
};
}
/**
* Find matches by query criteria
*/
async findMatches(options: MatchQueryDto): Promise<string[]> {
const {
sport,
limit = 50,
leagueId,
status,
date,
team,
dateRange,
} = options;
// Build where conditions
const where: Prisma.LiveMatchWhereInput = {
sport: sport as any,
};
const andConditions: Prisma.LiveMatchWhereInput[] = [];
if (leagueId) {
where.leagueId = leagueId;
} 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') {
andConditions.push(this.getLiveFilter());
} else if (status === 'UPCOMING' || status === 'NOT_STARTED') {
andConditions.push(this.getUpcomingFilter(Date.now()));
} else if (status === 'FINISHED') {
andConditions.push(this.getFinishedFilter());
} else if (status) {
where.status = status;
}
// Date filter
if (date) {
const d = new Date(date);
const startOfDay = new Date(d);
startOfDay.setUTCHours(0, 0, 0, 0);
const endOfDay = new Date(d);
endOfDay.setUTCHours(23, 59, 59, 999);
where.mstUtc = {
gte: BigInt(startOfDay.getTime()),
lte: BigInt(endOfDay.getTime()),
};
} else if (dateRange) {
where.mstUtc = {
gte: BigInt(new Date(dateRange.from).getTime()),
lte: BigInt(new Date(dateRange.to).getTime()),
};
}
// Team filter
if (team) {
if (team.role === 'home') {
where.homeTeamId = team.id;
} else if (team.role === 'away') {
where.awayTeamId = team.id;
} else {
andConditions.push({
OR: [{ homeTeamId: team.id }, { awayTeamId: team.id }],
});
}
}
// Default date filter: From today onwards if no specific filter
if (!date && !dateRange && !status) {
const today = new Date();
today.setUTCHours(0, 0, 0, 0); // Start of today in UTC
andConditions.push(this.getBrowseFilter(today.getTime()));
}
if (andConditions.length > 0) {
where.AND = andConditions;
}
// Switch to live_matches table
const matches = await this.prisma.liveMatch.findMany({
where,
select: { id: true },
orderBy: { mstUtc: 'asc' }, // Sort by nearest match first
take: limit,
});
return matches.map((m) => m.id);
}
/**
* Find upcoming matches from the live matches table
* Used for Coupon Generator when no specific matches are selected
*/
async findUpcomingMatches(
sport: Sport,
limit: number = 50,
): Promise<string[]> {
console.log(`[MatchesService] Finding upcoming matches for ${sport}`);
const matches = await this.prisma.liveMatch.findMany({
where: {
sport: sport as any,
AND: [this.getUpcomingFilter(Date.now())],
},
select: { id: true },
orderBy: { mstUtc: 'asc' },
take: limit,
});
console.log(
`[MatchesService] Found ${matches.length} upcoming matches from live_matches`,
);
return matches.map((m) => m.id);
}
async filterUpcomingMatchIds(
matchIds: string[],
sport: Sport,
): Promise<string[]> {
const uniqueIds = [...new Set(matchIds.filter((id) => !!id))];
if (uniqueIds.length === 0) {
return [];
}
const matches = await this.prisma.liveMatch.findMany({
where: {
id: { in: uniqueIds },
sport: sport as any,
AND: [this.getUpcomingFilter(Date.now())],
},
select: { id: true },
});
return matches.map((match) => match.id);
}
/**
* Get matches structured by league (from live_matches table)
*/
async getMatchesAndStructureByIds(
matchIds: string[],
sport: Sport,
): Promise<LeagueWithMatchesDto[]> {
if (!matchIds.length) return [];
const matches = await this.prisma.liveMatch.findMany({
where: { id: { in: matchIds } },
include: {
league: {
include: {
country: true,
},
},
homeTeam: true,
awayTeam: true,
},
});
// Sort matches by time (ASC) before grouping to ensure correct order
matches.sort((a, b) =>
Number(BigInt(a.mstUtc || 0) - BigInt(b.mstUtc || 0)),
);
// Group by league
const leaguesMap = new Map<string, LeagueWithMatchesDto>();
for (const match of matches) {
const leagueId = match.leagueId || 'unknown';
if (!leaguesMap.has(leagueId)) {
leaguesMap.set(leagueId, {
id: leagueId,
name: match.league?.name || 'Unknown League',
code: match.league?.code || undefined,
country: {
id: match.league?.country?.id || '',
name: match.league?.country?.name || '',
flagUrl: match.league?.country?.flagUrl || undefined,
},
sport: sport,
matches: [],
});
}
const league = leaguesMap.get(leagueId)!;
// Structure odds from JSON
const structuredOdds: any[] = [];
if (
match.odds &&
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') {
for (const [selName, selOdd] of Object.entries(selections)) {
structuredSelections[selName] = { odd: String(selOdd) };
}
structuredOdds.push({
category_name: marketName,
selections: structuredSelections,
});
}
}
}
// Map status for frontend
let displayStatus = match.status || 'NS';
if (match.state === 'live') {
displayStatus = 'LIVE';
} else if (
match.state === 'post' ||
match.state === 'FT' ||
match.status === 'Finished'
) {
displayStatus = 'Finished';
}
league.matches.push({
id: match.id,
matchName:
match.matchName ||
`${match.homeTeam?.name} vs ${match.awayTeam?.name}`,
matchSlug: match.matchSlug || undefined,
mstUtc: Number(match.mstUtc),
status: displayStatus,
state: match.state || undefined,
scoreHome: match.scoreHome ?? undefined,
scoreAway: match.scoreAway ?? undefined,
htScoreHome: undefined, // LiveMatch table doesn't have HT scores separately usually
htScoreAway: undefined,
homeTeamName: match.homeTeam?.name || 'Unknown',
homeTeamLogo: match.homeTeamId
? `https://file.mackolikfeeds.com/teams/${match.homeTeamId}`
: undefined,
awayTeamName: match.awayTeam?.name || 'Unknown',
awayTeamLogo: match.awayTeamId
? `https://file.mackolikfeeds.com/teams/${match.awayTeamId}`
: undefined,
leagueName: match.league?.name,
countryName: match.league?.country?.name,
odds: structuredOdds,
});
}
return Array.from(leaguesMap.values());
}
/**
* Get active leagues with match counts
*/
async getActiveLeagues(sport: Sport): Promise<ActiveLeagueDto[]> {
// Use raw query for complex aggregation
const leagues = await this.prisma.$queryRaw<any[]>`
SELECT
l.id, l.name, l.code,
c.name as country_name,
c.flag_url as country_flag,
COUNT(lm.id)::int as match_count,
COUNT(CASE WHEN lm.status IN ('LIVE', '1H', '2H', 'HT', '1Q', '2Q', '3Q', '4Q', 'Playing', 'Half Time')
OR lm.state IN ('live', 'firsthalf', 'secondhalf') THEN 1 END)::int as live_count
FROM live_matches lm
JOIN leagues l ON lm.league_id = l.id
LEFT JOIN countries c ON l.country_id = c.id
WHERE lm.sport = ${sport}
${this.topLeagueIds.length > 0 ? Prisma.sql`AND l.id IN (${Prisma.join(this.topLeagueIds)})` : Prisma.empty}
GROUP BY l.id, l.name, l.code, c.name, c.flag_url
ORDER BY l.name ASC
`;
// 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',
];
return leagues
.sort((a, b) => {
const aIdx = PRIORITY.findIndex((p) => a.name?.includes(p));
const bIdx = PRIORITY.findIndex((p) => b.name?.includes(p));
const aPriority = aIdx === -1 ? 999 : aIdx;
const bPriority = bIdx === -1 ? 999 : bIdx;
if (aPriority !== bPriority) return aPriority - bPriority;
return (a.name || '').localeCompare(b.name || '');
})
.map((l) => ({
id: l.id,
name: l.name,
code: l.code,
countryName: l.country_name,
countryFlag: l.country_flag,
matchCount: l.match_count,
liveCount: l.live_count,
}));
}
/**
* List matches with pagination
*/
async listMatches(sport: Sport, page: number = 1, limit: number = 20) {
const skip = (page - 1) * limit;
const [matches, total] = await Promise.all([
this.prisma.match.findMany({
where: { sport: sport as any },
include: {
homeTeam: true,
awayTeam: true,
league: {
include: { country: true },
},
},
orderBy: { mstUtc: 'desc' },
skip,
take: limit,
}),
this.prisma.match.count({ where: { sport: sport as any } }),
]);
return {
matches: matches.map((m) => ({
id: m.id,
matchName: m.matchName,
matchSlug: m.matchSlug,
mstUtc: Number(m.mstUtc),
scoreHome: m.scoreHome,
scoreAway: m.scoreAway,
status: m.status,
homeTeamName: m.homeTeam?.name,
homeTeamLogo: m.homeTeamId
? `https://file.mackolikfeeds.com/teams/${m.homeTeamId}`
: null,
awayTeamName: m.awayTeam?.name,
awayTeamLogo: m.awayTeamId
? `https://file.mackolikfeeds.com/teams/${m.awayTeamId}`
: null,
leagueName: m.league?.name,
countryName: m.league?.country?.name,
})),
total,
page,
totalPages: Math.ceil(total / limit),
};
}
private normalizeTeamStat(stat: any, sport?: string) {
if (!stat) return null;
const base = {
id: stat.id,
matchId: stat.matchId,
teamId: stat.teamId,
createdAt: stat.createdAt,
};
if ((sport || '').toLowerCase() === 'basketball') {
return {
...base,
points: stat.points,
rebounds: stat.rebounds,
assists: stat.assists,
fgMade: stat.fgMade,
fgAttempted: stat.fgAttempted,
threePtMade: stat.threePtMade,
threePtAttempted: stat.threePtAttempted,
ftMade: stat.ftMade,
ftAttempted: stat.ftAttempted,
steals: stat.steals,
blocks: stat.blocks,
turnovers: stat.turnovers,
q1Score: stat.q1Score,
q2Score: stat.q2Score,
q3Score: stat.q3Score,
q4Score: stat.q4Score,
otScore: stat.otScore,
};
}
return {
...base,
possessionPercentage: stat.possessionPercentage,
shotsOnTarget: stat.shotsOnTarget,
shotsOffTarget: stat.shotsOffTarget,
totalShots: stat.totalShots,
totalPasses: stat.totalPasses,
corners: stat.corners,
fouls: stat.fouls,
offsides: stat.offsides,
};
}
/**
* Get full match details by ID
*/
async getMatchDetailsById(matchId: string) {
let match: any = await this.prisma.match.findUnique({
where: { id: matchId },
include: {
league: { include: { country: true } },
homeTeam: true,
awayTeam: true,
footballTeamStats: true,
basketballTeamStats: true,
playerParticipations: {
include: { player: true },
orderBy: [{ isStarting: 'desc' }, { position: 'asc' }],
},
playerEvents: {
include: {
player: true,
assistPlayer: true,
substitutedOut: true,
},
orderBy: [{ periodId: 'asc' }, { timeMinute: 'asc' }],
},
oddCategories: {
include: { selections: true },
},
officials: true,
},
});
if (!match) {
// Try to find in LiveMatch table
const liveMatch = await this.prisma.liveMatch.findUnique({
where: { id: matchId },
include: {
league: { include: { country: true } },
homeTeam: true,
awayTeam: true,
},
});
if (liveMatch) {
// Map liveMatch status
let displayStatus = liveMatch.status || 'NS';
if (liveMatch.state === 'live') {
displayStatus = 'LIVE';
} else if (
liveMatch.state === 'post' ||
liveMatch.state === 'FT' ||
liveMatch.status === 'Finished'
) {
displayStatus = 'Finished';
}
match = {
...liveMatch,
matchName:
liveMatch.matchName ||
`${liveMatch.homeTeam?.name} vs ${liveMatch.awayTeam?.name}`,
status: displayStatus,
mstUtc: liveMatch.mstUtc,
score: {
home: liveMatch.scoreHome,
away: liveMatch.scoreAway,
},
date: new Date(Number(liveMatch.mstUtc)),
// Fill missing relations with empty arrays
teamStats: [],
playerParticipations: [],
playerEvents: [],
oddCategories: [], // Will handle odds parsing below
officials: [],
isLiveSource: true, // Flag to indicate source
};
}
}
if (!match) return null;
// Structure odds
const odds: Record<
string,
Record<string, { odd: string; sov?: number }>
> = {};
if (
match.isLiveSource &&
match.odds &&
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') {
for (const [selName, selOdd] of Object.entries(selections)) {
odds[marketName][selName] = { odd: String(selOdd) };
}
}
}
} else if (match.oddCategories) {
// Parse relation odds from Match
for (const cat of match.oddCategories) {
if (!cat.name) continue;
odds[cat.name] = {};
for (const sel of cat.selections) {
if (sel.name) {
odds[cat.name][sel.name] = {
odd: sel.oddValue || '',
sov: sel.sov ?? undefined,
};
}
}
}
}
const sportStats =
match.sport === 'basketball'
? match.basketballTeamStats || []
: match.footballTeamStats || [];
const normalizedTeamStats = sportStats.map((s: any) =>
this.normalizeTeamStat(s, match.sport),
);
const homeStat = sportStats.find((s: any) => s.teamId === match.homeTeamId);
const awayStat = sportStats.find((s: any) => s.teamId === match.awayTeamId);
return {
...match,
teamStats: normalizedTeamStats,
mstUtc: Number(match.mstUtc),
date: match.date || new Date(Number(match.mstUtc)),
// Ensure score is in expected format (nested object for frontend if needed, but frontend seems to use match.score.home in some places and match.scoreHome in others.
// The match-detail-content uses match.score.home. Match entity has scoreHome/scoreAway fields.
// Let's ensure compatibility.
score: match.score || { home: match.scoreHome, away: match.scoreAway },
stats: {
home: this.normalizeTeamStat(homeStat, match.sport),
away: this.normalizeTeamStat(awayStat, match.sport),
},
lineups: {
home: match.playerParticipations.filter(
(p: any) => p.teamId === match.homeTeamId,
),
away: match.playerParticipations.filter(
(p: any) => p.teamId === match.awayTeamId,
),
},
events: match.playerEvents,
odds,
};
}
/**
* Get team ID by name (for legacy compatibility)
*/
async getTeamIdByName(
teamName: string,
sport: Sport,
): Promise<string | null> {
const trimmedName = teamName.trim();
// Exact match first
let team = await this.prisma.team.findFirst({
where: { name: trimmedName, sport: sport as any },
select: { id: true },
});
if (team) return team.id;
// Fuzzy search
team = await this.prisma.team.findFirst({
where: {
name: { contains: trimmedName, mode: 'insensitive' },
sport: sport as any,
},
select: { id: true },
});
return team?.id || null;
}
}
+471
View File
@@ -0,0 +1,471 @@
import { ApiProperty } from '@nestjs/swagger';
export type SignalTier =
| 'CORE'
| 'VALUE'
| 'LEAN'
| 'LONGSHOT'
| 'PASS';
export class MatchInfoDto {
@ApiProperty()
match_id: string;
@ApiProperty()
match_name: string;
@ApiProperty()
home_team: string;
@ApiProperty()
away_team: string;
@ApiProperty()
league: string;
@ApiProperty()
match_date_ms: number;
@ApiProperty({ required: false, nullable: true })
league_id?: string | null;
@ApiProperty({ required: false, default: false })
is_top_league?: boolean;
@ApiProperty({
required: false,
enum: ['football', 'basketball'],
})
sport?: 'football' | 'basketball';
}
export class DataQualityDto {
@ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] })
label: 'HIGH' | 'MEDIUM' | 'LOW';
@ApiProperty()
score: number;
@ApiProperty()
home_lineup_count: number;
@ApiProperty()
away_lineup_count: number;
@ApiProperty({ required: false, default: 'none' })
lineup_source?: string;
@ApiProperty({ type: [String] })
flags: string[];
}
export class ConfidenceIntervalDto {
@ApiProperty()
lower: number;
@ApiProperty()
upper: number;
@ApiProperty()
width: number;
@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()
score: number;
@ApiProperty()
is_surprise_risk: boolean;
@ApiProperty({ nullable: true })
surprise_type: string | null;
@ApiProperty({ required: false, default: 0 })
surprise_score?: number;
@ApiProperty({ required: false, nullable: true })
surprise_comment?: string | null;
@ApiProperty({ type: [String], required: false })
surprise_reasons?: string[];
@ApiProperty({ type: [String] })
warnings: string[];
}
export class EngineBreakdownDto {
@ApiProperty()
team: number;
@ApiProperty()
player: number;
@ApiProperty()
odds: number;
@ApiProperty()
referee: number;
}
export class MatchPickDto {
@ApiProperty()
market: string;
@ApiProperty()
pick: string;
@ApiProperty()
probability: number;
@ApiProperty()
confidence: number;
@ApiProperty()
odds: number;
@ApiProperty()
raw_confidence: number;
@ApiProperty()
calibrated_confidence: number;
@ApiProperty()
min_required_confidence: number;
@ApiProperty()
edge: number;
@ApiProperty({ required: false, default: 0 })
ev_edge?: number;
@ApiProperty({ required: false, default: 0 })
implied_prob?: number;
@ApiProperty()
play_score: number;
@ApiProperty()
playable: boolean;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
bet_grade: 'A' | 'B' | 'C' | 'PASS';
@ApiProperty()
stake_units: number;
@ApiProperty({ type: [String] })
decision_reasons: string[];
@ApiProperty({ type: ConfidenceIntervalDto, required: false })
confidence_interval?: ConfidenceIntervalDto;
@ApiProperty({
required: false,
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
})
signal_tier?: SignalTier;
}
export class MatchBetAdviceDto {
@ApiProperty()
playable: boolean;
@ApiProperty()
suggested_stake_units: number;
@ApiProperty()
reason: string;
@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'],
})
signal_tier?: SignalTier;
}
export class MatchBetSummaryItemDto {
@ApiProperty()
market: string;
@ApiProperty()
pick: string;
@ApiProperty()
raw_confidence: number;
@ApiProperty()
calibrated_confidence: number;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
bet_grade: 'A' | 'B' | 'C' | 'PASS';
@ApiProperty()
playable: boolean;
@ApiProperty()
stake_units: number;
@ApiProperty()
play_score: number;
@ApiProperty({ required: false, default: 0 })
ev_edge?: number;
@ApiProperty({ required: false, default: 0 })
implied_prob?: number;
@ApiProperty({ required: false, default: 0 })
odds?: number;
@ApiProperty({ type: [String] })
reasons: string[];
@ApiProperty({ type: ConfidenceIntervalDto, required: false })
confidence_interval?: ConfidenceIntervalDto;
@ApiProperty({
required: false,
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
})
signal_tier?: SignalTier;
}
export class HtFtPredictionDto {
@ApiProperty()
'1/1': number;
@ApiProperty()
'1/X': number;
@ApiProperty()
'1/2': number;
@ApiProperty()
'X/1': number;
@ApiProperty()
'X/X': number;
@ApiProperty()
'X/2': number;
@ApiProperty()
'2/1': number;
@ApiProperty()
'2/X': number;
@ApiProperty()
'2/2': number;
@ApiProperty()
pick: string;
@ApiProperty()
confidence: number;
}
export class AggressivePickDto {
@ApiProperty()
market: string;
@ApiProperty()
pick: string;
@ApiProperty()
probability: number;
@ApiProperty()
confidence: number;
@ApiProperty()
odds: number;
@ApiProperty()
raw_confidence: number;
@ApiProperty()
calibrated_confidence: number;
@ApiProperty()
min_required_confidence: number;
@ApiProperty()
edge: number;
@ApiProperty({ required: false, default: 0 })
ev_edge?: number;
@ApiProperty({ required: false, default: 0 })
implied_prob?: number;
@ApiProperty()
play_score: number;
@ApiProperty()
playable: boolean;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
bet_grade: 'A' | 'B' | 'C' | 'PASS';
@ApiProperty()
stake_units: number;
@ApiProperty({ type: [String] })
decision_reasons: string[];
@ApiProperty({ type: ConfidenceIntervalDto, required: false })
confidence_interval?: ConfidenceIntervalDto;
}
export class ScenarioTop5ItemDto {
@ApiProperty()
scenario: string;
@ApiProperty()
score: number;
@ApiProperty()
probability: number;
}
export class ScorePredictionDto {
@ApiProperty()
ft: string;
@ApiProperty()
ht: string;
@ApiProperty()
xg_home: number;
@ApiProperty()
xg_away: number;
@ApiProperty()
xg_total: number;
}
export class MatchPredictionDto {
@ApiProperty()
model_version: string;
@ApiProperty({ type: MatchInfoDto })
match_info: MatchInfoDto;
@ApiProperty({ type: DataQualityDto })
data_quality: DataQualityDto;
@ApiProperty({ type: RiskDto })
risk: RiskDto;
@ApiProperty({ type: EngineBreakdownDto })
engine_breakdown: EngineBreakdownDto;
@ApiProperty({ type: MatchPickDto, nullable: true })
main_pick: MatchPickDto | null;
@ApiProperty({ type: MatchPickDto, nullable: true })
value_pick: MatchPickDto | null;
@ApiProperty({ type: MatchBetAdviceDto })
bet_advice: MatchBetAdviceDto;
@ApiProperty({ type: [MatchBetSummaryItemDto] })
bet_summary: MatchBetSummaryItemDto[];
@ApiProperty({ type: [MatchPickDto] })
supporting_picks: MatchPickDto[];
@ApiProperty({ type: AggressivePickDto, nullable: true })
aggressive_pick: AggressivePickDto | null;
@ApiProperty({ type: HtFtPredictionDto, required: false })
htft?: HtFtPredictionDto;
@ApiProperty({ type: [ScenarioTop5ItemDto] })
scenario_top5: ScenarioTop5ItemDto[];
@ApiProperty({ type: ScorePredictionDto })
score_prediction: ScorePredictionDto;
@ApiProperty({ type: Object })
market_board: Record<string, unknown>;
@ApiProperty({ type: [String] })
reasoning_factors: string[];
}
export class ValueBetDto {
@ApiProperty()
matchId: string;
@ApiProperty()
matchName: string;
@ApiProperty()
betType: string;
@ApiProperty()
prediction: string;
@ApiProperty()
confidence: number;
@ApiProperty()
odd: number;
@ApiProperty()
expectedValue: number;
}
export class PredictionHistoryStatsDto {
@ApiProperty()
totalPredictions: number;
@ApiProperty()
totalResolved: number;
@ApiProperty()
correctPredictions: number;
@ApiProperty()
accuracyRate: number;
}
export class PredictionHistoryResponseDto {
@ApiProperty({ type: PredictionHistoryStatsDto })
stats: PredictionHistoryStatsDto;
@ApiProperty({ type: [Object] })
history: Record<string, unknown>[];
}
export class UpcomingPredictionsDto {
@ApiProperty()
count: number;
@ApiProperty({ type: [MatchPredictionDto] })
matches: MatchPredictionDto[];
@ApiProperty()
modelVersion: string;
}
export class AIHealthDto {
@ApiProperty()
status: string;
@ApiProperty()
modelLoaded: boolean;
@ApiProperty()
predictionServiceReady: boolean;
}
export * from './smart-coupon.dto';
@@ -0,0 +1,63 @@
import {
IsArray,
IsString,
IsOptional,
IsNotEmpty,
IsNumber,
IsEnum,
ArrayMaxSize,
Min,
Max,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class GeneratePredictionDto {
@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',
}
export class SmartCouponRequestDto {
@ApiProperty({
description: 'List of match IDs for coupon',
example: ['match-1', 'match-2'],
})
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(50)
matchIds: string[];
@ApiPropertyOptional({
enum: CouponStrategy,
default: CouponStrategy.BALANCED,
})
@IsOptional()
@IsEnum(CouponStrategy)
strategy?: CouponStrategy;
@ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 })
@IsOptional()
@IsNumber()
@Min(1)
@Max(20)
maxMatches?: number;
@ApiPropertyOptional({
description: 'Minimum confidence threshold (0-100)',
example: 60,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
minConfidence?: number;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Smart Coupon DTOs aligned with AI Engine V20+ contract.
*/
export type CouponStrategy =
| 'SAFE'
| 'BALANCED'
| 'AGGRESSIVE'
| 'VALUE'
| 'MIRACLE';
export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
export type DataQualityLabel = 'HIGH' | 'MEDIUM' | 'LOW';
export interface SmartCouponRequestDto {
match_ids: string[];
strategy?: CouponStrategy;
max_matches?: number;
min_confidence?: number;
}
export interface CouponBetDto {
match_id: string;
match_name: string;
market: string;
pick: string;
probability: number;
confidence: number;
odds: number;
risk_level: RiskLevel;
data_quality: DataQualityLabel;
}
export interface RejectedMatchDto {
match_id: string;
reason: string;
threshold?: number;
}
export interface SmartCouponResponseDto {
strategy: CouponStrategy;
generated_at: string;
match_count: number;
bets: CouponBetDto[];
total_odds: number;
expected_win_rate: number;
rejected_matches: RejectedMatchDto[];
}
export interface SmartCouponApiError {
error: string;
detail?: string;
match_ids_failed?: string[];
}
export interface StrategyInfo {
name: CouponStrategy;
description: string;
typical_odds: string;
}
export interface StrategiesResponseDto {
strategies: StrategyInfo[];
}
+169
View File
@@ -0,0 +1,169 @@
import {
Controller,
Get,
Post,
Body,
Param,
HttpCode,
HttpStatus,
NotFoundException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
import { PredictionsService } from './predictions.service';
import {
MatchPredictionDto,
PredictionHistoryResponseDto,
UpcomingPredictionsDto,
ValueBetDto,
AIHealthDto,
} from './dto';
import {
GeneratePredictionDto,
SmartCouponRequestDto,
} from './dto/predictions-request.dto';
import { Public } from 'src/common/decorators';
@ApiTags('Predictions')
@Controller('predictions')
export class PredictionsController {
constructor(private readonly predictionsService: PredictionsService) {}
/**
* GET /predictions/health
* 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();
}
/**
* GET /predictions/upcoming
* 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();
}
/**
* 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) {
return this.predictionsService.testPrediction(id);
}
/**
* GET /predictions/value-bets
* Get EV+ betting opportunities
*/
@Get('value-bets')
@ApiOperation({ summary: 'Get value betting opportunities (EV+)' })
@ApiResponse({ status: 200, type: [ValueBetDto] })
async getValueBets(): Promise<ValueBetDto[]> {
return this.predictionsService.getValueBets();
}
/**
* GET /predictions/history
* Get prediction history and accuracy stats
*/
@Get('history')
@ApiOperation({ summary: 'Get prediction history and accuracy statistics' })
@ApiResponse({ status: 200, type: PredictionHistoryResponseDto })
async getHistory(): Promise<PredictionHistoryResponseDto> {
return this.predictionsService.getPredictionHistory();
}
/**
* GET /predictions/:matchId
* Get prediction for a specific match
*/
@Get(':matchId')
@Public()
@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' })
async getPrediction(
@Param('matchId') matchId: string,
): Promise<MatchPredictionDto> {
// Check cache first
const cached = await this.predictionsService.getCachedPrediction(matchId);
if (cached) {
return cached;
}
// Get from AI Engine
const prediction = await this.predictionsService.getPredictionById(matchId);
if (!prediction) {
throw new NotFoundException(`Match not found: ${matchId}`);
}
// Cache the result
await this.predictionsService.cachePrediction(matchId, prediction);
return prediction;
}
/**
* POST /predictions/generate
* Generate prediction with provided match data
*/
@Post('generate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Generate prediction with provided match data' })
@ApiResponse({ status: 200, type: MatchPredictionDto })
async generatePrediction(
@Body() dto: GeneratePredictionDto,
): Promise<MatchPredictionDto> {
const prediction = await this.predictionsService.getPredictionWithData({
matchId: dto.matchId,
});
if (!prediction) {
throw new NotFoundException('Failed to generate prediction');
}
return prediction;
}
/**
* POST /predictions/smart-coupon
* Generate Smart Coupon using AI Engine V20
*/
@Post('smart-coupon')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Generate Smart Coupon with V20 AI recommendations',
})
@ApiResponse({
status: 200,
description: 'Smart coupon generated successfully',
})
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> {
const coupon = await this.predictionsService.getSmartCoupon(
dto.matchIds,
dto.strategy || 'BALANCED',
{
maxMatches: dto.maxMatches,
minConfidence: dto.minConfidence,
},
);
if (!coupon) {
throw new NotFoundException('Failed to generate Smart Coupon');
}
return coupon;
}
}
+37
View File
@@ -0,0 +1,37 @@
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';
@Module({
imports: [
DatabaseModule,
HttpModule.register({
timeout: 30000, // 30 seconds
maxRedirects: 5,
}),
...(redisEnabled
? [BullModule.registerQueue({ name: PREDICTIONS_QUEUE })]
: []),
MatchesModule,
FeederModule,
],
controllers: [PredictionsController],
providers: [
PredictionsService,
AiFeatureStoreService,
...(redisEnabled ? [PredictionsQueue, PredictionsProcessor] : []),
],
exports: [PredictionsService, AiFeatureStoreService],
})
export class PredictionsModule {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
/* eslint-disable @typescript-eslint/unbound-method */
import axios from 'axios';
import { PredictionJobType } from './predictions.types';
import { PredictionsProcessor } from './predictions.processor';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('PredictionsProcessor', () => {
let processor: PredictionsProcessor;
beforeEach(() => {
jest.clearAllMocks();
process.env.AI_ENGINE_URL = 'http://unit-ai:8000';
processor = new PredictionsProcessor();
});
afterEach(() => {
delete process.env.AI_ENGINE_URL;
});
it('posts to analyze endpoint for predict-match jobs', async () => {
mockedAxios.post.mockResolvedValueOnce({ data: { ok: true } } as any);
const job = {
id: 'j1',
name: PredictionJobType.PREDICT_MATCH,
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',
{},
{ timeout: 30000 },
);
});
it('posts mapped payload to coupon endpoint for smart-coupon jobs', async () => {
mockedAxios.post.mockResolvedValueOnce({ data: { bets: [] } } as any);
const job = {
id: 'j2',
name: PredictionJobType.SMART_COUPON,
data: {
matchIds: ['m1', 'm2'],
strategy: 'BALANCED',
options: { maxMatches: 4, minConfidence: 65 },
},
} as any;
const result = await processor.process(job);
expect(result).toEqual({ bets: [] });
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://unit-ai:8000/v20plus/coupon',
{
match_ids: ['m1', 'm2'],
strategy: 'BALANCED',
max_matches: 4,
min_confidence: 65,
},
{ timeout: 60000 },
);
});
it('throws for unknown job type', async () => {
const job = {
id: 'j3',
name: 'unknown-job',
data: {},
} as any;
await expect(processor.process(job)).rejects.toThrow(
'Unknown job type: unknown-job',
);
});
});
+123
View File
@@ -0,0 +1,123 @@
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
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';
/**
* Predictions Processor
* Handles heavy AI computations in background via HTTP calls to AI Engine
*/
@Processor(PREDICTIONS_QUEUE)
export class PredictionsProcessor extends WorkerHost {
private readonly logger = new Logger(PredictionsProcessor.name);
private readonly aiEngineUrl: string;
constructor() {
super();
// Default to container service URL
this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000';
}
async process(job: Job<any, any, string>): Promise<any> {
this.logger.debug(`Processing job ${job.id}: ${job.name}`);
switch (job.name) {
case PredictionJobType.PREDICT_MATCH:
return this.handlePredictMatch(job.data as PredictMatchJobData);
case PredictionJobType.SMART_COUPON:
return this.handleSmartCoupon(job.data as SmartCouponJobData);
default:
throw new Error(`Unknown job type: ${job.name}`);
}
}
/**
* Handle Single Match Prediction
* HTTP POST /v20plus/analyze/:id
*/
private async handlePredictMatch(data: PredictMatchJobData): Promise<any> {
const { matchId } = data;
this.logger.log(`🤖 AI Engine: Predicting match ${matchId}...`);
try {
const response = await axios.post(
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
{},
{ timeout: 30000 },
);
return response.data;
} catch (error) {
throw this.mapAxiosError(error, matchId, 'predict');
}
}
/**
* Handle Smart Coupon Generation
* HTTP POST /v20plus/coupon
*/
private async handleSmartCoupon(data: SmartCouponJobData): Promise<any> {
const { matchIds, strategy } = data;
this.logger.log(`🎫 AI Engine: Generating ${strategy} Coupon...`);
try {
const response = await axios.post(
`${this.aiEngineUrl}/v20plus/coupon`,
{
match_ids: matchIds,
strategy,
max_matches: data.options?.maxMatches,
min_confidence: data.options?.minConfidence,
},
{ timeout: 60000 },
);
return response.data;
} catch (error) {
throw this.mapAxiosError(error, matchIds.join(','), 'smart-coupon');
}
}
private mapAxiosError(
error: unknown,
identifier: string,
flow: 'predict' | 'smart-coupon',
): Error {
if (!axios.isAxiosError(error)) {
return error instanceof Error
? error
: new Error(`AI_ENGINE_UNKNOWN|${flow}|Unknown error`);
}
const status = error.response?.status;
const detail = error.response?.data?.detail || error.message;
const code = error.code || '';
if (status === 502) {
this.logger.error(`AI Engine 502 (${flow}:${identifier}): ${detail}`);
return new Error(`AI_ENGINE_502|${flow}|${detail}`);
}
if (status === 504) {
this.logger.error(`AI Engine 504 (${flow}:${identifier}): ${detail}`);
return new Error(`AI_ENGINE_504|${flow}|${detail}`);
}
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}`,
);
return new Error(`AI_ENGINE_ERROR|${flow}|${detail}`);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import {
PREDICTIONS_QUEUE,
PredictionJobType,
PredictMatchJobData,
SmartCouponJobData,
} from './predictions.types';
@Injectable()
export class PredictionsQueue {
private readonly logger = new Logger(PredictionsQueue.name);
constructor(
@InjectQueue(PREDICTIONS_QUEUE)
public readonly queue: Queue,
) {}
/**
* Add a single match prediction job
*/
async addPredictMatchJob(data: PredictMatchJobData) {
this.logger.debug(`Adding prediction job for match: ${data.matchId}`);
return this.queue.add(PredictionJobType.PREDICT_MATCH, data, {
priority: 1, // High priority
});
}
/**
* Add a smart coupon generation job
*/
async addSmartCouponJob(data: SmartCouponJobData) {
this.logger.debug(
`Adding smart coupon job: ${data.strategy} (${data.matchIds.length} matches)`,
);
return this.queue.add(PredictionJobType.SMART_COUPON, data, {
priority: 5, // Lower priority than single predictions
});
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Prediction Queue Types
* Senior Level Strict Typing
*/
export const PREDICTIONS_QUEUE = 'predictions-queue';
export enum PredictionJobType {
PREDICT_MATCH = 'predict-match',
SMART_COUPON = 'smart-coupon',
}
export interface PredictMatchJobData {
matchId: string;
forceUpdate?: boolean;
}
export interface SmartCouponJobData {
matchIds: string[];
strategy: string;
options?: {
maxMatches?: number;
minConfidence?: number;
};
}
export type PredictionJob =
| { type: PredictionJobType.PREDICT_MATCH; data: PredictMatchJobData }
| { type: PredictionJobType.SMART_COUPON; data: SmartCouponJobData };
@@ -0,0 +1,114 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../database/prisma.service';
@Injectable()
export class AiFeatureStoreService {
private readonly logger = new Logger(AiFeatureStoreService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Bir maç için AI özelliklerini hesaplar ve 'match_ai_features' tablosuna yazar.
* Bu metod Feeder yeni veri çektiğinde tetiklenmelidir.
*/
async calculateAndSaveFeatures(matchId: string): Promise<void> {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
include: {
homeTeam: {
include: { homeMatches: { take: 5, orderBy: { mstUtc: 'desc' } } },
},
awayTeam: {
include: { awayMatches: { take: 5, orderBy: { mstUtc: 'desc' } } },
},
},
});
if (!match || !match.homeTeam || !match.awayTeam) return;
// 1. Form Score Calculation (0-100)
// Son 5 maçtaki galibiyet, beraberlik ve atılan gollerin ağırlıklı ortalaması
const homeForm = this.calculateFormScore(match.homeTeam.homeMatches);
const awayForm = this.calculateFormScore(match.awayTeam.awayMatches);
// 2. ELO — Read from team_elo_ratings table (populated by AI Engine compute_elo.py)
const homeElo = match.homeTeamId
? await this.getTeamElo(match.homeTeamId)
: 1500.0;
const awayElo = match.awayTeamId
? await this.getTeamElo(match.awayTeamId)
: 1500.0;
// 3. Missing Player Impact (Sakat/Cezalı etkisi)
// Feeder'dan gelen lineups verisindeki eksik as oyuncuları analiz etmeliyiz.
// Şimdilik 0.0 (Etkisiz) olarak set ediyoruz, ilerde Lineup analizi buraya eklenecek.
const missingImpact = 0.0;
// 4. Save to Feature Store
await this.prisma.footballAiFeature.upsert({
where: { matchId },
update: {
homeElo,
awayElo,
homeFormScore: homeForm,
awayFormScore: awayForm,
missingPlayersImpact: missingImpact,
updatedAt: new Date(),
},
create: {
matchId,
homeElo,
awayElo,
homeFormScore: homeForm,
awayFormScore: awayForm,
missingPlayersImpact: missingImpact,
},
});
this.logger.debug(
`Features calculated for match ${matchId} (Home Form: ${homeForm}, Away Form: ${awayForm})`,
);
}
/**
* Form Puanı Hesaplama Algoritması (V17 Simplified)
* W=30, D=10, L=0 puan. + Gol başına 5 puan (max 15).
* Toplam skor 0-100 arasına normalize edilir.
*/
private calculateFormScore(matches: any[]): number {
if (!matches || matches.length === 0) return 50; // Nötr form
let totalPoints = 0;
const maxPoints = matches.length * 45; // Max olası puan (30win + 15goal)
for (const m of matches) {
// Skor kontrolü (bazı maçlar oynanmamış olabilir)
if (m.scoreHome === null || m.scoreAway === null) continue;
const isWin = m.scoreHome > m.scoreAway; // Home team context
const isDraw = m.scoreHome === m.scoreAway;
if (isWin) totalPoints += 30;
else if (isDraw) totalPoints += 10;
const goals = Math.min(m.scoreHome, 3); // Max 3 gol katkısı
totalPoints += goals * 5;
}
// Normalize to 0-100
// Eğer hiç maç oynanmadıysa yine 50 dön.
return matches.length > 0 ? (totalPoints / maxPoints) * 100 : 50;
}
/**
* team_elo_ratings tablosundan takımın güncel ELO puanını okur.
* Kayıt yoksa varsayılan 1500.0 döner.
*/
private async getTeamElo(teamId: string): Promise<number> {
const row = await this.prisma.teamEloRating.findUnique({
where: { teamId },
select: { overallElo: true },
});
return row?.overallElo ?? 1500.0;
}
}
@@ -0,0 +1,109 @@
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.
KURALLAR:
- Türkçe yaz
- Maximum 250 karakter (X/Twitter uyumlu)
- Emoji kullan ama abartma (2-4 emoji yeterli)
- Skor tahminini vurgula
- Güven yüzdesini belirt
- İlgili hashtag'leri ekle (#PremierLeague, #SüperLig vb.)
- KESİNLİKLE "kesin kazanır", "garanti" gibi ifadeler KULLANMA
- "Tahminimiz", "Beklentimiz", "Analizimiz" gibi ifadeler kullan
- Farklı maçlar için farklı tarzda yaz, tekdüze olma
- Son satıra her zaman hashtag'leri koy`;
@Injectable()
export class CaptionGeneratorService {
private readonly logger = new Logger(CaptionGeneratorService.name);
constructor(private readonly geminiService: GeminiService) {}
/**
* Generate a social media caption for a match prediction using Gemini AI.
*/
async generateCaption(card: PredictionCardDto): Promise<string> {
if (!this.geminiService.isAvailable()) {
this.logger.warn('Gemini not available, using template caption');
return this.generateFallbackCaption(card);
}
const prompt = this.buildPrompt(card);
try {
const { text } = await this.geminiService.generateText(prompt, {
systemPrompt: SYSTEM_PROMPT,
temperature: 0.8,
maxTokens: 300,
});
// Ensure hashtags are present
const caption = this.ensureHashtags(text, card);
this.logger.log(
`Caption generated for ${card.homeTeam} vs ${card.awayTeam}`,
);
return caption;
} catch (error) {
this.logger.error('Gemini caption generation failed', error);
return this.generateFallbackCaption(card);
}
}
private buildPrompt(card: PredictionCardDto): string {
const topPicksText = card.topPicks
.map(
(p, i) =>
`${i + 1}. ${p.market} (${p.marketEn}) — ${p.pick} — Güven: %${p.confidence} — Oran: ${p.odds}`,
)
.join('\n');
return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur:
MAÇ: ${card.homeTeam} vs ${card.awayTeam}
LİG: ${card.leagueName}
TARİH: ${card.matchDate}
İLK YARI SKOR TAHMİNİ: ${card.htScore}
MAÇ SONU SKOR TAHMİNİ: ${card.ftScore}
SKOR GÜVEN: %${card.scoreConfidence}
RİSK SEVİYESİ: ${card.riskLevel}
EN İ TAHMİNLER:
${topPicksText}
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('#')) {
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, '');
text += `\n\n#${leagueTag} #${homeTag} #${awayTag}`;
}
return text.trim();
}
/**
* Fallback caption when Gemini is not available.
*/
private generateFallbackCaption(card: PredictionCardDto): string {
const topPick = card.topPicks[0];
const leagueTag = card.leagueName
.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})` : ''}
#${leagueTag} #SuggestBet #Bahis`.trim();
}
}
@@ -0,0 +1,60 @@
/**
* Prediction Card DTO
*
* Typed data structure for rendering match prediction cards
* and generating social media captions.
*/
export interface TopPick {
/** Market name in Turkish, e.g. "Üst 2.5 Gol" */
market: string;
/** Market name in English, e.g. "Over 2.5" */
marketEn: string;
/** Pick label, e.g. "Üst" */
pick: string;
/** Confidence 0-100 */
confidence: number;
/** Odds value */
odds: number;
}
export interface PredictionCardDto {
// ─── Match Info ───
matchId: string;
homeTeam: string;
awayTeam: string;
homeLogo: string;
awayLogo: string;
leagueName: string;
leagueLogo?: string;
/** Formatted date, e.g. "01 Mar 2026 - 21:00" */
matchDate: string;
// ─── Score Predictions ───
/** HT score, e.g. "1-0" */
htScore: string;
/** FT score, e.g. "2-1" */
ftScore: string;
/** Overall confidence 0-100 */
scoreConfidence: number;
// ─── Top 3 Best Bets ───
topPicks: TopPick[];
// ─── Risk ───
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
// ─── Raw prediction JSON (for Gemini caption) ───
rawPrediction?: Record<string, any>;
}
export interface SocialPostResult {
matchId: string;
imagePath: string;
caption: string;
twitterPostId?: string;
facebookPostId?: string;
instagramPostId?: string;
postedAt: Date;
errors?: string[];
}
@@ -0,0 +1,462 @@
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',
);
onModuleInit() {
// Ensure output directory exists
if (!fs.existsSync(this.outputDir)) {
fs.mkdirSync(this.outputDir, { recursive: true });
}
}
/**
* Render a prediction card to a PNG image using Canvas API.
* Returns the file path of the generated image.
*/
async renderCard(card: PredictionCardDto): Promise<string> {
const fileName = `prediction_${card.matchId}_${Date.now()}.png`;
const filePath = path.join(this.outputDir, fileName);
try {
this.logger.log(
`🎨 Rendering canvas for ${card.homeTeam} vs ${card.awayTeam}...`,
);
await this.drawCanvas(card, filePath);
this.logger.log(`✅ Card rendered to ${fileName}`);
return filePath;
} catch (error) {
this.logger.error(`Failed to render canvas card: ${error.message}`);
throw error;
}
}
/**
* Load a team logo image. Handles:
* 1. Local file path (e.g., /uploads/teams/xxx.png public/uploads/teams/xxx.png)
* 2. Full HTTP URL (e.g., https://cdn.example.com/logo.png)
* 3. Mackolik CDN fallback using team slug from path
*/
private async downloadImage(url: string) {
if (!url) return null;
try {
// Case 1: Local relative path → read from public/ directory
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);
}
// Local file not found → try as full URL via APP_BASE_URL
this.logger.debug(
`Local file not found: ${localPath}, trying remote...`,
);
}
// Case 2: Full HTTP/HTTPS URL → fetch directly
if (url.startsWith('http')) {
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 5000,
});
return await loadImage(response.data);
}
this.logger.warn(`Could not resolve logo path: ${url}`);
return null;
} catch (error) {
this.logger.warn(`Could not load image from ${url}: ${error.message}`);
return null;
}
}
private fillRoundRect(
ctx: any,
x: number,
y: number,
width: number,
height: number,
radius: number,
) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.fill();
}
private strokeRoundRect(
ctx: any,
x: number,
y: number,
width: number,
height: number,
radius: number,
) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.stroke();
}
private async drawCanvas(
data: PredictionCardDto,
outPath: string,
): Promise<void> {
const width = 1080;
const height = 1920;
const canvas = createCanvas(width, height);
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');
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
// Watermark
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';
const wmLine =
'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);
}
ctx.restore();
// Settings
const paddingX = 80;
// Header
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.fillText(data.matchDate, width - paddingX, 120);
// Teams Section
let currentY = 280;
const [homeImg, awayImg] = await Promise.all([
this.downloadImage(data.homeLogo),
this.downloadImage(data.awayLogo),
]);
if (homeImg) ctx.drawImage(homeImg, width / 4 - 100, currentY, 200, 200);
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);
currentY += 250;
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.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)');
ctx.fillStyle = grad;
ctx.fillRect(
paddingX,
y - 2,
(width - 2 * paddingX - txtWidth - 40) / 2,
3,
);
ctx.fillRect(
width / 2 + txtWidth / 2 + 20,
y - 2,
(width - 2 * paddingX - txtWidth - 40) / 2,
3,
);
};
drawSectionTitle(currentY, 'SKOR TAHMİNİ / SCORE PREDICTION');
// Scores
currentY += 80;
const scoreBoxWidth = 380;
const scoreBoxHeight = 220;
const htX = width / 2 - scoreBoxWidth - 24;
const ftX = width / 2 + 24;
// HT Box
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.fillText(data.htScore, htX + scoreBoxWidth / 2, currentY + 160);
// FT Box
const ftGrad = ctx.createLinearGradient(
ftX,
currentY,
ftX + scoreBoxWidth,
currentY + scoreBoxHeight,
);
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)';
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);
// Score text gradient
const txtGrad = ctx.createLinearGradient(
ftX,
currentY + 100,
ftX,
currentY + 160,
);
txtGrad.addColorStop(0, '#9b6fff');
txtGrad.addColorStop(1, '#00c8ff');
ctx.fillStyle = txtGrad;
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)';
this.fillRoundRect(
ctx,
ftX + scoreBoxWidth / 2 - 80,
currentY + scoreBoxHeight - 20,
160,
40,
20,
);
this.strokeRoundRect(
ctx,
ftX + scoreBoxWidth / 2 - 80,
currentY + scoreBoxHeight - 20,
160,
40,
20,
);
ctx.fillStyle = '#b89dff';
ctx.font = '800 20px sans-serif';
ctx.fillText(
`🎯 %${data.scoreConfidence}`,
ftX + scoreBoxWidth / 2,
currentY + scoreBoxHeight + 7,
);
// Divider: Picks
currentY += scoreBoxHeight + 100;
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)';
this.fillRoundRect(
ctx,
paddingX,
currentY,
width - 2 * paddingX,
100,
16,
);
this.strokeRoundRect(
ctx,
paddingX,
currentY,
width - 2 * paddingX,
100,
16,
);
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.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.fillText(
`(${pick.marketEn})`,
paddingX + 80 + marketWidth + 10,
currentY + 43,
);
// Pick Bar bg
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
const barMaxWidth = width - 2 * paddingX - 220;
this.fillRoundRect(ctx, paddingX + 80, currentY + 65, barMaxWidth, 12, 6);
// Pick Bar fill
const fillWidth = (pick.confidence / 100) * barMaxWidth;
const barGrad = ctx.createLinearGradient(
paddingX + 80,
0,
paddingX + 80 + barMaxWidth,
0,
);
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.fillText(`%${pick.confidence}`, width - paddingX - 30, currentY + 58);
currentY += 124;
});
// 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);
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)';
break;
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)';
break;
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)';
}
const riskText = `RISK: ${data.riskLevel}`;
ctx.font = '800 20px sans-serif';
const riskWidth = ctx.measureText(riskText).width;
ctx.fillStyle = riskBg;
ctx.strokeStyle = riskBorder;
this.fillRoundRect(
ctx,
width - paddingX - riskWidth - 48,
currentY - 26,
riskWidth + 48,
44,
22,
);
this.strokeRoundRect(
ctx,
width - paddingX - riskWidth - 48,
currentY - 26,
riskWidth + 48,
44,
22,
);
ctx.fillStyle = riskColor;
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');
fs.writeFileSync(outPath, buffer);
}
/**
* Get the web-accessible URL for a rendered image.
*/
getImageUrl(filePath: string): string {
const relativePath = path.relative(
path.join(process.cwd(), 'public'),
filePath,
);
return `/${relativePath.replace(/\\/g, '/')}`;
}
}
+180
View File
@@ -0,0 +1,180 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
@Injectable()
export class MetaService {
private readonly logger = new Logger(MetaService.name);
private readonly pageAccessToken: string;
private readonly pageId: string;
private readonly igUserId: string;
private readonly isEnabled: boolean;
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.isEnabled = !!(this.pageAccessToken && this.pageId);
if (this.isEnabled) {
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',
);
}
}
get facebookAvailable(): boolean {
return this.isEnabled;
}
get instagramAvailable(): boolean {
return this.isEnabled && !!this.igUserId;
}
// ═══════════════════════════════════════════════════════════════════════════
// FACEBOOK
// ═══════════════════════════════════════════════════════════════════════════
/**
* Post a photo to a Facebook Page.
*
* @param message - Post caption
* @param imageUrl - Publicly accessible image URL
* @returns Facebook post ID
*/
async postToFacebook(
message: string,
imageUrl: string,
): Promise<string | null> {
if (!this.facebookAvailable) {
this.logger.warn('Facebook not available, skipping post');
return null;
}
try {
const response = await axios.post(
`${this.graphApiBase}/${this.pageId}/photos`,
{
url: imageUrl,
message,
access_token: this.pageAccessToken,
},
);
const postId = response.data?.id;
this.logger.log(`✅ Facebook post published: ${postId}`);
return postId || null;
} catch (error) {
this.logger.error(
`❌ Facebook post failed: ${error.response?.data?.error?.message || error.message}`,
);
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// INSTAGRAM
// ═══════════════════════════════════════════════════════════════════════════
/**
* Post a photo to Instagram Business/Creator account.
*
* Two-step process:
* 1. Create media container with image_url
* 2. Publish the container
*
* @param caption - Post caption (max 2200 chars)
* @param imageUrl - Publicly accessible JPEG image URL
* @returns Instagram media ID
*/
async postToInstagram(
caption: string,
imageUrl: string,
): Promise<string | null> {
if (!this.instagramAvailable) {
this.logger.warn('Instagram not available, skipping post');
return null;
}
try {
// Step 1: Create media container
const containerResponse = await axios.post(
`${this.graphApiBase}/${this.igUserId}/media`,
{
image_url: imageUrl,
caption,
access_token: this.pageAccessToken,
},
);
const containerId = containerResponse.data?.id;
if (!containerId) {
throw new Error('No container ID returned');
}
// Wait for container processing (IG needs a few seconds)
await this.waitForContainerReady(containerId);
// Step 2: Publish
const publishResponse = await axios.post(
`${this.graphApiBase}/${this.igUserId}/media_publish`,
{
creation_id: containerId,
access_token: this.pageAccessToken,
},
);
const mediaId = publishResponse.data?.id;
this.logger.log(`✅ Instagram post published: ${mediaId}`);
return mediaId || null;
} catch (error) {
this.logger.error(
`❌ Instagram post failed: ${error.response?.data?.error?.message || error.message}`,
);
return null;
}
}
/**
* Wait for Instagram container to be ready for publishing.
*/
private async waitForContainerReady(
containerId: string,
maxWaitMs = 30000,
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
try {
const response = await axios.get(
`${this.graphApiBase}/${containerId}`,
{
params: {
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');
}
} catch (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');
}
}
@@ -0,0 +1,25 @@
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';
@ApiTags('Social Poster')
@ApiBearerAuth()
@UseGuards(RolesGuard)
@Roles('admin')
@Controller('social-poster')
export class SocialPosterController {
constructor(private readonly socialPosterService: SocialPosterService) {}
@Get('preview/:matchId')
async previewCard(@Param('matchId') matchId: string) {
return this.socialPosterService.renderPreview(matchId);
}
@Post('post/:matchId')
async postMatch(@Param('matchId') matchId: string) {
return this.socialPosterService.manualPost(matchId);
}
}
@@ -0,0 +1,31 @@
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 { SocialPosterController } from './social-poster.controller';
/**
* Social Poster Module
*
* Automates the generation of prediction cards and social media posting
* to X (Twitter), Facebook, and Instagram for upcoming matches.
*/
@Module({
imports: [ConfigModule, ScheduleModule.forRoot()],
controllers: [SocialPosterController],
providers: [
SocialPosterService,
ImageRendererService,
CaptionGeneratorService,
TwitterService,
MetaService,
],
exports: [SocialPosterService],
})
export class SocialPosterModule {}
@@ -0,0 +1,395 @@
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 {
PredictionCardDto,
TopPick,
SocialPostResult,
} from './dto/prediction-card.dto';
// Top leagues loaded once
const TOP_LEAGUES_PATH = path.join(process.cwd(), 'top_leagues.json');
@Injectable()
export class SocialPosterService {
private readonly logger = new Logger(SocialPosterService.name);
private readonly aiEngineUrl: string;
private readonly appBaseUrl: string;
private readonly isEnabled: boolean;
private readonly postedMatchIds = new Set<string>();
private topLeagueIds: Set<string> = new Set();
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
private readonly imageRenderer: ImageRendererService,
private readonly captionGenerator: CaptionGeneratorService,
private readonly twitterService: TwitterService,
private readonly metaService: MetaService,
) {
this.aiEngineUrl =
this.configService.get<string>('AI_ENGINE_URL') ||
'http://localhost:8000';
this.appBaseUrl =
this.configService.get<string>('APP_BASE_URL') || 'http://localhost:3000';
this.isEnabled =
this.configService.get<string>('SOCIAL_POSTER_ENABLED') === 'true';
this.loadTopLeagues();
}
private loadTopLeagues() {
try {
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');
}
}
/**
* Cron: Every 10 minutes, check for upcoming matches.
* Posts predictions 30 minutes before kickoff.
*/
@Cron('*/10 * * * *')
async checkAndPostUpcomingMatches() {
if (!this.isEnabled) return;
try {
const matches = await this.getUpcomingMatches(25, 40); // 25-40 min window
this.logger.log(
`📅 Found ${matches.length} upcoming matches in the window`,
);
for (const match of matches) {
if (this.postedMatchIds.has(match.id)) continue;
try {
await this.predictAndPost(match);
this.postedMatchIds.add(match.id);
// Cleanup: remove old IDs (keep last 500)
if (this.postedMatchIds.size > 500) {
const arr = Array.from(this.postedMatchIds);
arr
.slice(0, arr.length - 500)
.forEach((id) => this.postedMatchIds.delete(id));
}
} catch (error) {
this.logger.error(
`Failed to process match ${match.id}: ${error.message}`,
);
}
// Small delay between posts to avoid rate limits
await new Promise((resolve) => setTimeout(resolve, 3000));
}
} catch (error) {
this.logger.error(`Cron job failed: ${error.message}`);
}
}
/**
* Get matches starting in [minMinutes, maxMinutes] from now.
* Filtered by top leagues.
*/
private async getUpcomingMatches(
minMinutes: number,
maxMinutes: number,
): Promise<any[]> {
const now = Date.now();
const minTime = now + minMinutes * 60 * 1000;
const maxTime = now + maxMinutes * 60 * 1000;
const matches = await this.prisma.liveMatch.findMany({
where: {
sport: 'football',
leagueId: { in: Array.from(this.topLeagueIds) },
mstUtc: {
gte: minTime,
lte: maxTime,
},
},
include: {
homeTeam: true,
awayTeam: true,
league: true,
},
});
return matches;
}
/**
* Full pipeline: Predict Render Image Generate Caption Post.
*/
async predictAndPost(match: any): Promise<SocialPostResult> {
const matchId = match.id;
this.logger.log(
`🚀 Processing: ${match.homeTeam?.name} vs ${match.awayTeam?.name}`,
);
// Step 1: Get prediction from AI Engine
const prediction = await this.getPrediction(matchId);
if (!prediction) {
throw new Error('No prediction returned from AI Engine');
}
// Step 2: Build prediction card data
const card = this.buildCardFromPrediction(match, prediction);
// Step 3: Render image
const imagePath = await this.imageRenderer.renderCard(card);
const imageUrl = `${this.appBaseUrl}${this.imageRenderer.getImageUrl(imagePath)}`;
// Step 4: Generate caption via Gemini
const caption = await this.captionGenerator.generateCaption(card);
// Step 5: Post to all platforms
const result: SocialPostResult = {
matchId,
imagePath,
caption,
postedAt: new Date(),
errors: [],
};
// Twitter
try {
result.twitterPostId =
(await this.twitterService.postWithImage(caption, imagePath)) ||
undefined;
} catch (error) {
result.errors!.push(`Twitter: ${error.message}`);
}
// Facebook
try {
result.facebookPostId =
(await this.metaService.postToFacebook(caption, imageUrl)) || undefined;
} catch (error) {
result.errors!.push(`Facebook: ${error.message}`);
}
// Instagram
try {
result.instagramPostId =
(await this.metaService.postToInstagram(caption, imageUrl)) ||
undefined;
} catch (error) {
result.errors!.push(`Instagram: ${error.message}`);
}
this.logger.log(
`✅ Posted: ${match.homeTeam?.name} vs ${match.awayTeam?.name} ` +
`[TW: ${result.twitterPostId ? '✅' : '❌'}, ` +
`FB: ${result.facebookPostId ? '✅' : '❌'}, ` +
`IG: ${result.instagramPostId ? '✅' : '❌'}]`,
);
return result;
}
/**
* Call AI Engine's V20+ prediction endpoint directly.
*/
private async getPrediction(matchId: string): Promise<any> {
try {
const response = await axios.post(
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
null,
{ timeout: 30000 },
);
return response.data;
} catch (error) {
this.logger.error(`AI Engine request failed: ${error.message}`);
return null;
}
}
/**
* Build a PredictionCardDto from the raw AI prediction + match data.
* Maps the V20+ response structure to our card DTO.
*/
private buildCardFromPrediction(
match: any,
prediction: any,
): PredictionCardDto {
// V20+ returns score_prediction.ft / .ht
const score = prediction.score_prediction || {};
const htScore = score.ht || '0-0';
const ftScore = score.ft || '1-1';
// Extract best bets from bet_summary array
const topPicks = this.extractTopPicks(prediction);
// Match date formatting
const matchDate = this.formatMatchDate(match.mstUtc);
// Score confidence from main_pick or scenario_top5
const mainPick = prediction.main_pick || {};
const scoreConfidence = Math.round(
mainPick.confidence || mainPick.raw_confidence || 50,
);
return {
matchId: match.id,
homeTeam:
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 || '',
matchDate,
htScore,
ftScore,
scoreConfidence,
topPicks,
riskLevel: prediction.risk?.level || 'MEDIUM',
rawPrediction: prediction,
};
}
/**
* Extract top 3 picks sorted by confidence from the V20+ bet_summary array.
*/
private extractTopPicks(prediction: any): TopPick[] {
const betSummary: any[] = prediction.bet_summary || [];
// 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' },
};
const candidates: TopPick[] = betSummary.map((bet) => {
const labels = marketLabels[bet.market] || {
tr: bet.market,
en: bet.market,
};
return {
market: `${labels.tr}: ${bet.pick}`,
marketEn: `${labels.en}: ${bet.pick}`,
pick: bet.pick,
confidence: Math.round(bet.raw_confidence || bet.confidence || 0),
odds: bet.odds || 0,
};
});
// Sort by confidence and return top 3
candidates.sort((a, b) => b.confidence - a.confidence);
return candidates.slice(0, 3);
}
/**
* Convert relative logo paths to full HTTP URLs.
* On the deployed server, logos exist at public/uploads/teams/...
* Locally during dev, we fetch them from the deployed server via APP_BASE_URL.
*/
private resolveLogoUrl(logoUrl: string): string {
if (!logoUrl) return '';
// Already a full URL
if (logoUrl.startsWith('http')) return logoUrl;
// Relative path → check local first, otherwise make full URL
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}`;
}
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',
];
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');
return `${day} ${month} ${year} - ${hour}:${min}`;
}
/**
* Manual trigger for testing: predict and post for a specific match.
*/
async manualPost(matchId: string): Promise<SocialPostResult> {
const match = await this.prisma.liveMatch.findUnique({
where: { id: matchId },
include: {
homeTeam: true,
awayTeam: true,
league: true,
},
});
if (!match) {
throw new Error(`Match ${matchId} not found`);
}
return this.predictAndPost(match);
}
/**
* Manual trigger: render only (no posting) for preview/testing.
*/
async renderPreview(
matchId: string,
): Promise<{ imagePath: string; card: PredictionCardDto; caption: string }> {
const match = await this.prisma.liveMatch.findUnique({
where: { id: matchId },
include: {
homeTeam: true,
awayTeam: true,
league: true,
},
});
if (!match) {
throw new Error(`Match ${matchId} not found`);
}
const prediction = await this.getPrediction(matchId);
if (!prediction) {
throw new Error('No prediction returned from AI Engine');
}
const card = this.buildCardFromPrediction(match, prediction);
const imagePath = await this.imageRenderer.renderCard(card);
const caption = await this.captionGenerator.generateCaption(card);
return { imagePath, card, caption };
}
}
@@ -0,0 +1,87 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
@Injectable()
export class TwitterService {
private readonly logger = new Logger(TwitterService.name);
private client: any = null;
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 accessSecret = this.configService.get<string>(
'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',
);
}
}
private async initClient(
apiKey: string,
apiSecret: string,
accessToken: string,
accessSecret: string,
) {
try {
const { TwitterApi } = await import('twitter-api-v2');
this.client = new TwitterApi({
appKey: apiKey,
appSecret: apiSecret,
accessToken,
accessSecret,
});
this.isEnabled = true;
this.logger.log('✅ Twitter API client initialized');
} catch (error) {
this.logger.error('Failed to initialize Twitter client', error);
}
}
get available(): boolean {
return this.isEnabled && this.client !== null;
}
/**
* Post a tweet with an image.
*
* @param text - Tweet text
* @param imagePath - Absolute path to the image file
* @returns Tweet ID
*/
async postWithImage(text: string, imagePath: string): Promise<string | null> {
if (!this.available) {
this.logger.warn('Twitter not available, skipping post');
return null;
}
try {
// Step 1: Upload media via v1.1
const mediaData = fs.readFileSync(imagePath);
const mediaId = await this.client.v1.uploadMedia(mediaData, {
mimeType: 'image/png',
});
// Step 2: Create tweet via v2
const tweet = await this.client.v2.tweet({
text,
media: { media_ids: [mediaId] },
});
const tweetId = tweet.data?.id;
this.logger.log(`✅ Tweet posted: ${tweetId}`);
return tweetId || null;
} catch (error) {
this.logger.error(`❌ Twitter post failed: ${error.message}`);
return null;
}
}
}
+256
View File
@@ -0,0 +1,256 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
IsDateString,
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} 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)' })
@IsInt()
@Min(1)
@Max(15)
matchOrder: number;
@ApiProperty({ example: 'Blackpool' })
@IsString()
homeTeamName: string;
@ApiProperty({ example: 'Burton Albion' })
@IsString()
awayTeamName: string;
@ApiPropertyOptional({ example: 'İN1' })
@IsOptional()
@IsString()
leagueName?: string;
@ApiPropertyOptional({ example: '2026-03-28T18:00:00' })
@IsOptional()
@IsDateString()
kickoffTime?: string;
@ApiPropertyOptional({ description: 'Link to existing match ID' })
@IsOptional()
@IsString()
matchId?: string;
}
// ─── Create Bulletin DTO ───
export class CreateBulletinDto {
@ApiProperty({ example: 333, description: 'Game cycle number from API' })
@IsInt()
gameCycleNo: number;
@ApiPropertyOptional({ example: '27-29 Mart' })
@IsOptional()
@IsString()
programName?: string;
@ApiPropertyOptional({ example: '2025-2026' })
@IsOptional()
@IsString()
season?: string;
@ApiPropertyOptional({ example: '2026-03-22T10:00:00' })
@IsOptional()
@IsDateString()
payinBeginDate?: string;
@ApiPropertyOptional({ example: '2026-03-27T20:55:00' })
@IsOptional()
@IsDateString()
payinEndDate?: string;
@ApiProperty({ type: [BulletinMatchItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => BulletinMatchItemDto)
matches: BulletinMatchItemDto[];
}
// ─── Update Results DTO ───
export class MatchResultDto {
@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';
@ApiPropertyOptional({ default: false })
@IsOptional()
isCancelled?: boolean;
@ApiPropertyOptional({ enum: ['HOME', 'DRAW', 'AWAY'] })
@IsOptional()
@IsEnum({ HOME: 'HOME', DRAW: 'DRAW', AWAY: 'AWAY' })
drawResult?: 'HOME' | 'DRAW' | 'AWAY';
}
export class UpdateResultsDto {
@ApiProperty({ type: [MatchResultDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => MatchResultDto)
results: MatchResultDto[];
@ApiPropertyOptional({ description: '15 bilen sayısı' })
@IsOptional()
@IsInt()
winners15?: number;
@ApiPropertyOptional({ description: '15 bilen ödülü (TL)' })
@IsOptional()
@IsNumber()
prize15?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
winners14?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
prize14?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
winners13?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
prize13?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
winners12?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
prize12?: number;
@ApiPropertyOptional({ description: 'Sonraki haftaya devir' })
@IsOptional()
@IsNumber()
rolloverNext?: number;
}
// ─── Generate Columns DTO ───
export type TotoSelectionType = '1' | 'X' | '2';
export class TotoMatchSelection {
@ApiProperty({ example: 1 })
@IsInt()
@Min(1)
@Max(15)
matchOrder: number;
@ApiProperty({
type: [String],
example: ['1', 'X'],
description: 'Seçimler: 1=Ev, X=Beraberlik, 2=Deplasman',
})
@IsArray()
selections: TotoSelectionType[];
}
export class GenerateColumnsDto {
@ApiProperty({ description: 'Bulletin ID' })
@IsString()
bulletinId: string;
@ApiProperty({ type: [TotoMatchSelection] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => TotoMatchSelection)
matchSelections: TotoMatchSelection[];
@ApiPropertyOptional({
example: 'FULL_SYSTEM',
description: 'FULL_SYSTEM | REDUCED_SYSTEM | MANUAL',
})
@IsOptional()
@IsString()
strategy?: string;
@ApiPropertyOptional({
example: 100,
description: 'Max kolon sayısı (reduced system için)',
})
@IsOptional()
@IsInt()
maxColumns?: number;
}
// ─── Generate AI Prediction DTO ───
export class GenerateSporTotoPredictionDto {
@ApiProperty({ description: 'Bulletin ID' })
@IsString()
bulletinId: string;
@ApiPropertyOptional({
example: 'BALANCED',
enum: ['CONSERVATIVE', 'BALANCED', 'AGGRESSIVE', 'FORMULA_6PCT'],
description:
'CONSERVATIVE(100 col), BALANCED(500), AGGRESSIVE(2500), FORMULA_6PCT(%6 sampling)',
})
@IsOptional()
@IsString()
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.',
})
@IsOptional()
@IsNumber()
maxBudget?: number;
@ApiPropertyOptional({
example: 200,
description: 'Max kolon sayısı override',
})
@IsOptional()
@IsInt()
maxColumns?: number;
}
// ─── Evaluate Columns DTO ───
export class EvaluateColumnsDto {
@ApiProperty({ description: 'Bulletin ID' })
@IsString()
bulletinId: string;
@ApiProperty({
type: [String],
example: ['11X2X1XX21X1121'],
description: 'Array of 15-char column strings',
})
@IsArray()
@IsString({ each: true })
columns: string[];
}
@@ -0,0 +1,190 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../database/prisma.service';
/**
* Spor Toto Analitik Servisi
* - Havuz dağılım hesabı (%25/%20/%20/%35)
* - Expected Value (EV) hesabı
* - Devir geçmişi ve trend analizi
*/
@Injectable()
export class TotoAnalyticsService {
private readonly logger = new Logger(TotoAnalyticsService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Havuz dağılımını hesapla
* Spor Toto havuz dağılımı:
* %35 15 bilen
* %20 14 bilen
* %20 13 bilen
* %25 12 bilen
*/
calculatePoolDistribution(totalPool: number): {
pool15: number;
pool14: number;
pool13: number;
pool12: number;
} {
return {
pool15: totalPool * 0.35,
pool14: totalPool * 0.2,
pool13: totalPool * 0.2,
pool12: totalPool * 0.25,
};
}
/**
* Expected Value hesaplama
* EV = (Kazanma Olasılığı × Ödül) - Maliyet
*
* 15 maçın tamamını bilme olasılığı (hepsi tek tahmin):
* P = (1/3)^15 1/14,348,907
*/
calculateEV(
poolTotal: number,
rolloverAmount: number,
columnCost: number,
columnCount: number,
): {
totalPool: number;
pool15: number;
probWin15: number;
ev15: number;
totalCost: number;
netEV: number;
} {
const effectivePool = poolTotal + rolloverAmount;
const distribution = this.calculatePoolDistribution(effectivePool);
// Basit olasılık: 1/3^15 (her maç bağımsız, 3 sonuç)
const probSingleColumn = 1 / Math.pow(3, 15); // ~6.97e-8
const probWin15 = 1 - Math.pow(1 - probSingleColumn, columnCount);
const totalCost = columnCost * columnCount;
const ev15 = probWin15 * distribution.pool15 - totalCost;
return {
totalPool: effectivePool,
pool15: distribution.pool15,
probWin15,
ev15,
totalCost,
netEV: ev15,
};
}
/**
* Devir geçmişi ve trend analizi
*/
async getRolloverHistory(limit = 10): Promise<{
history: Array<{
gameCycleNo: number;
programName: string | null;
poolTotal: number | null;
rolloverAmount: number | null;
winners15: number;
prize15: number | null;
}>;
averageRollover: number;
consecutiveRollovers: number;
}> {
const bulletins = await this.prisma.totoBulletin.findMany({
where: { status: 'COMPLETED' },
orderBy: { gameCycleNo: 'desc' },
take: limit,
include: { result: true },
});
const history = bulletins.map((b) => ({
gameCycleNo: b.gameCycleNo,
programName: b.programName,
poolTotal: b.poolTotal,
rolloverAmount: b.rolloverAmount,
winners15: b.result?.winners15 ?? 0,
prize15: b.result?.prize15 ?? null,
}));
// Ortalama devir miktarı
const rollovers = history
.map((h) => h.rolloverAmount ?? 0)
.filter((r) => r > 0);
const averageRollover =
rollovers.length > 0
? rollovers.reduce((a, b) => a + b, 0) / rollovers.length
: 0;
// Ardışık devir sayısı (son kaç haftadır devir var)
let consecutiveRollovers = 0;
for (const h of history) {
if (h.winners15 === 0) {
consecutiveRollovers++;
} else {
break;
}
}
return { history, averageRollover, consecutiveRollovers };
}
/**
* Bülten istatistikleri
*/
async getBulletinStats(bulletinId: string): Promise<{
poolDistribution: {
pool15: number;
pool14: number;
pool13: number;
pool12: number;
} | null;
ev: {
totalPool: number;
pool15: number;
probWin15: number;
ev15: number;
totalCost: number;
netEV: number;
} | null;
rolloverInfo: { averageRollover: number; consecutiveRollovers: number };
}> {
const bulletin = await this.prisma.totoBulletin.findUnique({
where: { id: bulletinId },
});
if (!bulletin) {
return {
poolDistribution: null,
ev: null,
rolloverInfo: { averageRollover: 0, consecutiveRollovers: 0 },
};
}
const poolDistribution = bulletin.poolTotal
? this.calculatePoolDistribution(
bulletin.poolTotal + (bulletin.rolloverAmount ?? 0),
)
: null;
const ev =
bulletin.poolTotal != null
? this.calculateEV(
bulletin.poolTotal,
bulletin.rolloverAmount ?? 0,
1, // birim fiyat
1, // tek kolon bazında
)
: null;
const rolloverInfo = await this.getRolloverHistory(20);
return {
poolDistribution,
ev,
rolloverInfo: {
averageRollover: rolloverInfo.averageRollover,
consecutiveRollovers: rolloverInfo.consecutiveRollovers,
},
};
}
}
@@ -0,0 +1,156 @@
import { Injectable, Logger } from '@nestjs/common';
export interface TotoMatchSelectionInput {
matchOrder: number;
selections: ('1' | 'X' | '2')[];
}
export interface GeneratedColumn {
predictions: string; // "1X2102X112X2101" — 15 chars
}
/**
* Kombinatorik kolon üretim motoru.
* Tam Sistem (Full System): Tüm olası kombinasyonları üretir (2^d × 3^t).
* İndirgenmiş Sistem (Reduced): Belirli bir kapak garantisi ile kolon sayısını azaltır.
*/
@Injectable()
export class TotoCombinatoricsService {
private readonly logger = new Logger(TotoCombinatoricsService.name);
/**
* Tam Sistemli Kolon Üretimi
* Her maç için seçilen tahminlerin tüm olası kombinasyonlarını üretir.
*
* @param matchSelections 15 maç için seçimler
* @returns Tüm olası kolonlar
*/
generateFullSystem(
matchSelections: TotoMatchSelectionInput[],
): GeneratedColumn[] {
// 15 maçlık tam liste oluştur (seçim yapılmayan maçlara default '1' ata)
const selectionsMap = new Map<number, string[]>();
matchSelections.forEach((ms) => {
selectionsMap.set(ms.matchOrder, ms.selections);
});
const orderedSelections: string[][] = [];
for (let i = 1; i <= 15; i++) {
const sel = selectionsMap.get(i);
if (!sel || sel.length === 0) {
orderedSelections.push(['1']); // Default: ev sahibi
} else {
orderedSelections.push(sel);
}
}
// Toplam kolon sayısını hesapla
const totalColumns = orderedSelections.reduce(
(acc, sel) => acc * sel.length,
1,
);
this.logger.debug(
`Full system: generating ${totalColumns} columns from selections`,
);
// Tüm kombinasyonları üret
const columns: GeneratedColumn[] = [];
this.generateCombinations(orderedSelections, 0, '', columns);
return columns;
}
/**
* İndirgenmiş Sistem Kolon Üretimi
* Tam sistemdeki kolonlardan rastgele veya stratejik olarak seçim yapar.
* maxColumns kadar kolon üretir.
*/
generateReducedSystem(
matchSelections: TotoMatchSelectionInput[],
maxColumns: number,
): GeneratedColumn[] {
const fullColumns = this.generateFullSystem(matchSelections);
if (fullColumns.length <= maxColumns) {
return fullColumns;
}
// Fisher-Yates shuffle ile rastgele seçim
const shuffled = [...fullColumns];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
this.logger.debug(
`Reduced system: ${fullColumns.length}${maxColumns} columns`,
);
return shuffled.slice(0, maxColumns);
}
/**
* Kolon maliyetini hesapla
* Spor Toto birim fiyat: 1 TL/kolon (2026 itibarıyla)
*/
calculateCost(columnCount: number, unitPrice = 1): number {
return columnCount * unitPrice;
}
/**
* Kolon sayısını hesapla (sistem üretmeden)
*/
calculateColumnCount(matchSelections: TotoMatchSelectionInput[]): number {
const selectionsMap = new Map<number, string[]>();
matchSelections.forEach((ms) => {
selectionsMap.set(ms.matchOrder, ms.selections);
});
let total = 1;
for (let i = 1; i <= 15; i++) {
const sel = selectionsMap.get(i);
total *= sel && sel.length > 0 ? sel.length : 1;
}
return total;
}
/**
* Kolonları sonuçlarla karşılaştır
* @param columns Kolon tahminleri
* @param results Gerçek sonuçlar (15 karakter: 1/X/2)
* @returns Her kolon için doğru tahmin sayısı
*/
evaluateColumns(
columns: GeneratedColumn[],
results: string,
): { predictions: string; correctCount: number }[] {
return columns.map((col) => {
let correct = 0;
for (let i = 0; i < 15; i++) {
if (col.predictions[i] === results[i]) {
correct++;
}
}
return { predictions: col.predictions, correctCount: correct };
});
}
/**
* Recursive kombinasyon üretici
*/
private generateCombinations(
selections: string[][],
index: number,
current: string,
result: GeneratedColumn[],
): void {
if (index === selections.length) {
result.push({ predictions: current });
return;
}
for (const sel of selections[index]) {
this.generateCombinations(selections, index + 1, current + sel, result);
}
}
}
@@ -0,0 +1,124 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
/**
* Spor Toto API response types
* Source: https://sportotov2.iddaa.com/SporToto
*/
export interface SporTotoApiEvent {
eventNo: number;
eventName: string; // "Blackpool-Burton Albion"
competitionName: string; // "İN1"
eventDate: string; // "2026-03-28T18:00:00"
result: string | null;
winner: string | null;
}
export interface SporTotoApiDividend {
winnerCount15?: number;
dividend15?: number;
winnerCount14?: number;
dividend14?: number;
winnerCount13?: number;
dividend13?: number;
winnerCount12?: number;
dividend12?: number;
}
export interface SporTotoApiResponse {
isSuccess: boolean;
data: {
payinBeginDate: string;
payinEndDate: string;
gameCycleNo: number;
dividends: SporTotoApiDividend | null;
events: SporTotoApiEvent[];
programName: string;
nextDrawExpectedWins: number | null;
} | null;
message: string;
error: string | null;
info: string | null;
dateTime: string | null;
}
@Injectable()
export class TotoFetcherService {
private readonly logger = new Logger(TotoFetcherService.name);
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...');
const response = await axios.get<SporTotoApiResponse>(this.apiUrl, {
timeout: 10000,
headers: {
Accept: 'application/json',
'User-Agent': 'SuggestBet/1.0',
},
});
if (!response.data?.isSuccess || !response.data?.data) {
this.logger.warn(
'Spor Toto API returned unsuccessful response',
response.data?.message,
);
return null;
}
this.logger.log(
`Fetched bulletin: Cycle ${response.data.data.gameCycleNo}${response.data.data.programName} (${response.data.data.events.length} events)`,
);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
this.logger.error(
`Spor Toto API error: ${error.message}`,
error.response?.status,
);
} else {
this.logger.error('Spor Toto fetch failed', error);
}
return null;
}
}
/**
* Parse "Blackpool-Burton Albion" { home: "Blackpool", away: "Burton Albion" }
*/
parseEventName(eventName: string): {
homeTeam: string;
awayTeam: string;
} {
const parts = eventName.split('-');
if (parts.length >= 2) {
return {
homeTeam: parts[0].trim(),
awayTeam: parts.slice(1).join('-').trim(),
};
}
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 {
if (!winner) return null;
switch (winner) {
case '1':
return 'HOME';
case '0':
case 'X':
return 'DRAW';
case '2':
return 'AWAY';
default:
return null;
}
}
}
@@ -0,0 +1,795 @@
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';
// ═══════════ TYPES ═══════════
export type PredictionStrategy =
| 'CONSERVATIVE'
| 'BALANCED'
| 'AGGRESSIVE'
| 'FORMULA_6PCT';
export type TotoSelection = '1' | 'X' | '2';
export interface MatchPredictionAnalysis {
matchOrder: number;
homeTeam: string;
awayTeam: string;
leagueName: string | null;
/** Linked matchId from DB (null if not found) */
linkedMatchId: string | null;
/** AI Engine prediction source */
predictionSource: 'AI_ENGINE' | 'HISTORICAL_FORM' | 'FALLBACK';
/** Raw AI probabilities for each outcome */
probabilities: { home: number; draw: number; away: number };
/** AI confidence (0-100) */
confidence: number;
/** AI's primary pick */
aiPick: TotoSelection;
/** Contrarian-adjusted selections for the coupon */
selections: TotoSelection[];
/** Why this selection was made */
reasoning: string;
/** Contrarian score: how "against the public" this pick is (0-100) */
contrarianScore: number;
}
export interface PredictionResult {
bulletinId: string;
gameCycleNo: number;
strategy: PredictionStrategy;
/** Per-match analysis */
matchAnalyses: MatchPredictionAnalysis[];
/** Generated coupon */
coupon: {
totalColumns: number;
cost: number;
maxCouponLimit: number;
columns: string[];
};
/** EV report */
evReport: {
poolTotal: number;
rolloverAmount: number;
effectivePool: number;
ev15: number;
evPerColumn: number;
recommendation: 'PLAY' | 'WAIT' | 'HIGH_VALUE';
recommendationReason: string;
};
/** System info */
systemInfo: {
singlePicks: number;
doublePicks: number;
triplePicks: number;
formula: string;
};
}
// ═══════════ STRATEGY CONFIGS ═══════════
interface StrategyConfig {
maxColumns: number;
/** Confidence threshold for single pick */
singleThreshold: number;
/** Confidence threshold for double pick (below this → triple) */
doubleThreshold: number;
/** Use formula reduction */
useFormulaReduction: boolean;
/** Formula sampling rate (e.g., 0.06 = %6) */
formulaSamplingRate: number;
/** Favor contrarian picks */
contrarianBias: number;
}
const STRATEGY_CONFIGS: Record<PredictionStrategy, StrategyConfig> = {
CONSERVATIVE: {
maxColumns: 100,
singleThreshold: 55,
doubleThreshold: 35,
useFormulaReduction: false,
formulaSamplingRate: 1.0,
contrarianBias: 0.0,
},
BALANCED: {
maxColumns: 500,
singleThreshold: 60,
doubleThreshold: 40,
useFormulaReduction: false,
formulaSamplingRate: 1.0,
contrarianBias: 0.15,
},
AGGRESSIVE: {
maxColumns: 2500,
singleThreshold: 70,
doubleThreshold: 50,
useFormulaReduction: false,
formulaSamplingRate: 1.0,
contrarianBias: 0.3,
},
FORMULA_6PCT: {
maxColumns: 2500,
singleThreshold: 60,
doubleThreshold: 40,
useFormulaReduction: true,
formulaSamplingRate: 0.06,
contrarianBias: 0.2,
},
};
// ═══════════ SERVICE ═══════════
@Injectable()
export class TotoPredictionService {
private readonly logger = new Logger(TotoPredictionService.name);
private readonly aiEngineUrl: string;
constructor(
private readonly prisma: PrismaService,
private readonly httpService: HttpService,
private readonly configService: ConfigService,
private readonly combinatorics: TotoCombinatoricsService,
private readonly analytics: TotoAnalyticsService,
) {
this.aiEngineUrl =
this.configService.get('AI_ENGINE_URL') || 'http://127.0.0.1:8000';
}
/**
* Ana tahmin motoru: Bülten AI analiz Contrarian strateji Sistem kuponu
*/
async generatePrediction(
bulletinId: string,
strategy: PredictionStrategy = 'BALANCED',
maxBudget?: number,
): Promise<PredictionResult> {
const config = STRATEGY_CONFIGS[strategy];
// 1. Bülteni getir
const bulletin = await this.prisma.totoBulletin.findUnique({
where: { id: bulletinId },
include: { matches: { orderBy: { matchOrder: 'asc' } } },
});
if (!bulletin) {
throw new Error(`Bulletin not found: ${bulletinId}`);
}
this.logger.log(
`Generating prediction for Cycle ${bulletin.gameCycleNo} — Strategy: ${strategy}`,
);
// 2. Her maç için AI tahmin al
const matchAnalyses: MatchPredictionAnalysis[] = [];
for (const match of bulletin.matches) {
const analysis = await this.analyzeMatch(
match.matchOrder,
match.homeTeamName,
match.awayTeamName,
match.leagueName,
match.kickoffTime,
match.matchId,
config,
);
matchAnalyses.push(analysis);
}
// 3. Selections'dan kupon üret
const matchSelections: TotoMatchSelectionInput[] = matchAnalyses.map(
(a) => ({
matchOrder: a.matchOrder,
selections: a.selections,
}),
);
const totalColumns =
this.combinatorics.calculateColumnCount(matchSelections);
// Bütçe kontrolü
let effectiveMaxColumns = config.maxColumns;
if (maxBudget && maxBudget < totalColumns) {
effectiveMaxColumns = Math.min(effectiveMaxColumns, maxBudget);
}
// Kolon üretimi
let columns;
if (config.useFormulaReduction) {
// Formula %6: Tam sistemden yüzde örnekleme
const sampledCount = Math.max(
1,
Math.floor(totalColumns * config.formulaSamplingRate),
);
const targetCount = Math.min(sampledCount, effectiveMaxColumns);
columns = this.combinatorics.generateReducedSystem(
matchSelections,
targetCount,
);
} else if (totalColumns > effectiveMaxColumns) {
columns = this.combinatorics.generateReducedSystem(
matchSelections,
effectiveMaxColumns,
);
} else {
columns = this.combinatorics.generateFullSystem(matchSelections);
}
const cost = this.combinatorics.calculateCost(columns.length);
// 4. EV raporu
const evReport = await this.calculateEvReport(
bulletin.poolTotal ?? 0,
bulletin.rolloverAmount ?? 0,
columns.length,
cost,
);
// 5. Sistem bilgisi
const singles = matchAnalyses.filter(
(a) => a.selections.length === 1,
).length;
const doubles = matchAnalyses.filter(
(a) => a.selections.length === 2,
).length;
const triples = matchAnalyses.filter(
(a) => a.selections.length === 3,
).length;
return {
bulletinId: bulletin.id,
gameCycleNo: bulletin.gameCycleNo,
strategy,
matchAnalyses,
coupon: {
totalColumns: columns.length,
cost,
maxCouponLimit: 2500,
columns: columns.map((c) => c.predictions),
},
evReport,
systemInfo: {
singlePicks: singles,
doublePicks: doubles,
triplePicks: triples,
formula: `2^${doubles} × 3^${triples} = ${totalColumns} (full) → ${columns.length} (generated)`,
},
};
}
// ═══════════ MATCH ANALYSIS ═══════════
/**
* Tek bir maç için AI tahmin + contrarian analiz
*/
private async analyzeMatch(
matchOrder: number,
homeTeam: string,
awayTeam: string,
leagueName: string | null,
kickoffTime: Date | null,
existingMatchId: string | null,
config: StrategyConfig,
): Promise<MatchPredictionAnalysis> {
// 1. Match linking — DB'den matchId bul
const linkedMatchId =
existingMatchId ||
(await this.fuzzyMatchLink(homeTeam, awayTeam, kickoffTime));
// 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ı';
if (linkedMatchId) {
const aiResult = await this.callAiEngine(linkedMatchId);
if (aiResult) {
probabilities = aiResult.probabilities;
confidence = aiResult.confidence;
aiPick = aiResult.pick;
predictionSource = 'AI_ENGINE';
reasoning = aiResult.reasoning;
} else {
// AI Engine erişilemez → tarihsel form analizi
const formResult = await this.analyzeHistoricalForm(homeTeam, awayTeam);
probabilities = formResult.probabilities;
confidence = formResult.confidence;
aiPick = formResult.pick;
predictionSource = 'HISTORICAL_FORM';
reasoning = formResult.reasoning;
}
} else {
// matchId yok → tarihsel form analizi dene
const formResult = await this.analyzeHistoricalForm(homeTeam, awayTeam);
if (formResult.confidence > 33) {
probabilities = formResult.probabilities;
confidence = formResult.confidence;
aiPick = formResult.pick;
predictionSource = 'HISTORICAL_FORM';
reasoning = formResult.reasoning;
}
}
// 3. Contrarian strateji — maç seçimleri belirle
const { selections, contrarianScore, contrarianReasoning } =
this.applyContrarianStrategy(probabilities, confidence, aiPick, config);
return {
matchOrder,
homeTeam,
awayTeam,
leagueName,
linkedMatchId,
predictionSource,
probabilities,
confidence,
aiPick,
selections,
reasoning: `${reasoning} | ${contrarianReasoning}`,
contrarianScore,
};
}
// ═══════════ FUZZY MATCH LINKING ═══════════
/**
* Bülten maç adlarını DB'deki live_matches/matches ile eşleştir
* Strateji: takım adlarını normalize et, ILIKE ile ara, tarih filtrele
*/
private async fuzzyMatchLink(
homeTeam: string,
awayTeam: string,
kickoffTime: Date | null,
): Promise<string | null> {
const homeNorm = this.normalizeTeamName(homeTeam);
const awayNorm = this.normalizeTeamName(awayTeam);
// 1. Önce live_matches'te ara (canlı + odds verisi var)
try {
const liveMatch = await this.prisma.$queryRawUnsafe<
Array<{ id: string }>
>(
`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` : ''}
LIMIT 1`,
`%${homeNorm}%`,
`%${awayNorm}%`,
...(kickoffTime ? [kickoffTime.getTime()] : []),
);
if (liveMatch.length > 0) {
this.logger.debug(
`Fuzzy matched live: ${homeTeam} vs ${awayTeam}${liveMatch[0].id}`,
);
return liveMatch[0].id;
}
} catch (err) {
this.logger.warn(`Live match fuzzy search failed: ${err}`);
}
// 2. matches tablosunda ara (tarihsel)
try {
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` : ''}
ORDER BY mst_utc DESC
LIMIT 1`,
`%${homeNorm}%`,
`%${awayNorm}%`,
...(kickoffTime ? [kickoffTime.getTime()] : []),
);
if (match.length > 0) {
this.logger.debug(
`Fuzzy matched historical: ${homeTeam} vs ${awayTeam}${match[0].id}`,
);
return match[0].id;
}
} catch (err) {
this.logger.warn(`Historical match fuzzy search failed: ${err}`);
}
this.logger.warn(`No match found for: ${homeTeam} vs ${awayTeam}`);
return null;
}
/**
* Takım adını normalize et (lowercase, türkçe karakter düzelt, boşlukları trim)
*/
private normalizeTeamName(name: string): string {
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, ' ');
}
// ═══════════ AI ENGINE INTEGRATION ═══════════
/**
* AI Engine V20+ ile maç analizi
*/
private async callAiEngine(matchId: string): Promise<{
probabilities: { home: number; draw: number; away: number };
confidence: number;
pick: TotoSelection;
reasoning: string;
} | null> {
try {
const response = await firstValueFrom(
this.httpService.post(
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
{},
{ timeout: 30000 },
),
);
const data = response.data;
if (!data || !data.bet_summary) {
return null;
}
// "Maç Sonucu" market'ını bul
const msPick = (
data.bet_summary as Array<{
market: string;
pick: string;
calibrated_confidence?: number;
confidence?: number;
probability?: number;
reasons?: string[];
}>
).find(
(b) =>
b.market?.toLowerCase().includes('maç sonucu') ||
b.market?.toLowerCase().includes('match result') ||
b.market === '1X2',
);
// Score prediction'dan olasılıklar çıkar
const scorePred = data.score_prediction;
let probabilities = { home: 0.4, draw: 0.3, away: 0.3 };
if (scorePred?.xg_home != null && scorePred?.xg_away != null) {
// xG bazlı basit olasılık tahmini
const xgHome = scorePred.xg_home;
const xgAway = scorePred.xg_away;
const total = xgHome + xgAway + 0.001;
const homeStrength = xgHome / total;
const awayStrength = xgAway / total;
probabilities = {
home: Math.max(0.1, Math.min(0.8, homeStrength + 0.1)),
draw: Math.max(
0.1,
Math.min(0.4, 1 - Math.abs(homeStrength - awayStrength)),
),
away: Math.max(0.1, Math.min(0.8, awayStrength)),
};
// Normalize
const sum =
probabilities.home + probabilities.draw + probabilities.away;
probabilities.home /= sum;
probabilities.draw /= sum;
probabilities.away /= sum;
}
// Pick'i Toto formatına çevir
let pick: TotoSelection = '1';
if (msPick) {
const rawPick = msPick.pick?.toLowerCase();
if (
rawPick?.includes('2') ||
rawPick?.includes('away') ||
rawPick?.includes('deplasman')
) {
pick = '2';
} else if (
rawPick?.includes('x') ||
rawPick?.includes('draw') ||
rawPick?.includes('beraberlik')
) {
pick = 'X';
} else {
pick = '1';
}
} else {
// No explicit MS pick → use probabilities
if (
probabilities.away > probabilities.home &&
probabilities.away > probabilities.draw
) {
pick = '2';
} else if (probabilities.draw > probabilities.home) {
pick = 'X';
}
}
const confidence = Math.round(
(msPick?.calibrated_confidence ?? msPick?.confidence ?? 50) *
(typeof (msPick?.calibrated_confidence ?? msPick?.confidence) ===
'number' &&
(msPick?.calibrated_confidence ?? msPick?.confidence ?? 0) <= 1
? 100
: 1),
);
const reasons = msPick?.reasons ?? [];
return {
probabilities,
confidence,
pick,
reasoning:
reasons.length > 0
? reasons.join(' | ')
: `AI Engine: ${pick} (confidence: ${confidence}%)`,
};
} catch (error) {
this.logger.warn(
`AI Engine call failed for ${matchId}: ${error instanceof Error ? error.message : error}`,
);
return null;
}
}
// ═══════════ HISTORICAL FORM ANALYSIS ═══════════
/**
* AI Engine erişilemezse: Tarihsel form bazlı basit olasılık hesabı
* Son 10 maçın ev sahibi/deplasman performansını analiz eder
*/
private async analyzeHistoricalForm(
homeTeam: string,
awayTeam: string,
): Promise<{
probabilities: { home: number; draw: number; away: number };
confidence: number;
pick: TotoSelection;
reasoning: string;
}> {
const homeNorm = this.normalizeTeamName(homeTeam);
const awayNorm = this.normalizeTeamName(awayTeam);
try {
// Son 10 ev sahibi maçı
const homeMatches = await this.prisma.$queryRawUnsafe<
Array<{ winner: string | null }>
>(
`SELECT winner FROM matches
WHERE LOWER(match_name) LIKE $1 AND winner IS NOT NULL
ORDER BY mst_utc DESC LIMIT 10`,
`%${homeNorm}%`,
);
// Son 10 deplasman maçı
const awayMatches = await this.prisma.$queryRawUnsafe<
Array<{ winner: string | null }>
>(
`SELECT winner FROM matches
WHERE LOWER(match_name) LIKE $1 AND winner IS NOT NULL
ORDER BY mst_utc DESC LIMIT 10`,
`%${awayNorm}%`,
);
if (homeMatches.length === 0 && awayMatches.length === 0) {
return {
probabilities: { home: 0.33, draw: 0.33, away: 0.34 },
confidence: 33,
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 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 awayTotal = awayMatches.length || 1;
// Basit form bazlı olasılık
const homeProb =
(homeWins / homeTotal) * 0.6 + (awayLosses / awayTotal) * 0.4;
const drawProb =
(homeDraws / homeTotal) * 0.5 + (awayDraws / awayTotal) * 0.5;
const awayProb =
(homeLosses / homeTotal) * 0.4 + (awayWins / awayTotal) * 0.6;
// Normalize
const sum = homeProb + drawProb + awayProb || 1;
const probabilities = {
home: homeProb / sum,
draw: drawProb / sum,
away: awayProb / sum,
};
// En yüksek olasılık
let pick: TotoSelection = '1';
if (
probabilities.away > probabilities.home &&
probabilities.away > probabilities.draw
) {
pick = '2';
} else if (probabilities.draw > probabilities.home) {
pick = 'X';
}
const confidence = Math.round(
Math.max(probabilities.home, probabilities.draw, probabilities.away) *
100,
);
return {
probabilities,
confidence,
pick,
reasoning: `Form: ${homeTeam} (${homeWins}W/${homeDraws}D/${homeLosses}L son ${homeTotal}) vs ${awayTeam} (${awayWins}W/${awayDraws}D/${awayLosses}L son ${awayTotal})`,
};
} catch (err) {
this.logger.warn(`Historical form analysis failed: ${err}`);
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',
};
}
}
// ═══════════ CONTRARIAN STRATEGY ═══════════
/**
* Parimutüel mantık: "Herkesin bildiğini bilmenin değeri yok"
*
* - Confidence yüksek + favori public de aynı yöne oynar düşük parimutüel değer
* - Sürpriz potansiyeli olan maçlara çift/üç seçim az kişi bilir yüksek değer
*
* Contrarian bias: Favorinin çok güçlü olduğu durumda bile sürpriz ihtimalini
* kupon içinde tutarak, varyansı kucaklamak (7. maçta Juventus'un evinde kaybetmesi gibi)
*/
private applyContrarianStrategy(
probabilities: { home: number; draw: number; away: number },
confidence: number,
aiPick: TotoSelection,
config: StrategyConfig,
): {
selections: TotoSelection[];
contrarianScore: number;
contrarianReasoning: string;
} {
// 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 },
].sort((a, b) => b.prob - a.prob);
const topProb = probs[0].prob;
const secondProb = probs[1].prob;
const gap = topProb - secondProb;
// Contrarian score: Favori ne kadar belirginse, public o kadar yığılır
// → bize ters yönü kapsamamız lazım
const contrarianScore = Math.round(
Math.min(100, (topProb * 100 - 33) * 2 + config.contrarianBias * 30),
);
// Karar: confidence ve strateji config'e göre
let selections: TotoSelection[];
let contrarianReasoning: string;
if (confidence >= config.singleThreshold && gap > 0.2) {
// Yüksek güven + belirgin fark → tek seçim (ama contrarian bias varsa %X ihtimal)
if (
config.contrarianBias > 0 &&
topProb > 0.55 &&
Math.random() < config.contrarianBias
) {
// Contrarian: Favori + ikinci seçenek
selections = [probs[0].pick, probs[1].pick];
contrarianReasoning = `Contrarian çift: ${probs[0].pick}(${(probs[0].prob * 100).toFixed(0)}%) + ${probs[1].pick}(${(probs[1].prob * 100).toFixed(0)}%) — Public yığılma riski`;
} else {
selections = [probs[0].pick];
contrarianReasoning = `Tek: ${probs[0].pick}(${(probs[0].prob * 100).toFixed(0)}%) — Yüksek güven`;
}
} else if (confidence >= config.doubleThreshold) {
// Orta güven → ikili seçim (en olası 2)
selections = [probs[0].pick, probs[1].pick];
contrarianReasoning = `İkili: ${probs[0].pick} + ${probs[1].pick} — Orta güven, varyans koruması`;
} else {
// Düşük güven → üçlü kapatma
selections = ['1', 'X', '2'];
contrarianReasoning = `Kapatma: 1X2 — Düşük güven (${confidence}%), maç çok belirsiz`;
}
return { selections, contrarianScore, contrarianReasoning };
}
// ═══════════ EV CALCULATION ═══════════
/**
* Expected Value raporu Devir yüksekse oyna
*/
private async calculateEvReport(
poolTotal: number,
rolloverAmount: number,
columnCount: number,
totalCost: number,
): Promise<PredictionResult['evReport']> {
const effectivePool = poolTotal + rolloverAmount;
const distribution =
this.analytics.calculatePoolDistribution(effectivePool);
// Basit EV: (15 bilme olasılığı × havuz payı) - maliyet
const prob15 = 1 / Math.pow(3, 15); // ~6.97e-8
const probWinWithColumns = 1 - Math.pow(1 - prob15, columnCount);
const ev15 = probWinWithColumns * distribution.pool15 - totalCost;
const evPerColumn = columnCount > 0 ? ev15 / columnCount : 0;
// Devir bilgisi
let rolloverData;
try {
rolloverData = await this.analytics.getRolloverHistory(5);
} catch {
rolloverData = {
consecutiveRollovers: 0,
averageRollover: 0,
history: [],
};
}
// Karar
let recommendation: PredictionResult['evReport']['recommendation'];
let recommendationReason: string;
if (rolloverAmount > 50_000_000) {
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';
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';
recommendationReason = `✅ Orta düzey devir: ${(rolloverAmount / 1_000_000).toFixed(1)}M TL`;
} else {
recommendation = 'WAIT';
recommendationReason = `⏳ Devir düşük (${(rolloverAmount / 1_000_000).toFixed(1)}M TL). Havuz büyümesini bekle.`;
}
return {
poolTotal,
rolloverAmount,
effectivePool,
ev15,
evPerColumn,
recommendation,
recommendationReason,
};
}
}
@@ -0,0 +1,259 @@
import {
Controller,
Get,
Post,
Patch,
Body,
Param,
Query,
HttpCode,
HttpStatus,
Logger,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiQuery,
ApiResponse,
ApiParam,
ApiBody,
ApiBearerAuth,
} 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';
@ApiTags('Spor Toto')
@Controller('spor-toto')
export class SporTotoController {
private readonly logger = new Logger(SporTotoController.name);
constructor(private readonly sporTotoService: SporTotoService) {}
// ═══════════ BULLETINS ═══════════
@Post('sync')
@UseGuards(JwtAuthGuard)
@Roles('admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
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.',
})
@ApiResponse({
status: 200,
description: 'Sync result with action (created/updated/unchanged)',
})
async syncFromApi() {
const result = await this.sporTotoService.syncFromApi();
return { success: true, data: result };
}
@Get('bulletins')
@Public()
@ApiOperation({
summary: 'List Spor Toto bulletins',
description:
'Returns a paginated list of bulletins, optionally filtered by status.',
})
@ApiQuery({
name: 'status',
required: false,
enum: TotoBulletinStatus,
description: 'Filter by bulletin status',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Max results (default: 10)',
})
@ApiResponse({
status: 200,
description: 'Array of bulletins with matches and results',
})
async listBulletins(
@Query('status') status?: TotoBulletinStatus,
@Query('limit') limit?: string,
) {
const bulletins = await this.sporTotoService.listBulletins(
status,
Number(limit) || 10,
);
return { success: true, data: bulletins };
}
@Get('bulletins/:id')
@Public()
@ApiOperation({
summary: 'Get bulletin details',
description:
'Returns a single bulletin with all 15 matches, results, and dividend info.',
})
@ApiParam({ name: 'id', description: 'Bulletin UUID' })
@ApiResponse({
status: 200,
description: 'Bulletin with matches and results',
})
@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')
@UseGuards(JwtAuthGuard)
@Roles('admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Create a bulletin manually',
description:
'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: 409,
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')
@UseGuards(JwtAuthGuard)
@Roles('admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
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.',
})
@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) {
const bulletin = await this.sporTotoService.updateResults(id, dto);
return { success: true, data: bulletin };
}
// ═══════════ STATS & ANALYTICS ═══════════
@Get('bulletins/:id/stats')
@Public()
@ApiOperation({
summary: 'Get bulletin pool & EV statistics',
description:
'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) {
const stats = await this.sporTotoService.getBulletinStats(id);
return { success: true, data: stats };
}
@Get('history')
@Public()
@ApiOperation({
summary: 'Get rollover history and trends',
description:
'Returns the last N bulletins with rollover amounts and consecutive rollover streak.',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of results (default: 20)',
})
@ApiResponse({ status: 200, description: 'Rollover history with trend data' })
async getRolloverHistory(@Query('limit') limit?: string) {
const history = await this.sporTotoService.getRolloverHistory(
Number(limit) || 20,
);
return { success: true, data: history };
}
// ═══════════ COLUMNS ═══════════
@Post('columns/generate')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
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.',
})
@ApiBody({ type: GenerateColumnsDto })
@ApiResponse({
status: 200,
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')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
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).',
})
@ApiBody({ type: EvaluateColumnsDto })
@ApiResponse({
status: 200,
description: 'Evaluation results with correct counts per column',
})
async evaluateColumns(@Body() dto: EvaluateColumnsDto) {
const result = await this.sporTotoService.evaluateColumns(
dto.bulletinId,
dto.columns,
);
return { success: true, data: result };
}
// ═══════════ AI PREDICTION ═══════════
@Post('predict')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
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).',
})
@ApiBody({ type: GenerateSporTotoPredictionDto })
@ApiResponse({
status: 200,
description:
'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'}`,
);
const result = await this.sporTotoService.generatePrediction(dto);
return { success: true, data: result };
}
}
+24
View File
@@ -0,0 +1,24 @@
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],
controllers: [SporTotoController],
providers: [
SporTotoService,
TotoFetcherService,
TotoCombinatoricsService,
TotoAnalyticsService,
TotoPredictionService,
],
exports: [SporTotoService],
})
export class SporTotoModule {}
+462
View File
@@ -0,0 +1,462 @@
import {
Injectable,
Logger,
NotFoundException,
ConflictException,
} 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';
import {
TotoPredictionService,
PredictionStrategy,
} from './services/toto-prediction.service';
import {
CreateBulletinDto,
UpdateResultsDto,
GenerateColumnsDto,
GenerateSporTotoPredictionDto,
} from './dto/spor-toto.dto';
import { TotoBulletinStatus, TotoMatchResult, Prisma } from '@prisma/client';
@Injectable()
export class SporTotoService {
private readonly logger = new Logger(SporTotoService.name);
constructor(
private readonly prisma: PrismaService,
private readonly fetcher: TotoFetcherService,
private readonly combinatorics: TotoCombinatoricsService,
private readonly analytics: TotoAnalyticsService,
private readonly prediction: TotoPredictionService,
) {}
// ═══════════ BULLETIN CRUD ═══════════
/**
* Fetch and sync current bulletin from Spor Toto API
*/
async syncFromApi(): Promise<{
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');
}
const apiData = apiResponse.data;
const existing = await this.prisma.totoBulletin.findUnique({
where: { gameCycleNo: apiData.gameCycleNo },
include: { matches: true },
});
if (existing) {
// Update: sonuçları güncelle (eğer varsa)
let hasChanges = false;
for (const event of apiData.events) {
const resultEnum = this.fetcher.mapResultToEnum(event.winner);
if (resultEnum) {
const match = existing.matches.find(
(m) => m.matchOrder === event.eventNo,
);
if (match && !match.result) {
await this.prisma.totoBulletinMatch.update({
where: { id: match.id },
data: { result: resultEnum as TotoMatchResult },
});
hasChanges = true;
}
}
}
// Update dividends if present
if (apiData.dividends) {
await this.upsertResultsFromDividends(existing.id, apiData.dividends);
hasChanges = true;
}
// Check if all matches have results → mark COMPLETED
const allHaveResults = apiData.events.every((e) => e.winner !== null);
if (allHaveResults && existing.status !== 'COMPLETED') {
await this.prisma.totoBulletin.update({
where: { id: existing.id },
data: { status: TotoBulletinStatus.COMPLETED },
});
hasChanges = true;
}
return {
action: hasChanges ? 'updated' : 'unchanged',
gameCycleNo: apiData.gameCycleNo,
matchCount: apiData.events.length,
};
}
// Create new bulletin
const matchData = apiData.events.map((event) => {
const parsed = this.fetcher.parseEventName(event.eventName);
return {
matchOrder: event.eventNo,
homeTeamName: parsed.homeTeam,
awayTeamName: parsed.awayTeam,
leagueName: event.competitionName,
kickoffTime: new Date(event.eventDate),
result: this.fetcher.mapResultToEnum(
event.winner,
) as TotoMatchResult | null,
};
});
await this.prisma.totoBulletin.create({
data: {
gameCycleNo: apiData.gameCycleNo,
programName: apiData.programName,
payinBeginDate: new Date(apiData.payinBeginDate),
payinEndDate: new Date(apiData.payinEndDate),
status: TotoBulletinStatus.UPCOMING,
matches: {
createMany: { data: matchData },
},
},
});
this.logger.log(
`Created bulletin: Cycle ${apiData.gameCycleNo} with ${matchData.length} matches`,
);
return {
action: 'created',
gameCycleNo: apiData.gameCycleNo,
matchCount: matchData.length,
};
}
/**
* Create bulletin manually
*/
async createBulletin(dto: CreateBulletinDto) {
const existing = await this.prisma.totoBulletin.findUnique({
where: { gameCycleNo: dto.gameCycleNo },
});
if (existing) {
throw new ConflictException(
`Bulletin with gameCycleNo ${dto.gameCycleNo} already exists`,
);
}
return this.prisma.totoBulletin.create({
data: {
gameCycleNo: dto.gameCycleNo,
programName: dto.programName,
season: dto.season,
payinBeginDate: dto.payinBeginDate
? new Date(dto.payinBeginDate)
: undefined,
payinEndDate: dto.payinEndDate ? new Date(dto.payinEndDate) : undefined,
matches: {
createMany: {
data: dto.matches.map((m) => ({
matchOrder: m.matchOrder,
homeTeamName: m.homeTeamName,
awayTeamName: m.awayTeamName,
leagueName: m.leagueName,
kickoffTime: m.kickoffTime ? new Date(m.kickoffTime) : undefined,
matchId: m.matchId,
})),
},
},
},
include: { matches: true },
});
}
/**
* List bulletins
*/
async listBulletins(status?: TotoBulletinStatus, limit = 10) {
const where: Prisma.TotoBulletinWhereInput = {};
if (status) where.status = status;
return this.prisma.totoBulletin.findMany({
where,
orderBy: { gameCycleNo: 'desc' },
take: limit,
include: {
matches: { orderBy: { matchOrder: 'asc' } },
result: true,
},
});
}
/**
* Get single bulletin with details
*/
async getBulletinById(id: string) {
const bulletin = await this.prisma.totoBulletin.findUnique({
where: { id },
include: {
matches: { orderBy: { matchOrder: 'asc' } },
result: true,
},
});
if (!bulletin) {
throw new NotFoundException('Bulletin not found');
}
return bulletin;
}
// ═══════════ RESULTS ═══════════
/**
* Update match results manually
*/
async updateResults(bulletinId: string, dto: UpdateResultsDto) {
const bulletin = await this.prisma.totoBulletin.findUnique({
where: { id: bulletinId },
include: { matches: true },
});
if (!bulletin) {
throw new NotFoundException('Bulletin not found');
}
// Update individual match results
for (const r of dto.results) {
const match = bulletin.matches.find((m) => m.matchOrder === r.matchOrder);
if (match) {
await this.prisma.totoBulletinMatch.update({
where: { id: match.id },
data: {
result: r.result as TotoMatchResult,
isCancelled: r.isCancelled ?? false,
drawResult: r.drawResult
? (r.drawResult as TotoMatchResult)
: undefined,
},
});
}
}
// Upsert TotoResult record
if (dto.winners15 !== undefined || dto.winners14 !== undefined) {
await this.prisma.totoResult.upsert({
where: { bulletinId },
create: {
bulletinId,
winners15: dto.winners15 ?? 0,
prize15: dto.prize15,
winners14: dto.winners14 ?? 0,
prize14: dto.prize14,
winners13: dto.winners13 ?? 0,
prize13: dto.prize13,
winners12: dto.winners12 ?? 0,
prize12: dto.prize12,
rolloverNext: dto.rolloverNext,
},
update: {
winners15: dto.winners15,
prize15: dto.prize15,
winners14: dto.winners14,
prize14: dto.prize14,
winners13: dto.winners13,
prize13: dto.prize13,
winners12: dto.winners12,
prize12: dto.prize12,
rolloverNext: dto.rolloverNext,
},
});
}
// Check if all 15 results entered → mark COMPLETED
const allEntered = dto.results.length === 15;
if (allEntered) {
await this.prisma.totoBulletin.update({
where: { id: bulletinId },
data: { status: TotoBulletinStatus.COMPLETED },
});
}
return this.getBulletinById(bulletinId);
}
private async upsertResultsFromDividends(
bulletinId: string,
dividends: {
winnerCount15?: number;
dividend15?: number;
winnerCount14?: number;
dividend14?: number;
winnerCount13?: number;
dividend13?: number;
winnerCount12?: number;
dividend12?: number;
},
) {
await this.prisma.totoResult.upsert({
where: { bulletinId },
create: {
bulletinId,
winners15: dividends.winnerCount15 ?? 0,
prize15: dividends.dividend15 ?? null,
winners14: dividends.winnerCount14 ?? 0,
prize14: dividends.dividend14 ?? null,
winners13: dividends.winnerCount13 ?? 0,
prize13: dividends.dividend13 ?? null,
winners12: dividends.winnerCount12 ?? 0,
prize12: dividends.dividend12 ?? null,
},
update: {
winners15: dividends.winnerCount15 ?? undefined,
prize15: dividends.dividend15 ?? undefined,
winners14: dividends.winnerCount14 ?? undefined,
prize14: dividends.dividend14 ?? undefined,
winners13: dividends.winnerCount13 ?? undefined,
prize13: dividends.dividend13 ?? undefined,
winners12: dividends.winnerCount12 ?? undefined,
prize12: dividends.dividend12 ?? undefined,
},
});
}
// ═══════════ COLUMNS & COUPONS ═══════════
/**
* Generate columns (system or reduced)
*/
async generateColumns(dto: GenerateColumnsDto) {
const bulletin = await this.getBulletinById(dto.bulletinId);
const matchSelections: TotoMatchSelectionInput[] = dto.matchSelections.map(
(ms) => ({
matchOrder: ms.matchOrder,
selections: ms.selections,
}),
);
const totalColumnCount =
this.combinatorics.calculateColumnCount(matchSelections);
let columns;
const strategy = dto.strategy || 'FULL_SYSTEM';
if (
strategy === 'REDUCED_SYSTEM' &&
dto.maxColumns &&
totalColumnCount > dto.maxColumns
) {
columns = this.combinatorics.generateReducedSystem(
matchSelections,
dto.maxColumns,
);
} else {
columns = this.combinatorics.generateFullSystem(matchSelections);
}
const cost = this.combinatorics.calculateCost(columns.length);
return {
bulletinId: bulletin.id,
gameCycleNo: bulletin.gameCycleNo,
strategy,
totalPossibleColumns: totalColumnCount,
generatedColumns: columns.length,
cost,
columns: columns.map((c) => c.predictions),
};
}
/**
* Evaluate columns against results
*/
async evaluateColumns(bulletinId: string, columnPredictions: string[]) {
const bulletin = await this.prisma.totoBulletin.findUnique({
where: { id: bulletinId },
include: { matches: { orderBy: { matchOrder: 'asc' } } },
});
if (!bulletin) {
throw new NotFoundException('Bulletin not found');
}
// Build results string (15 chars)
const resultMap: Record<string, string> = {
HOME: '1',
DRAW: 'X',
AWAY: '2',
};
const resultsString = bulletin.matches
.map((m) => {
if (m.isCancelled && m.drawResult) {
return resultMap[m.drawResult] || '?';
}
return m.result ? resultMap[m.result] || '?' : '?';
})
.join('');
if (resultsString.includes('?')) {
return {
complete: false,
message: 'Bazı maçların sonuçları henüz girilmedi',
resultsString,
evaluations: [],
};
}
const columns = columnPredictions.map((p) => ({ predictions: p }));
const evaluations = this.combinatorics.evaluateColumns(
columns,
resultsString,
);
const summary = {
total: evaluations.length,
correct15: evaluations.filter((e) => e.correctCount === 15).length,
correct14: evaluations.filter((e) => e.correctCount === 14).length,
correct13: evaluations.filter((e) => e.correctCount === 13).length,
correct12: evaluations.filter((e) => e.correctCount === 12).length,
maxCorrect: Math.max(...evaluations.map((e) => e.correctCount)),
};
return {
complete: true,
resultsString,
summary,
evaluations: evaluations.sort((a, b) => b.correctCount - a.correctCount),
};
}
// ═══════════ ANALYTICS ═══════════
async getBulletinStats(bulletinId: string) {
return this.analytics.getBulletinStats(bulletinId);
}
async getRolloverHistory(limit = 20) {
return this.analytics.getRolloverHistory(limit);
}
// ═══════════ AI PREDICTION ═══════════
/**
* AI Engine ile akıllı sistem kuponu üret
*/
async generatePrediction(dto: GenerateSporTotoPredictionDto) {
const strategy: PredictionStrategy = dto.strategy || 'BALANCED';
return this.prediction.generatePrediction(
dto.bulletinId,
strategy,
dto.maxBudget,
);
}
}
+103
View File
@@ -0,0 +1,103 @@
import {
IsEmail,
IsString,
IsOptional,
IsBoolean,
MinLength,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
export class CreateUserDto {
@ApiPropertyOptional({ example: 'user@example.com' })
@IsEmail()
email: string;
@ApiPropertyOptional({ example: 'password123', minLength: 8 })
@IsString()
@MinLength(8)
password: string;
@ApiPropertyOptional({ example: 'John' })
@IsOptional()
@IsString()
firstName?: string;
@ApiPropertyOptional({ example: 'Doe' })
@IsOptional()
@IsString()
lastName?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateUserDto extends PartialType(CreateUserDto) {
@ApiPropertyOptional({ example: 'John' })
@IsOptional()
@IsString()
firstName?: string;
@ApiPropertyOptional({ example: 'Doe' })
@IsOptional()
@IsString()
lastName?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateProfileDto {
@ApiPropertyOptional({ example: 'John' })
@IsOptional()
@IsString()
firstName?: string;
@ApiPropertyOptional({ example: 'Doe' })
@IsOptional()
@IsString()
lastName?: string;
}
export class ChangePasswordDto {
@ApiProperty({ example: 'oldPassword123' })
@IsString()
currentPassword: string;
@ApiProperty({ example: 'newPassword456', minLength: 8 })
@IsString()
@MinLength(8)
newPassword: string;
}
import { Exclude, Expose } from 'class-transformer';
@Exclude()
export class UserResponseDto {
@Expose()
id: string;
@Expose()
email: string;
@Expose()
firstName: string | null;
@Expose()
lastName: string | null;
@Expose()
role: string;
@Expose()
isActive: boolean;
@Expose()
createdAt: Date;
@Expose()
updatedAt: Date;
}
+105
View File
@@ -0,0 +1,105 @@
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';
import {
CreateUserDto,
UpdateUserDto,
UpdateProfileDto,
ChangePasswordDto,
} from './dto/user.dto';
import { CurrentUser, Roles } from '../../common/decorators';
import {
ApiResponse,
createSuccessResponse,
} from '../../common/types/api-response.type';
import { User } from '@prisma/client';
import { plainToInstance } from 'class-transformer';
import { UserResponseDto } from './dto/user.dto';
interface AuthenticatedUser {
id: string;
email: string;
role: string;
}
@ApiTags('Users')
@ApiBearerAuth()
@Controller('users')
export class UsersController extends BaseController<
User,
CreateUserDto,
UpdateUserDto
> {
constructor(private readonly usersService: UsersService) {
super(usersService, 'User');
}
@Get('me')
@ApiOperation({ summary: 'Get current authenticated user profile' })
@ApiOkResponse({ type: UserResponseDto })
async getMe(
@CurrentUser() user: AuthenticatedUser,
): Promise<ApiResponse<UserResponseDto>> {
const fullUser = await this.usersService.findOneWithDetails(user.id);
return createSuccessResponse(
plainToInstance(UserResponseDto, fullUser),
'User profile retrieved successfully',
);
}
@Put('me')
@ApiOperation({ summary: 'Update current user profile' })
@ApiOkResponse({ type: UserResponseDto })
async updateMe(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: UpdateProfileDto,
): Promise<ApiResponse<UserResponseDto>> {
const updatedUser = await this.usersService.updateProfile(user.id, dto);
return createSuccessResponse(
plainToInstance(UserResponseDto, updatedUser),
'User profile updated successfully',
);
}
@Patch('me/password')
@ApiOperation({ summary: 'Change current user password' })
@ApiOkResponse({ description: 'Password changed successfully' })
async changePassword(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: ChangePasswordDto,
): Promise<ApiResponse<null>> {
await this.usersService.changePassword(
user.id,
dto.currentPassword,
dto.newPassword,
);
return createSuccessResponse(null, 'Password changed successfully');
}
// Override create to require admin role
@Roles('admin')
async create(
...args: Parameters<
BaseController<User, CreateUserDto, UpdateUserDto>['create']
>
) {
return super.create(...args);
}
// Override delete to require admin role
@Roles('admin')
async delete(
...args: Parameters<
BaseController<User, CreateUserDto, UpdateUserDto>['delete']
>
) {
return super.delete(...args);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

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