main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 29s

This commit is contained in:
2026-05-04 18:00:40 +03:00
parent 145a8b336b
commit 27e96da31d
22 changed files with 571 additions and 169 deletions
+25 -5
View File
@@ -18,7 +18,12 @@ import {
CACHE_MANAGER,
} from "@nestjs/cache-manager";
import * as cacheManager from "cache-manager";
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse as SwaggerResponse } from "@nestjs/swagger";
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse as SwaggerResponse,
} from "@nestjs/swagger";
import { Roles } from "../../common/decorators";
import { PrismaService } from "../../database/prisma.service";
import { PaginationDto } from "../../common/dto/pagination.dto";
@@ -181,7 +186,10 @@ export class AdminController {
@CacheKey("app_settings")
@CacheTTL(60 * 1000)
@ApiOperation({ summary: "Get all app settings" })
@SwaggerResponse({ status: 200, schema: { type: "object", additionalProperties: { type: "string" } } })
@SwaggerResponse({
status: 200,
schema: { type: "object", additionalProperties: { type: "string" } },
})
async getAllSettings(): Promise<ApiResponse<Record<string, string>>> {
const settings = await this.prisma.appSetting.findMany();
const settingsMap: Record<string, string> = {};
@@ -193,7 +201,13 @@ export class AdminController {
@Put("settings/:key")
@ApiOperation({ summary: "Update an app setting" })
@SwaggerResponse({ status: 200, schema: { type: "object", properties: { key: { type: "string" }, value: { type: "string" } } } })
@SwaggerResponse({
status: 200,
schema: {
type: "object",
properties: { key: { type: "string" }, value: { type: "string" } },
},
})
async updateSetting(
@Param("key") key: string,
@Body() data: { value: string },
@@ -214,7 +228,10 @@ export class AdminController {
@Get("usage-limits")
@ApiOperation({ summary: "Get all usage limits" })
@SwaggerResponse({ status: 200, schema: { type: "array", items: { type: "object" } } })
@SwaggerResponse({
status: 200,
schema: { type: "array", items: { type: "object" } },
})
async getAllUsageLimits(@Query() pagination: PaginationDto) {
const { skip, take } = pagination;
@@ -242,7 +259,10 @@ export class AdminController {
@Post("usage-limits/reset-all")
@ApiOperation({ summary: "Reset all usage limits" })
@SwaggerResponse({ status: 200, schema: { type: "object", properties: { count: { type: "number" } } } })
@SwaggerResponse({
status: 200,
schema: { type: "object", properties: { count: { type: "number" } } },
})
async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> {
const result = await this.prisma.usageLimit.updateMany({
data: {
+2 -5
View File
@@ -94,11 +94,8 @@ export class RolesGuard implements CanActivate {
return false;
}
const normalizedUserRoles = (user.roles?.length
? user.roles
: user.role
? [user.role]
: []
const normalizedUserRoles = (
user.roles?.length ? user.roles : user.role ? [user.role] : []
).map((role) => normalizeRole(role));
const normalizedRequiredRoles = requiredRoles.map((role) =>
-1
View File
@@ -25,4 +25,3 @@ import { MatchesModule } from "../matches/matches.module";
],
})
export class CouponsModule {}
@@ -109,8 +109,7 @@ export class FrequencyCouponDto {
minSignal?: number;
@ApiPropertyOptional({
description:
"Filter markets: OU1.5, OU2.5, OU3.5, BTTS, MS (default: all)",
description: "Filter markets: OU1.5, OU2.5, OU3.5, BTTS, MS (default: all)",
example: ["OU2.5", "BTTS"],
})
@IsOptional()
@@ -108,8 +108,7 @@ export class FrequencyEngineService {
venue: "home" | "away",
oddsBand: string,
): Promise<TeamFrequencyRow | null> {
const venueColumn =
venue === "home" ? "m.home_team_id" : "m.away_team_id";
const venueColumn = venue === "home" ? "m.home_team_id" : "m.away_team_id";
const oddsSelection = venue === "home" ? "'1'" : "'2'";
const bandRange = this.parseBandRange(oddsBand);
@@ -191,7 +190,7 @@ export class FrequencyEngineService {
// OU 1.5 OVER
const ou15Combined = (homeFreq.ou15_rate + awayFreq.ou15_rate) / 2;
if (ou15Combined >= 0.80) {
if (ou15Combined >= 0.8) {
signals.push({
market: "OU1.5_OVER",
pick: "1.5 UST",
@@ -212,7 +211,7 @@ export class FrequencyEngineService {
// OU 2.5 OVER
const ou25Combined = (homeFreq.ou25_rate + awayFreq.ou25_rate) / 2;
if (ou25Combined >= 0.60) {
if (ou25Combined >= 0.6) {
signals.push({
market: "OU2.5_OVER",
pick: "2.5 UST",
@@ -233,7 +232,7 @@ export class FrequencyEngineService {
// OU 3.5 OVER
const ou35Combined = (homeFreq.ou35_rate + awayFreq.ou35_rate) / 2;
if (ou35Combined >= 0.50) {
if (ou35Combined >= 0.5) {
signals.push({
market: "OU3.5_OVER",
pick: "3.5 UST",
@@ -254,7 +253,7 @@ export class FrequencyEngineService {
// BTTS YES
const bttsCombined = (homeFreq.btts_rate + awayFreq.btts_rate) / 2;
if (bttsCombined >= 0.60) {
if (bttsCombined >= 0.6) {
signals.push({
market: "BTTS_YES",
pick: "KG VAR",
@@ -299,7 +298,7 @@ export class FrequencyEngineService {
const hwCombined = (homeFreq.win_rate + awayFreq.win_rate) / 2;
// awayFreq.win_rate aslında deplasman takımının KAYBETme oranı
// (away takımı o bandda maçları kazanma değil, kaybetme olarak bak)
if (hwCombined >= 0.70 && homeOdds > 1.10 && homeOdds < 3.50) {
if (hwCombined >= 0.7 && homeOdds > 1.1 && homeOdds < 3.5) {
signals.push({
market: "MS_HOME",
pick: "MS 1",
@@ -411,9 +410,7 @@ export class FrequencyEngineService {
/**
* Lig bazlı gol profili.
*/
async getLeagueProfile(
leagueId: string,
): Promise<LeagueProfileRow | null> {
async getLeagueProfile(leagueId: string): Promise<LeagueProfileRow | null> {
const rows = await this.prisma.$queryRawUnsafe<LeagueProfileRow[]>(
`
SELECT
@@ -521,9 +518,7 @@ export class FrequencyEngineService {
return "6.00+";
}
private parseBandRange(
band: string,
): { min: number; max: number } | null {
private parseBandRange(band: string): { min: number; max: number } | null {
const map: Record<string, { min: number; max: number }> = {
"1.00-1.30": { min: 1.0, max: 1.3 },
"1.30-1.50": { min: 1.3, max: 1.5 },
@@ -537,9 +532,7 @@ export class FrequencyEngineService {
return map[band] || null;
}
private calculateLeagueBonus(
profile: LeagueProfileRow | null,
): number {
private calculateLeagueBonus(profile: LeagueProfileRow | null): number {
if (!profile || profile.total_matches < 20) {
return 0;
}
@@ -154,9 +154,10 @@ export class SmartCouponService {
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
let prediction: SingleMatchPredictionPackage;
try {
const response = await this.aiEngineClient.post<SingleMatchPredictionPackage>(
`/v20plus/analyze/${matchId}`,
);
const response =
await this.aiEngineClient.post<SingleMatchPredictionPackage>(
`/v20plus/analyze/${matchId}`,
);
prediction = response.data;
} catch (error: unknown) {
if (error instanceof AiEngineRequestError) {
@@ -264,7 +265,7 @@ export class SmartCouponService {
markets?: string[];
}): Promise<FrequencyCouponResult> {
const maxMatches = options.maxMatches ?? 3;
const minSignal = options.minSignal ?? 0.70;
const minSignal = options.minSignal ?? 0.7;
const allowedMarkets = options.markets?.map((m) => m.toUpperCase()) || null;
this.logger.log(
@@ -858,9 +858,7 @@ export class FeederPersistenceService {
// Use raw SQL for performance — Prisma's { some: {} } relation filters
// generate heavy correlated subqueries that hang on Raspberry Pi with
// large tables (15M+ odd_selections, 3M+ participations).
const result = await this.prisma.$queryRawUnsafe<
Array<{ id: string }>
>(
const result = await this.prisma.$queryRawUnsafe<Array<{ id: string }>>(
`
SELECT m.id
FROM matches m
@@ -888,9 +886,7 @@ export class FeederPersistenceService {
* returns which data scopes are missing per match.
* Only checks completed (Ended) football/basketball matches.
*/
async getMissingScopes(
matchIds: string[],
): Promise<Map<string, string[]>> {
async getMissingScopes(matchIds: string[]): Promise<Map<string, string[]>> {
const result = new Map<string, string[]>();
if (matchIds.length === 0) return result;
+9 -4
View File
@@ -324,8 +324,8 @@ export class FeederService {
const sample = allMatches.slice(0, 3);
this.logger.warn(
`[${sport}] [${dateString}] DEBUG: bounds=[${targetDateStartTs}, ${targetDateEndTs}] ` +
`(${new Date(targetDateStartTs * 1000).toISOString()} - ${new Date(targetDateEndTs * 1000).toISOString()}) | ` +
`sampleMstUtc=[${sample.map((m) => `${m.mstUtc} (asSec=${new Date(m.mstUtc * 1000).toISOString()}, asMs=${new Date(m.mstUtc).toISOString()})`).join(', ')}]`,
`(${new Date(targetDateStartTs * 1000).toISOString()} - ${new Date(targetDateEndTs * 1000).toISOString()}) | ` +
`sampleMstUtc=[${sample.map((m) => `${m.mstUtc} (asSec=${new Date(m.mstUtc * 1000).toISOString()}, asMs=${new Date(m.mstUtc).toISOString()})`).join(", ")}]`,
);
}
@@ -393,7 +393,8 @@ export class FeederService {
// ── Patch incomplete existing matches ──────────────────────
// Find matches that ARE in DB but have missing data scopes
const allExistingInDb = await this.persistenceService.getMissingScopes(allIds);
const allExistingInDb =
await this.persistenceService.getMissingScopes(allIds);
if (allExistingInDb.size > 0) {
this.logger.log(
`[${sport}] [${dateString}] 🔧 Found ${allExistingInDb.size} existing matches with missing data. Patching...`,
@@ -407,7 +408,11 @@ export class FeederService {
await this.delay(500);
try {
const patchScope: "all" | "lineups" | "odds" =
scope === "odds" ? "odds" : scope === "lineups" ? "lineups" : "all";
scope === "odds"
? "odds"
: scope === "lineups"
? "lineups"
: "all";
const result = await this.processSingleMatch(
matchSummary,
+2 -1
View File
@@ -94,7 +94,8 @@ export class HealthController {
} catch (error: unknown) {
return {
status: "down",
detail: error instanceof Error ? error.message : "Unknown database error",
detail:
error instanceof Error ? error.message : "Unknown database error",
};
}
}
+19 -4
View File
@@ -165,9 +165,24 @@ export class LeaguesController {
},
})
@ApiParam({ name: "id", description: "Team ID" })
@ApiQuery({ name: "page", required: false, type: Number, description: "Page number (default: 1)" })
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page (default: 20)" })
@ApiQuery({ name: "season", required: false, type: String, description: "Season (e.g. 2024-2025)" })
@ApiQuery({
name: "page",
required: false,
type: Number,
description: "Page number (default: 1)",
})
@ApiQuery({
name: "limit",
required: false,
type: Number,
description: "Items per page (default: 20)",
})
@ApiQuery({
name: "season",
required: false,
type: String,
description: "Season (e.g. 2024-2025)",
})
async getTeamMatches(
@Param("id") id: string,
@Query("page") page?: string,
@@ -178,7 +193,7 @@ export class LeaguesController {
id,
parseInt(page || "1", 10),
parseInt(limit || "20", 10),
season
season,
);
}
+6 -4
View File
@@ -105,7 +105,7 @@ export class LeaguesService {
teamId: string,
page: number = 1,
limit: number = 20,
season?: string
season?: string,
) {
const skip = (page - 1) * limit;
const where: any = {
@@ -118,13 +118,15 @@ export class LeaguesService {
if (parts.length === 2) {
const startYear = parseInt(parts[0], 10);
const endYear = parseInt(parts[1], 10);
if (!isNaN(startYear) && !isNaN(endYear)) {
// Season starts August 1st of startYear
const startDate = new Date(Date.UTC(startYear, 7, 1)).getTime();
// Season ends July 31st of endYear
const endDate = new Date(Date.UTC(endYear, 6, 31, 23, 59, 59, 999)).getTime();
const endDate = new Date(
Date.UTC(endYear, 6, 31, 23, 59, 59, 999),
).getTime();
where.mstUtc = {
gte: startDate,
lte: endDate,
+25 -14
View File
@@ -587,12 +587,19 @@ export class MatchesService {
// Fill missing relations with empty arrays
teamStats: [],
playerParticipations: (() => {
const parsed: Array<{ teamId: string; isStarting: boolean; shirtNumber: string | number | null; position: string | null; player: { id: string; name: string } }> = [];
const canTrustFeedLineups = displayStatus === "LIVE" || displayStatus === "Finished";
const parsed: Array<{
teamId: string;
isStarting: boolean;
shirtNumber: string | number | null;
position: string | null;
player: { id: string; name: string };
}> = [];
const canTrustFeedLineups =
displayStatus === "LIVE" || displayStatus === "Finished";
if (!canTrustFeedLineups) {
return parsed;
}
if (liveMatch.lineups && typeof liveMatch.lineups === 'object') {
if (liveMatch.lineups && typeof liveMatch.lineups === "object") {
const lu = liveMatch.lineups as Record<string, any>;
const addPlayers = (teamLu: any, teamId: string | null) => {
if (!teamLu || !teamId) return;
@@ -603,7 +610,11 @@ export class MatchesService {
isStarting: true,
shirtNumber: p.shirtNumber || p.number,
position: p.position || p.pos,
player: { id: p.personId || p.id || p.playerId || 'unknown', name: p.matchName || p.name || p.playerName || 'Bilinmiyor' }
player: {
id: p.personId || p.id || p.playerId || "unknown",
name:
p.matchName || p.name || p.playerName || "Bilinmiyor",
},
});
});
}
@@ -614,7 +625,11 @@ export class MatchesService {
isStarting: false,
shirtNumber: p.shirtNumber || p.number,
position: p.position || p.pos,
player: { id: p.personId || p.id || p.playerId || 'unknown', name: p.matchName || p.name || p.playerName || 'Bilinmiyor' }
player: {
id: p.personId || p.id || p.playerId || "unknown",
name:
p.matchName || p.name || p.playerName || "Bilinmiyor",
},
});
});
}
@@ -641,7 +656,8 @@ export class MatchesService {
scoreHome: match.scoreHome,
scoreAway: match.scoreAway,
});
const canTrustStoredLineups = this.canTrustStoredLineups(detailDisplayStatus);
const canTrustStoredLineups =
this.canTrustStoredLineups(detailDisplayStatus);
if (Array.isArray(match.playerParticipations)) {
if (!canTrustStoredLineups) {
@@ -865,9 +881,7 @@ export class MatchesService {
if (!rows.length) return [];
const latestMst = Math.max(
...rows.map((row) => Number(row.mstUtc || 0)),
);
const latestMst = Math.max(...rows.map((row) => Number(row.mstUtc || 0)));
const ageDays =
latestMst > 0
? (beforeDateMs - latestMst) / (24 * 60 * 60 * 1000)
@@ -901,8 +915,7 @@ export class MatchesService {
const rank = matchOrder.get(String(row.matchId)) ?? matchLimit;
const recencyWeight = Math.max(1, matchLimit - rank);
const score =
recencyWeight + (rank === 0 ? 3 : rank === 1 ? 1.5 : 0);
const score = recencyWeight + (rank === 0 ? 3 : rank === 1 ? 1.5 : 0);
const existing = playerMap.get(playerId);
if (!existing) {
@@ -996,9 +1009,7 @@ export class MatchesService {
private canTrustStoredLineups(displayStatus?: string): boolean {
const normalized = String(displayStatus || "").toLowerCase();
return (
normalized === "live" ||
normalized === "finished" ||
normalized === "ft"
normalized === "live" || normalized === "finished" || normalized === "ft"
);
}
}
+152 -62
View File
@@ -21,6 +21,10 @@ import {
} from "./dto";
import { Prisma } from "@prisma/client";
import { FeederService } from "../feeder/feeder.service";
import {
isMatchCompleted,
isMatchLive,
} from "../../common/utils/match-status.util";
import * as fs from "node:fs";
import * as path from "node:path";
import {
@@ -49,7 +53,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private queueEvents: QueueEvents | null = null;
private readonly aiEngineUrl: string;
private readonly aiEngineClient: AiEngineClient;
private readonly topLeagueIds = new Set<string>();
private readonly qualifiedLeagueIds = new Set<string>();
private readonly reasonTranslations: Record<string, string> = {
confidence_below_threshold: "Güven eşiğin altında",
confidence_interval_too_wide: "Güven aralığı çok geniş",
@@ -137,7 +141,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
maxRetries: 2,
retryDelayMs: 750,
});
this.topLeagueIds = this.loadTopLeagueIds();
this.qualifiedLeagueIds = this.loadQualifiedLeagueIds();
}
onModuleInit() {
@@ -155,6 +159,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
}
private predictionMemCache = new Map<
string,
{ timestamp: number; payload: MatchPredictionDto }
>();
async onModuleDestroy() {
if (this.queueEvents) {
await this.queueEvents.close();
@@ -177,8 +186,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return {
status: response.data?.status || "healthy",
modelLoaded: response.data?.model_loaded ?? true,
predictionServiceReady:
response.data?.prediction_service_ready ?? true,
predictionServiceReady: response.data?.prediction_service_ready ?? true,
aiEngineReachable: true,
circuitState: circuit.state,
consecutiveFailures: circuit.consecutiveFailures,
@@ -330,33 +338,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
match_date_ms: Number(p.match.mstUtc) * 1000,
league: p.match.league?.name || "",
league_id: p.match.leagueId,
is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ""),
is_top_league: this.qualifiedLeagueIds.has(p.match.leagueId ?? ""),
},
} as unknown as MatchPredictionDto;
}),
};
}
private loadTopLeagueIds(): Set<string> {
private loadQualifiedLeagueIds(): Set<string> {
try {
const topLeaguesPath = path.join(process.cwd(), "top_leagues.json");
if (!fs.existsSync(topLeaguesPath)) {
const filePath = path.join(process.cwd(), "qualified_leagues.json");
if (!fs.existsSync(filePath)) {
this.logger.warn(
"qualified_leagues.json not found — all leagues allowed",
);
return new Set<string>();
}
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
if (!Array.isArray(raw)) {
return new Set<string>();
}
return new Set(
const ids = new Set(
raw
.map((value) => String(value).trim())
.filter((value) => value.length > 0),
);
this.logger.log(`Loaded ${ids.size} qualified league IDs`);
return ids;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to load top_leagues.json: ${message}`);
this.logger.warn(`Failed to load qualified_leagues.json: ${message}`);
return new Set<string>();
}
}
@@ -370,7 +383,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (match) {
return {
leagueId: match.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(match.leagueId ?? ""),
isTopLeague: this.qualifiedLeagueIds.has(match.leagueId ?? ""),
};
}
@@ -381,7 +394,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return {
leagueId: liveMatch?.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ""),
isTopLeague: this.qualifiedLeagueIds.has(liveMatch?.leagueId ?? ""),
};
}
@@ -731,20 +744,20 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return this.reasonTranslations[normalized];
}
const evMatch = normalized.match(/^ev_edge_([+\-][\d.]+%)_grade_(\w)$/);
const evMatch = normalized.match(/^ev_edge_([-+][\d.]+%)_grade_(\w)$/);
if (evMatch) {
return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`;
}
const negativeEdgeMatch = normalized.match(
/^negative_model_edge_([+\-]?[\d.]+)$/,
/^negative_model_edge_([-+]?[\d.]+)$/,
);
if (negativeEdgeMatch) {
return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
}
const edgeThresholdMatch = normalized.match(
/^below_market_edge_threshold_([+\-]?[\d.]+)$/,
/^below_market_edge_threshold_([-+]?[\d.]+)$/,
);
if (edgeThresholdMatch) {
return `Piyasa avantaj eşiğinin altında (${edgeThresholdMatch[1]})`;
@@ -1071,10 +1084,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
// Direct HTTP mode
try {
const response = await this.aiEngineClient.post(
"/smart-coupon",
{ match_ids: matchIds, strategy, ...options },
);
const response = await this.aiEngineClient.post("/smart-coupon", {
match_ids: matchIds,
strategy,
...options,
});
return response.data;
} catch (error: unknown) {
const message =
@@ -1130,8 +1144,26 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
async cachePrediction(matchId: string, prediction: MatchPredictionDto) {
this.predictionMemCache.set(matchId, {
timestamp: Date.now(),
payload: prediction,
});
if (this.predictionMemCache.size > 500) {
const firstKey = this.predictionMemCache.keys().next().value;
if (firstKey) this.predictionMemCache.delete(firstKey);
}
const payload = prediction as unknown as Prisma.InputJsonObject;
try {
const existsInMatch = await this.prisma.match.findUnique({
where: { id: matchId },
select: { id: true },
});
if (!existsInMatch) {
return;
}
await this.prisma.prediction.upsert({
where: { matchId },
update: {
@@ -1151,6 +1183,16 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async getCachedPrediction(
matchId: string,
): Promise<MatchPredictionDto | null> {
const memCached = this.predictionMemCache.get(matchId);
if (memCached) {
if (Date.now() - memCached.timestamp < 10 * 60 * 1000) {
// 10 mins TTL
return memCached.payload;
} else {
this.predictionMemCache.delete(matchId);
}
}
const prediction = await this.prisma.prediction.findUnique({
where: { matchId },
});
@@ -1216,32 +1258,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
private async ensurePredictionDataReady(matchId: string): Promise<void> {
const [liveMatch, persistedMatch, oddCategoryCount] = await Promise.all([
this.prisma.liveMatch.findUnique({
where: { id: matchId },
select: {
id: true,
odds: true,
state: true,
status: true,
scoreHome: true,
scoreAway: true,
},
}),
this.prisma.match.findUnique({
where: { id: matchId },
select: {
id: true,
state: true,
status: true,
scoreHome: true,
scoreAway: true,
},
}),
this.prisma.oddCategory.count({
where: { matchId },
}),
]);
const [liveMatch, persistedMatch, oddCategoryCount, lineupCount] =
await Promise.all([
this.prisma.liveMatch.findUnique({
where: { id: matchId },
select: {
id: true,
odds: true,
state: true,
status: true,
scoreHome: true,
scoreAway: true,
leagueId: true,
},
}),
this.prisma.match.findUnique({
where: { id: matchId },
select: {
id: true,
state: true,
status: true,
scoreHome: true,
scoreAway: true,
leagueId: true,
},
}),
this.prisma.oddCategory.count({
where: { matchId },
}),
this.prisma.matchPlayerParticipation.count({
where: { matchId },
}),
]);
const hasLiveOdds =
!!liveMatch?.odds &&
@@ -1257,27 +1305,68 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
);
}
// League qualification gate: reject predictions for leagues without
// sufficient historical training data (odds + lineups + stats)
const leagueId = liveMatch?.leagueId || persistedMatch?.leagueId;
if (
this.qualifiedLeagueIds.size > 0 &&
(!leagueId || !this.qualifiedLeagueIds.has(leagueId))
) {
throw new HttpException(
`Bu lig için yeterli geçmiş veri bulunmuyor. Tahmin yapılamaz.`,
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
const state = liveMatch?.state || persistedMatch?.state;
const status = liveMatch?.status || persistedMatch?.status;
const scoreHome = liveMatch?.scoreHome ?? persistedMatch?.scoreHome;
const scoreAway = liveMatch?.scoreAway ?? persistedMatch?.scoreAway;
const hasScores =
scoreHome !== null &&
scoreHome !== undefined &&
scoreAway !== null &&
scoreAway !== undefined;
const isFinished =
hasScores ||
state === "MS" ||
state === "postGame" ||
["Finished", "Played", "FT", "AET", "PEN", "Ended"].includes(
status as string,
);
const isFinished = isMatchCompleted({
state: state ?? null,
status: status ?? null,
scoreHome,
scoreAway,
});
const isLive = isMatchLive({
state: state ?? null,
status: status ?? null,
});
const hasOdds = hasLiveOdds || oddCategoryCount > 0;
if (hasOdds || isFinished) {
if (hasOdds || isFinished || isLive) {
// ── Lineup guard: fetch lineups if missing before analysis ──
// A proper football lineup has at least 11 starting players (22 total
// with subs). If we have fewer than 11 participation records, the
// lineup data is likely missing — attempt to fetch it from source.
if (lineupCount < 11) {
this.logger.log(
`[${matchId}] ⚠️ Lineups missing (${lineupCount} players in DB). Fetching from source before analysis...`,
);
try {
const refreshResult = await this.feederService.refreshMatch(
matchId,
"lineups",
);
if (refreshResult.success) {
this.logger.log(
`[${matchId}] ✅ Lineups fetched successfully before analysis`,
);
} else {
this.logger.warn(
`[${matchId}] ⚠️ Lineup fetch returned failure — proceeding with existing data. Error: ${refreshResult.error ?? "unknown"}`,
);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`[${matchId}] ⚠️ Lineup fetch exception — proceeding with existing data. ${message}`,
);
}
}
return;
}
@@ -1315,7 +1404,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Prediction run audit skipped for ${matchId}: ${message}`);
this.logger.warn(
`Prediction run audit skipped for ${matchId}: ${message}`,
);
}
}
@@ -1397,8 +1488,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
: null,
bet_advice: {
playable: payload.bet_advice?.playable ?? false,
suggested_stake_units:
payload.bet_advice?.suggested_stake_units ?? 0,
suggested_stake_units: payload.bet_advice?.suggested_stake_units ?? 0,
reason: payload.bet_advice?.reason ?? null,
},
top_summary: topSummary,
@@ -6,6 +6,7 @@ import axios from "axios";
let createCanvas: any;
let loadImage: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const canvas = require("canvas");
createCanvas = canvas.createCanvas;
loadImage = canvas.loadImage;
@@ -397,7 +398,7 @@ export class ImageRendererService implements OnModuleInit {
ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
ctx.font = "700 26px sans-serif";
ctx.textAlign = "left";
ctx.fillText("⚡ AI Powered by SuggestBet", paddingX, currentY);
ctx.fillText("⚡ AI Powered by iddaai.com", paddingX, currentY);
let riskBg, riskColor, riskBorder;
switch (data.riskLevel) {