This commit is contained in:
Executable
+280
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminController],
|
||||
})
|
||||
export class AdminModule {}
|
||||
Executable
+71
@@ -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;
|
||||
}
|
||||
Executable
+100
@@ -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 };
|
||||
}
|
||||
}
|
||||
Executable
+13
@@ -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 {}
|
||||
Executable
+152
@@ -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[];
|
||||
}
|
||||
Executable
+78
@@ -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'));
|
||||
}
|
||||
}
|
||||
Executable
+37
@@ -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 {}
|
||||
Executable
+248
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+70
@@ -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;
|
||||
}
|
||||
Executable
+142
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export * from './auth.guards';
|
||||
Executable
+37
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Executable
+238
@@ -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 };
|
||||
}
|
||||
}
|
||||
Executable
+16
@@ -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 {}
|
||||
Executable
+38
@@ -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
@@ -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
@@ -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
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
Executable
+746
@@ -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
@@ -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()}`;
|
||||
}
|
||||
}
|
||||
Executable
+22
@@ -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 {}
|
||||
Executable
+994
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+533
@@ -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;
|
||||
}
|
||||
Executable
+7
@@ -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',
|
||||
}));
|
||||
Executable
+18
@@ -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 {}
|
||||
Executable
+240
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
export * from './gemini.module';
|
||||
export * from './gemini.service';
|
||||
export * from './gemini.config';
|
||||
Executable
+44
@@ -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() };
|
||||
}
|
||||
}
|
||||
Executable
+11
@@ -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 {}
|
||||
Executable
+152
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+12
@@ -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 {}
|
||||
Executable
+173
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Executable
+219
@@ -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;
|
||||
}
|
||||
Executable
+130
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+12
@@ -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 {}
|
||||
Executable
+703
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+471
@@ -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
@@ -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
@@ -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
@@ -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 {}
|
||||
+1166
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
@@ -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
@@ -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
@@ -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 İYİ 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, '/')}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/üçlü 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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Executable
+103
@@ -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;
|
||||
}
|
||||
Executable
+105
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+10
@@ -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 {}
|
||||
Executable
+199
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { BaseService } from '../../common/base';
|
||||
import { CreateUserDto, UpdateUserDto, UpdateProfileDto } from './dto/user.dto';
|
||||
import { User, UserRole } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService extends BaseService<
|
||||
User,
|
||||
CreateUserDto,
|
||||
UpdateUserDto
|
||||
> {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'User');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user with hashed password
|
||||
*/
|
||||
async create(dto: CreateUserDto): Promise<User> {
|
||||
// Check if email already exists
|
||||
const existingUser = await this.findOneBy({ email: dto.email });
|
||||
if (existingUser) {
|
||||
throw new ConflictException('EMAIL_ALREADY_EXISTS');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await this.hashPassword(dto.password);
|
||||
|
||||
// Map password to passwordHash for Prisma
|
||||
// Exclude plain password from the data and use hashed version
|
||||
|
||||
const { password: _password, ...rest } = dto;
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
...rest,
|
||||
passwordHash: hashedPassword,
|
||||
role: UserRole.user,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user, hash password if provided
|
||||
*/
|
||||
async update(id: string, dto: UpdateUserDto): Promise<User> {
|
||||
const updateData: any = { ...dto };
|
||||
|
||||
if (dto.password) {
|
||||
updateData.passwordHash = await this.hashPassword(dto.password);
|
||||
delete updateData.password;
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by email
|
||||
*/
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user with subscription and usage info
|
||||
*/
|
||||
findOneWithDetails(id: string) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
usageLimit: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has specific role
|
||||
*/
|
||||
hasRole(user: User, role: UserRole): boolean {
|
||||
return user.role === role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user role
|
||||
*/
|
||||
async updateRole(userId: string, role: UserRole): Promise<User> {
|
||||
return this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { role },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create usage limit for user
|
||||
*/
|
||||
async getOrCreateUsageLimit(userId: string) {
|
||||
let usageLimit = await this.prisma.usageLimit.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!usageLimit) {
|
||||
usageLimit = await this.prisma.usageLimit.create({
|
||||
data: {
|
||||
userId,
|
||||
analysisCount: 0,
|
||||
couponCount: 0,
|
||||
lastResetDate: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return usageLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment analysis count
|
||||
*/
|
||||
async incrementAnalysisCount(userId: string): Promise<void> {
|
||||
await this.prisma.usageLimit.upsert({
|
||||
where: { userId },
|
||||
update: { analysisCount: { increment: 1 } },
|
||||
create: {
|
||||
userId,
|
||||
analysisCount: 1,
|
||||
couponCount: 0,
|
||||
lastResetDate: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using bcrypt
|
||||
*/
|
||||
private async hashPassword(password: string): Promise<string> {
|
||||
const saltRounds = 12;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare password with hash
|
||||
*/
|
||||
async comparePassword(
|
||||
password: string,
|
||||
hashedPassword: string,
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hashedPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile (first name, last name)
|
||||
*/
|
||||
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<User> {
|
||||
return this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: dto,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password with current password verification
|
||||
*/
|
||||
async changePassword(
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
const isCurrentPasswordValid = await this.comparePassword(
|
||||
currentPassword,
|
||||
user.passwordHash,
|
||||
);
|
||||
|
||||
if (!isCurrentPasswordValid) {
|
||||
throw new UnauthorizedException('INVALID_CURRENT_PASSWORD');
|
||||
}
|
||||
|
||||
const hashedNewPassword = await this.hashPassword(newPassword);
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash: hashedNewPassword },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user