v26-shadow #2

Merged
fahricansecer merged 4 commits from v26-shadow into main 2026-04-23 22:29:24 +03:00
25 changed files with 1639 additions and 1076 deletions
Showing only changes of commit 1346924387 - Show all commits
+330 -695
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
import { UserRole } from "@prisma/client";
export const APP_ROLES = {
user: UserRole.user,
superadmin: UserRole.superadmin,
} as const;
export const ADMIN_ROLES = [APP_ROLES.superadmin] as const;
export function normalizeRole(role: string | null | undefined): string {
return role?.trim().toLowerCase() ?? "";
}
+267
View File
@@ -0,0 +1,267 @@
import axios, {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from "axios";
import { Logger } from "@nestjs/common";
export type AiCircuitState = "closed" | "open";
export interface AiEngineClientOptions {
baseUrl: string;
logger: Logger;
serviceName: string;
timeoutMs?: number;
maxRetries?: number;
retryDelayMs?: number;
circuitBreakerThreshold?: number;
circuitBreakerCooldownMs?: number;
}
interface AiEngineRequestConfig extends AxiosRequestConfig {
retryCount?: number;
}
export interface AiEngineClientSnapshot {
state: AiCircuitState;
consecutiveFailures: number;
openedAt: string | null;
}
export class AiEngineRequestError extends Error {
status?: number;
detail?: unknown;
isCircuitOpen: boolean;
constructor(
message: string,
options: {
status?: number;
detail?: unknown;
isCircuitOpen?: boolean;
} = {},
) {
super(message);
this.name = "AiEngineRequestError";
this.status = options.status;
this.detail = options.detail;
this.isCircuitOpen = options.isCircuitOpen ?? false;
}
}
export class AiEngineClient {
private readonly axiosClient: AxiosInstance;
private readonly logger: Logger;
private readonly serviceName: string;
private readonly defaultTimeoutMs: number;
private readonly maxRetries: number;
private readonly retryDelayMs: number;
private readonly circuitBreakerThreshold: number;
private readonly circuitBreakerCooldownMs: number;
private consecutiveFailures = 0;
private circuitOpenedAt: number | null = null;
constructor(options: AiEngineClientOptions) {
this.logger = options.logger;
this.serviceName = options.serviceName;
this.defaultTimeoutMs = options.timeoutMs ?? 30000;
this.maxRetries = options.maxRetries ?? 2;
this.retryDelayMs = options.retryDelayMs ?? 750;
this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 3;
this.circuitBreakerCooldownMs =
options.circuitBreakerCooldownMs ?? 30000;
this.axiosClient = axios.create({
baseURL: options.baseUrl,
timeout: this.defaultTimeoutMs,
});
}
async get<T>(
path: string,
config?: AiEngineRequestConfig,
): Promise<AxiosResponse<T>> {
return this.request<T>({
method: "get",
url: path,
...config,
});
}
async post<T>(
path: string,
data?: unknown,
config?: AiEngineRequestConfig,
): Promise<AxiosResponse<T>> {
return this.request<T>({
method: "post",
url: path,
data,
...config,
});
}
getSnapshot(): AiEngineClientSnapshot {
return {
state: this.isCircuitOpen() ? "open" : "closed",
consecutiveFailures: this.consecutiveFailures,
openedAt: this.circuitOpenedAt
? new Date(this.circuitOpenedAt).toISOString()
: null,
};
}
private async request<T>(config: AiEngineRequestConfig): Promise<AxiosResponse<T>> {
this.ensureCircuitAvailable();
const retries = this.resolveRetryCount(config);
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const response = await this.axiosClient.request<T>({
timeout: this.defaultTimeoutMs,
...config,
});
this.resetFailures();
return response;
} catch (error) {
lastError = error;
const shouldRetry = attempt < retries && this.isRetriableError(error);
if (!shouldRetry) {
this.registerFailure(error);
throw this.toRequestError(error);
}
this.logger.warn(
`[${this.serviceName}] AI request retry ${attempt + 1}/${retries} for ${config.method?.toUpperCase()} ${config.url}`,
);
await this.delay(this.retryDelayMs * (attempt + 1));
}
}
this.registerFailure(lastError);
throw this.toRequestError(lastError);
}
private resolveRetryCount(config: AiEngineRequestConfig): number {
if (typeof config.retryCount === "number" && config.retryCount >= 0) {
return config.retryCount;
}
return this.maxRetries;
}
private ensureCircuitAvailable() {
if (!this.isCircuitOpen()) {
return;
}
const remainingCooldown =
this.circuitBreakerCooldownMs - (Date.now() - (this.circuitOpenedAt ?? 0));
if (remainingCooldown > 0) {
throw new AiEngineRequestError("AI engine circuit breaker is open", {
status: 503,
detail: {
cooldownRemainingMs: remainingCooldown,
},
isCircuitOpen: true,
});
}
this.logger.warn(
`[${this.serviceName}] AI circuit breaker cooldown elapsed, allowing a recovery attempt`,
);
this.circuitOpenedAt = null;
}
private isCircuitOpen(): boolean {
return this.circuitOpenedAt !== null;
}
private resetFailures() {
this.consecutiveFailures = 0;
this.circuitOpenedAt = null;
}
private registerFailure(error: unknown) {
this.consecutiveFailures += 1;
const normalizedError = this.toRequestError(error);
this.logger.warn(
`[${this.serviceName}] AI request failed (${this.consecutiveFailures}/${this.circuitBreakerThreshold}): ${normalizedError.message}`,
);
if (this.consecutiveFailures >= this.circuitBreakerThreshold) {
this.circuitOpenedAt = Date.now();
this.logger.error(
`[${this.serviceName}] AI circuit breaker opened after ${this.consecutiveFailures} consecutive failures`,
);
}
}
private isRetriableError(error: unknown): boolean {
if (!axios.isAxiosError(error)) {
return false;
}
if (!error.response) {
return true;
}
const status = error.response.status;
return status >= 500 || status === 429 || error.code === "ECONNABORTED";
}
private toRequestError(error: unknown): AiEngineRequestError {
if (error instanceof AiEngineRequestError) {
return error;
}
if (axios.isAxiosError(error)) {
const detail = error.response?.data ?? error.message;
const status = error.response?.status;
const message = this.buildAxiosErrorMessage(error);
return new AiEngineRequestError(message, {
status,
detail,
});
}
if (error instanceof Error) {
return new AiEngineRequestError(error.message);
}
return new AiEngineRequestError("Unknown AI engine error", {
detail: error,
});
}
private buildAxiosErrorMessage(error: AxiosError): string {
if (error.code === "ECONNABORTED") {
return "AI engine request timed out";
}
if (!error.response) {
return "AI engine is unreachable";
}
const detail =
(error.response.data as Record<string, unknown> | undefined)?.detail ??
error.message;
return typeof detail === "string"
? detail
: `AI engine request failed with status ${error.response.status}`;
}
private async delay(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
}
+203
View File
@@ -0,0 +1,203 @@
type ScoreLikeValue = number | string | null | undefined;
type ScoreLike = {
home?: ScoreLikeValue;
away?: ScoreLikeValue;
} | null;
export interface MatchStatusLike {
state?: string | null;
status?: string | null;
substate?: string | null;
statusBoxContent?: string | null;
scoreHome?: ScoreLikeValue;
scoreAway?: ScoreLikeValue;
score?: ScoreLike;
}
const LIVE_STATUS_TOKENS = [
"live",
"livegame",
"playing",
"half time",
"halftime",
"1h",
"2h",
"ht",
"1q",
"2q",
"3q",
"4q",
];
const LIVE_STATE_TOKENS = [
"live",
"livegame",
"firsthalf",
"secondhalf",
"halftime",
"1h",
"2h",
"ht",
"1q",
"2q",
"3q",
"4q",
];
const FINISHED_STATUS_TOKENS = [
"finished",
"played",
"ft",
"aet",
"pen",
"penalties",
"afterpenalties",
"ended",
"post",
"postgame",
"posted",
];
const FINISHED_STATE_TOKENS = [
"finished",
"post",
"postgame",
"posted",
"ft",
"ended",
];
export const LIVE_STATUS_VALUES_FOR_DB = [
"LIVE",
"live",
"1H",
"2H",
"HT",
"1Q",
"2Q",
"3Q",
"4Q",
"Playing",
"Half Time",
"liveGame",
];
export const LIVE_STATE_VALUES_FOR_DB = [
"live",
"liveGame",
"firsthalf",
"secondhalf",
"halfTime",
"1H",
"2H",
"HT",
"1Q",
"2Q",
"3Q",
"4Q",
];
export const FINISHED_STATUS_VALUES_FOR_DB = [
"Finished",
"Played",
"FT",
"AET",
"PEN",
"Ended",
"post",
"postGame",
"posted",
"Posted",
];
export const FINISHED_STATE_VALUES_FOR_DB = [
"Finished",
"post",
"postGame",
"postgame",
"posted",
"FT",
"Ended",
];
function normalizeToken(value: unknown): string {
return String(value || "")
.trim()
.toLowerCase();
}
function parseScoreValue(value: ScoreLikeValue): number | null {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
export function hasResolvedScore(match: MatchStatusLike): boolean {
const homeScore = parseScoreValue(match.score?.home ?? match.scoreHome);
const awayScore = parseScoreValue(match.score?.away ?? match.scoreAway);
return homeScore !== null && awayScore !== null;
}
export function isMatchLive(match: MatchStatusLike): boolean {
const state = normalizeToken(match.state);
const status = normalizeToken(match.status);
const substate = normalizeToken(match.substate);
return (
LIVE_STATE_TOKENS.includes(state) ||
LIVE_STATUS_TOKENS.includes(status) ||
LIVE_STATE_TOKENS.includes(substate)
);
}
export function isMatchCompleted(match: MatchStatusLike): boolean {
if (normalizeToken(match.statusBoxContent) === "ert") {
return false;
}
const state = normalizeToken(match.state);
const status = normalizeToken(match.status);
const substate = normalizeToken(match.substate);
if (
FINISHED_STATE_TOKENS.includes(state) ||
FINISHED_STATUS_TOKENS.includes(status) ||
FINISHED_STATE_TOKENS.includes(substate)
) {
return true;
}
return hasResolvedScore(match) && !isMatchLive(match);
}
export function deriveStoredMatchStatus(match: MatchStatusLike): string {
if (normalizeToken(match.statusBoxContent) === "ert") {
return "POSTPONED";
}
if (isMatchLive(match)) {
return "LIVE";
}
if (isMatchCompleted(match)) {
return "FT";
}
return "NS";
}
export function getDisplayMatchStatus(match: MatchStatusLike): string {
if (isMatchLive(match)) {
return "LIVE";
}
if (isMatchCompleted(match)) {
return "Finished";
}
return String(match.status || match.state || "NS");
}
+82
View File
@@ -0,0 +1,82 @@
function extractDateParts(date: Date, timeZone: string) {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const parts = formatter.formatToParts(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);
return { year, month, day };
}
export function getDateStringInTimeZone(
date: Date,
timeZone: string,
): string {
const { year, month, day } = extractDateParts(date, timeZone);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
export function getShiftedDateStringInTimeZone(
daysOffset: number,
timeZone: string,
baseDate: Date = new Date(),
): string {
const { year, month, day } = extractDateParts(baseDate, timeZone);
const shifted = new Date(Date.UTC(year, month - 1, day));
shifted.setUTCDate(shifted.getUTCDate() + daysOffset);
return shifted.toISOString().split("T")[0];
}
function 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;
}
export function getDayBoundsForTimeZone(
dateString: string,
timeZone: string,
): { startMs: number; endMs: 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 = getTimeZoneOffsetMs(startGuess, timeZone);
const nextDayOffsetMs = 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 {
startMs,
endMs: nextDayStartMs - 1,
};
}
export function getDateOnlyValueForTimeZone(
timeZone: string,
date: Date = new Date(),
): Date {
return new Date(`${getDateStringInTimeZone(date, timeZone)}T00:00:00.000Z`);
}
+6
View File
@@ -259,15 +259,21 @@ export class AdminController {
premiumUsers,
totalMatches,
totalPredictions,
totalCoupons,
] = 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(),
this.prisma.userCoupon.count(),
]);
return createSuccessResponse({
totalUsers,
activeUsers,
totalPredictions,
totalCoupons,
users: {
total: totalUsers,
active: activeUsers,
+21 -2
View File
@@ -13,11 +13,13 @@ import {
ROLES_KEY,
PERMISSIONS_KEY,
} from "../../../common/decorators";
import { normalizeRole } from "../../../common/constants/roles";
interface AuthenticatedUser {
id: string;
email: string;
roles: string[];
role?: string;
permissions: string[];
}
@@ -88,11 +90,28 @@ export class RolesGuard implements CanActivate {
const user = req.user as AuthenticatedUser | undefined;
if (!user || !user.roles) {
if (!user) {
return false;
}
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
const normalizedUserRoles = (user.roles?.length
? user.roles
: user.role
? [user.role]
: []
).map((role) => normalizeRole(role));
const normalizedRequiredRoles = requiredRoles.map((role) =>
normalizeRole(role),
);
if (normalizedUserRoles.length === 0) {
return false;
}
const hasRole = normalizedRequiredRoles.some((role) =>
normalizedUserRoles.includes(role),
);
if (!hasRole) {
throw new ForbiddenException("PERMISSION_DENIED");
}
+6 -1
View File
@@ -3,6 +3,7 @@ import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
import { AuthService, JwtPayload } from "../auth.service";
import { normalizeRole } from "../../../common/constants/roles";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -29,9 +30,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
return null;
}
const normalizedRole = normalizeRole(payload.role);
return {
...user,
role: payload.role,
role: normalizedRole,
roles: normalizedRole ? [normalizedRole] : [],
permissions: [],
};
}
}
@@ -1,6 +1,9 @@
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
import axios from "axios";
import { GeminiService } from "../../gemini/gemini.service";
import {
AiEngineClient,
AiEngineRequestError,
} from "../../../common/utils/ai-engine-client";
export type PredictionRiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
export type PredictionDataQuality = "HIGH" | "MEDIUM" | "LOW";
@@ -126,24 +129,34 @@ export interface SmartCouponResult {
export class SmartCouponService {
private readonly logger = new Logger(SmartCouponService.name);
private readonly aiEngineUrl: string;
private readonly aiEngineClient: AiEngineClient;
constructor(private readonly geminiService: GeminiService) {
this.aiEngineUrl = process.env.AI_ENGINE_URL || "http://ai-engine:8000";
this.aiEngineClient = new AiEngineClient({
baseUrl: this.aiEngineUrl,
logger: this.logger,
serviceName: SmartCouponService.name,
timeoutMs: 60000,
maxRetries: 2,
retryDelayMs: 750,
});
}
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
let prediction: SingleMatchPredictionPackage;
try {
const response = await axios.post<SingleMatchPredictionPackage>(
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
const response = await this.aiEngineClient.post<SingleMatchPredictionPackage>(
`/v20plus/analyze/${matchId}`,
);
prediction = response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const detail = error.response?.data?.detail || error.message;
} catch (error: unknown) {
if (error instanceof AiEngineRequestError) {
const detail =
typeof error.detail === "string" ? error.detail : error.message;
throw new HttpException(
`AI analyze failed: ${detail}`,
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
error.status || HttpStatus.SERVICE_UNAVAILABLE,
);
}
throw new HttpException(
@@ -205,8 +218,8 @@ export class SmartCouponService {
options: { maxMatches?: number; minConfidence?: number } = {},
): Promise<SmartCouponResult> {
try {
const response = await axios.post<SmartCouponResult>(
`${this.aiEngineUrl}/v20plus/coupon`,
const response = await this.aiEngineClient.post<SmartCouponResult>(
"/v20plus/coupon",
{
match_ids: matchIds,
strategy,
@@ -215,13 +228,14 @@ export class SmartCouponService {
},
);
return response.data;
} catch (error) {
} catch (error: unknown) {
this.logger.error("Failed to generate smart coupon", error);
if (axios.isAxiosError(error)) {
const detail = error.response?.data?.detail || error.message;
if (error instanceof AiEngineRequestError) {
const detail =
typeof error.detail === "string" ? error.detail : error.message;
throw new HttpException(
`Coupon generation failed: ${detail}`,
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
error.status || HttpStatus.SERVICE_UNAVAILABLE,
);
}
throw new HttpException(
@@ -22,6 +22,7 @@ import {
BasketballTeamStats,
} from "./feeder.types";
import { ImageUtils } from "../../common/utils/image.util";
import { deriveStoredMatchStatus } from "../../common/utils/match-status.util";
@Injectable()
export class FeederPersistenceService {
@@ -311,33 +312,15 @@ export class FeederPersistenceService {
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";
}
const status = deriveStoredMatchStatus({
state: headerData?.matchStatus ?? matchSummary.state,
status: matchSummary.status,
substate: matchSummary.substate,
statusBoxContent: matchSummary.statusBoxContent,
scoreHome: finalScoreHome,
scoreAway: finalScoreAway,
score: matchSummary.score,
});
await tx.match.upsert({
where: { id: matchId },
@@ -870,15 +853,11 @@ export class FeederPersistenceService {
}
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: {} } },
+31 -50
View File
@@ -24,6 +24,7 @@ import {
DbEventPayload,
DbMarketPayload,
} from "./feeder.types";
import { isMatchCompleted } from "../../common/utils/match-status.util";
interface ProcessDateOptions {
onlyCompletedMatches?: boolean;
@@ -113,51 +114,16 @@ export class FeederService {
};
}
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;
return isMatchCompleted({
state: match.state,
status: match.status,
substate: match.substate,
statusBoxContent: match.statusBoxContent,
score: match.score,
scoreHome: match.homeScore,
scoreAway: match.awayScore,
});
}
async runPreviousDayCompletedMatchesScan(
@@ -957,15 +923,30 @@ export class FeederService {
*/
// ==========================================
if (saved && hasCriticalError) {
// Collect missing components
const missingParts: string[] = [];
if (!stats) missingParts.push("Stats");
const completedMatch = isMatchCompleted({
state: headerData?.matchStatus ?? matchSummary.state,
status: matchSummary.status,
substate: matchSummary.substate,
statusBoxContent: matchSummary.statusBoxContent,
scoreHome: headerData?.scoreHome ?? matchSummary.score?.home,
scoreAway: headerData?.scoreAway ?? matchSummary.score?.away,
});
const missingParts: string[] = [];
if (scope === "all" && completedMatch) {
if (sport === "football" && !stats) missingParts.push("Stats");
if (sport === "basketball" && !basketballTeamStats)
missingParts.push("BoxScore");
if (oddsArray.length === 0) missingParts.push("Odds");
if (officialsData.length === 0) missingParts.push("Officials");
}
if (saved && (hasCriticalError || missingParts.length > 0)) {
const reason = hasCriticalError
? "missing data after upstream errors"
: "incomplete completed-match payload";
this.logger.warn(
`[${matchId}] Saved with MISSING DATA (502). Missing: [${missingParts.join(", ")}]. Scheduled for retry.`,
`[${matchId}] Saved with ${reason}. Missing: [${missingParts.join(", ")}]. Scheduled for retry.`,
);
return { success: false, retryable: true };
}
+64 -18
View File
@@ -1,44 +1,90 @@
import { Controller, Get } from "@nestjs/common";
import { Controller, Get, Res } from "@nestjs/common";
import { ApiTags, ApiOperation } from "@nestjs/swagger";
import {
HealthCheck,
HealthCheckService,
PrismaHealthIndicator,
} from "@nestjs/terminus";
import { Response } from "express";
import { Public } from "../../common/decorators";
import { PrismaService } from "../../database/prisma.service";
import { PredictionsService } from "../predictions/predictions.service";
@ApiTags("Health")
@Controller("health")
export class HealthController {
constructor(
private health: HealthCheckService,
private prismaHealth: PrismaHealthIndicator,
private prisma: PrismaService,
private readonly predictionsService: PredictionsService,
) {}
@Get()
@Public()
@HealthCheck()
@ApiOperation({ summary: "Basic health check" })
check() {
return this.health.check([]);
async check(@Res() response: Response) {
const database = await this.getDatabaseHealth();
const aiEngine = await this.predictionsService.checkHealth();
const ok = database.status === "up" && aiEngine.predictionServiceReady;
return response.status(ok ? 200 : 503).json({
status: ok ? "ok" : "degraded",
timestamp: new Date().toISOString(),
checks: {
database,
aiEngine,
},
});
}
@Get("ready")
@Public()
@HealthCheck()
@ApiOperation({ summary: "Readiness check (includes database)" })
readiness() {
return this.health.check([
() => this.prismaHealth.pingCheck("database", this.prisma),
]);
async readiness(@Res() response: Response) {
const database = await this.getDatabaseHealth();
const aiEngine = await this.predictionsService.checkHealth();
const ready = database.status === "up" && aiEngine.predictionServiceReady;
return response.status(ready ? 200 : 503).json({
status: ready ? "ready" : "not_ready",
timestamp: new Date().toISOString(),
checks: {
database,
aiEngine,
},
});
}
@Get("live")
@Public()
@ApiOperation({ summary: "Liveness check" })
liveness() {
return { status: "ok", timestamp: new Date().toISOString() };
liveness(@Res() response: Response) {
return response
.status(200)
.json({ status: "ok", timestamp: new Date().toISOString() });
}
@Get("dependencies")
@Public()
@ApiOperation({ summary: "Dependency-level health details" })
async dependencies(@Res() response: Response) {
const database = await this.getDatabaseHealth();
const aiEngine = await this.predictionsService.checkHealth();
return response.status(200).json({
timestamp: new Date().toISOString(),
checks: {
database,
aiEngine,
},
});
}
private async getDatabaseHealth() {
try {
await this.prisma.$queryRaw`SELECT 1`;
return {
status: "up",
};
} catch (error: unknown) {
return {
status: "down",
detail: error instanceof Error ? error.message : "Unknown database error",
};
}
}
}
+2 -4
View File
@@ -1,11 +1,9 @@
import { Module } from "@nestjs/common";
import { TerminusModule } from "@nestjs/terminus";
import { PrismaHealthIndicator } from "@nestjs/terminus";
import { HealthController } from "./health.controller";
import { PredictionsModule } from "../predictions/predictions.module";
@Module({
imports: [TerminusModule],
imports: [PredictionsModule],
controllers: [HealthController],
providers: [PrismaHealthIndicator],
})
export class HealthModule {}
+34 -35
View File
@@ -9,6 +9,13 @@ import {
ActiveLeagueDto,
} from "./dto";
import { Prisma } from "@prisma/client";
import {
FINISHED_STATE_VALUES_FOR_DB,
FINISHED_STATUS_VALUES_FOR_DB,
LIVE_STATE_VALUES_FOR_DB,
LIVE_STATUS_VALUES_FOR_DB,
getDisplayMatchStatus,
} from "../../common/utils/match-status.util";
@Injectable()
export class MatchesService {
@@ -38,23 +45,12 @@ export class MatchesService {
OR: [
{
status: {
in: [
"LIVE",
"1H",
"2H",
"HT",
"1Q",
"2Q",
"3Q",
"4Q",
"Playing",
"Half Time",
],
in: LIVE_STATUS_VALUES_FOR_DB,
},
},
{
state: {
in: ["live", "firsthalf", "secondhalf"],
in: LIVE_STATE_VALUES_FOR_DB,
},
},
],
@@ -66,14 +62,23 @@ export class MatchesService {
OR: [
{
status: {
in: ["Finished", "Played", "FT", "AET", "PEN", "Ended"],
in: FINISHED_STATUS_VALUES_FOR_DB,
},
},
{
state: {
in: ["Finished", "post", "FT", "postGame"],
in: FINISHED_STATE_VALUES_FOR_DB,
},
},
{
AND: [
{ scoreHome: { not: null } },
{ scoreAway: { not: null } },
{
NOT: this.getLiveFilter(),
},
],
},
],
};
}
@@ -325,16 +330,13 @@ export class MatchesService {
}
// 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";
}
const displayStatus = getDisplayMatchStatus({
state: match.state,
status: match.status,
substate: match.substate,
scoreHome: match.scoreHome,
scoreAway: match.scoreAway,
});
league.matches.push({
id: match.id,
@@ -562,16 +564,13 @@ export class MatchesService {
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";
}
const displayStatus = getDisplayMatchStatus({
state: liveMatch.state,
status: liveMatch.status,
substate: liveMatch.substate,
scoreHome: liveMatch.scoreHome,
scoreAway: liveMatch.scoreAway,
});
match = {
...liveMatch,
+15
View File
@@ -461,6 +461,21 @@ export class AIHealthDto {
@ApiProperty()
predictionServiceReady: boolean;
@ApiProperty({ required: false, default: true })
aiEngineReachable?: boolean;
@ApiProperty({ required: false, enum: ["closed", "open"] })
circuitState?: "closed" | "open";
@ApiProperty({ required: false, default: 0 })
consecutiveFailures?: number;
@ApiProperty({ required: false })
endpoint?: string;
@ApiProperty({ required: false, nullable: true })
detail?: string | null;
}
export * from "./smart-coupon.dto";
+80 -21
View File
@@ -19,11 +19,14 @@ import {
ValueBetDto,
AIHealthDto,
} from "./dto";
import axios, { AxiosError } from "axios";
import { Prisma } from "@prisma/client";
import { FeederService } from "../feeder/feeder.service";
import * as fs from "node:fs";
import * as path from "node:path";
import {
AiEngineClient,
AiEngineRequestError,
} from "../../common/utils/ai-engine-client";
type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW";
@@ -45,6 +48,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PredictionsService.name);
private queueEvents: QueueEvents | null = null;
private readonly aiEngineUrl: string;
private readonly aiEngineClient: AiEngineClient;
private readonly topLeagueIds = new Set<string>();
private readonly reasonTranslations: Record<string, string> = {
confidence_below_threshold: "Güven eşiğin altında",
@@ -125,6 +129,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
"AI_ENGINE_URL",
"http://localhost:8000",
);
this.aiEngineClient = new AiEngineClient({
baseUrl: this.aiEngineUrl,
logger: this.logger,
serviceName: PredictionsService.name,
timeoutMs: 60000,
maxRetries: 2,
retryDelayMs: 750,
});
this.topLeagueIds = this.loadTopLeagueIds();
}
@@ -149,12 +161,50 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
}
checkHealth(): Promise<AIHealthDto> {
return Promise.resolve({
status: "healthy",
modelLoaded: true,
predictionServiceReady: true,
});
async checkHealth(): Promise<AIHealthDto> {
const circuit = this.aiEngineClient.getSnapshot();
try {
const response = await this.aiEngineClient.get<{
status?: string;
model_loaded?: boolean;
prediction_service_ready?: boolean;
}>("/health", {
timeout: 5000,
retryCount: 0,
});
return {
status: response.data?.status || "healthy",
modelLoaded: response.data?.model_loaded ?? true,
predictionServiceReady:
response.data?.prediction_service_ready ?? true,
aiEngineReachable: true,
circuitState: circuit.state,
consecutiveFailures: circuit.consecutiveFailures,
endpoint: this.aiEngineUrl,
};
} catch (error: unknown) {
const requestError =
error instanceof AiEngineRequestError
? error
: new AiEngineRequestError("AI health check failed");
return {
status: requestError.isCircuitOpen ? "circuit_open" : "unhealthy",
modelLoaded: false,
predictionServiceReady: false,
aiEngineReachable: false,
circuitState: this.aiEngineClient.getSnapshot().state,
consecutiveFailures:
this.aiEngineClient.getSnapshot().consecutiveFailures,
endpoint: this.aiEngineUrl,
detail:
typeof requestError.detail === "string"
? requestError.detail
: requestError.message,
};
}
}
async getPredictionById(matchId: string): Promise<MatchPredictionDto | null> {
@@ -182,22 +232,21 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
// Direct HTTP mode (no Redis)
try {
const response = await axios.post(
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
const response = await this.aiEngineClient.post<MatchPredictionDto>(
`/v20plus/analyze/${matchId}`,
{},
{ timeout: 60000 },
);
return this.enrichPredictionResponse(
response.data as MatchPredictionDto,
matchContext,
);
} catch (e: unknown) {
const error = e as AxiosError<Record<string, unknown>>;
const status = error?.response?.status;
const detail =
error?.response?.data?.detail ||
error?.response?.data ||
error?.message;
const requestError =
e instanceof AiEngineRequestError
? e
: new AiEngineRequestError("AI Engine request failed");
const status = requestError.status;
const detail = requestError.detail || requestError.message;
this.logger.error(
`Direct AI Engine call failed for ${matchId}: status=${status}, detail=${JSON.stringify(detail)}`,
);
@@ -988,14 +1037,18 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
// Direct HTTP mode
try {
const response = await axios.post(
`${this.aiEngineUrl}/smart-coupon`,
const response = await this.aiEngineClient.post(
"/smart-coupon",
{ match_ids: matchIds, strategy, ...options },
{ timeout: 60000 },
);
return response.data;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
} catch (error: unknown) {
const message =
error instanceof AiEngineRequestError
? error.message
: error instanceof Error
? error.message
: String(error);
this.logger.error(`Direct smart coupon call failed: ${message}`);
this.throwAiError(message);
}
@@ -1018,6 +1071,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
HttpStatus.BAD_GATEWAY,
);
}
if (message.includes("circuit breaker is open")) {
throw new HttpException(
"AI Engine is temporarily unavailable",
HttpStatus.SERVICE_UNAVAILABLE,
);
}
throw new HttpException(
"Failed to get prediction from AI Engine",
HttpStatus.SERVICE_UNAVAILABLE,
@@ -8,7 +8,7 @@ import { RolesGuard } from "../auth/guards/auth.guards";
@ApiTags("Social Poster")
@ApiBearerAuth()
@UseGuards(RolesGuard)
@Roles("admin")
@Roles("superadmin")
@Controller("social-poster")
export class SocialPosterController {
constructor(private readonly socialPosterService: SocialPosterService) {}
@@ -43,7 +43,7 @@ export class SporTotoController {
@Post("sync")
@UseGuards(JwtAuthGuard)
@Roles("admin")
@Roles("superadmin")
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
@@ -114,7 +114,7 @@ export class SporTotoController {
@Post("bulletins")
@UseGuards(JwtAuthGuard)
@Roles("admin")
@Roles("superadmin")
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
@@ -135,7 +135,7 @@ export class SporTotoController {
@Patch("bulletins/:id/results")
@UseGuards(JwtAuthGuard)
@Roles("admin")
@Roles("superadmin")
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
+2 -2
View File
@@ -84,7 +84,7 @@ export class UsersController extends BaseController<
}
// Override create to require admin role
@Roles("admin")
@Roles("superadmin")
async create(
...args: Parameters<
BaseController<User, CreateUserDto, UpdateUserDto>["create"]
@@ -94,7 +94,7 @@ export class UsersController extends BaseController<
}
// Override delete to require admin role
@Roles("admin")
@Roles("superadmin")
async delete(
...args: Parameters<
BaseController<User, CreateUserDto, UpdateUserDto>["delete"]
+29 -16
View File
@@ -1,7 +1,9 @@
import { Injectable, Logger } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { firstValueFrom } from "rxjs";
import {
AiEngineClient,
AiEngineRequestError,
} from "../common/utils/ai-engine-client";
export interface AIPredictionResult {
matchId: string;
@@ -40,13 +42,21 @@ export interface AIPredictionResult {
export class AiService {
private readonly logger = new Logger(AiService.name);
private readonly pythonEngineUrl: string;
private readonly aiEngineClient: AiEngineClient;
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {
this.pythonEngineUrl =
this.configService.get("AI_ENGINE_URL") || "http://127.0.0.1:8000";
this.aiEngineClient = new AiEngineClient({
baseUrl: this.pythonEngineUrl,
logger: this.logger,
serviceName: AiService.name,
timeoutMs: 30000,
maxRetries: 2,
retryDelayMs: 500,
});
}
/**
@@ -71,14 +81,9 @@ export class AiService {
`Calling Python V25 Engine for ${matchDetails.homeTeam} vs ${matchDetails.awayTeam}`,
);
const response = await firstValueFrom(
this.httpService.post(
`${this.pythonEngineUrl}/v20plus/analyze/${matchId}`,
{},
{
timeout: 30000,
},
),
const response = await this.aiEngineClient.post(
`/v20plus/analyze/${matchId}`,
{},
);
if (response.data) {
@@ -86,8 +91,14 @@ export class AiService {
}
return null;
} catch (error: any) {
this.logger.warn(`Python Engine error: ${error.message}`);
} catch (error: unknown) {
const message =
error instanceof AiEngineRequestError
? error.message
: error instanceof Error
? error.message
: "Unknown AI engine error";
this.logger.warn(`Python Engine error: ${message}`);
return null;
}
}
@@ -286,10 +297,12 @@ export class AiService {
*/
async checkHealth(): Promise<boolean> {
try {
const response = await firstValueFrom(
this.httpService.get(`${this.pythonEngineUrl}/health`, {
const response = await this.aiEngineClient.get<{ status?: string }>(
"/health",
{
timeout: 5000,
}),
retryCount: 0,
},
);
return response.data?.status === "healthy";
} catch {
+177 -98
View File
@@ -1,4 +1,4 @@
import { Injectable, Logger } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { HttpService } from "@nestjs/axios";
import { PrismaService } from "../database/prisma.service";
@@ -8,10 +8,22 @@ import * as fs from "fs";
import * as path from "path";
import { Prisma } from "@prisma/client";
import { SidelinedResponse } from "../modules/feeder/feeder.types";
import {
FINISHED_STATE_VALUES_FOR_DB,
FINISHED_STATUS_VALUES_FOR_DB,
LIVE_STATE_VALUES_FOR_DB,
LIVE_STATUS_VALUES_FOR_DB,
} from "../common/utils/match-status.util";
import {
getDateStringInTimeZone,
getDayBoundsForTimeZone,
getShiftedDateStringInTimeZone,
} from "../common/utils/timezone.util";
import { TaskLockService } from "./task-lock.service";
// ────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────
// Types
// ────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────
interface LiveScoreTeamPayload {
id: string;
@@ -64,75 +76,119 @@ interface LiveLineupsJson {
type SportType = "football" | "basketball";
// ────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────
// Service
// ────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────
@Injectable()
export class DataFetcherTask {
private readonly logger = new Logger(DataFetcherTask.name);
private readonly timeZone = "Europe/Istanbul";
constructor(
private readonly httpService: HttpService,
private readonly prisma: PrismaService,
private readonly scraper: FeederScraperService,
private readonly taskLock: TaskLockService,
) {}
// ────────────────────────────────────────────────────────────
// CRON 1: Main sync every 15 minutes
// Phases: match list live scores odds lineups
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
// CRON 1: Main sync — every 15 minutes
// Phases: match list → live scores → odds → lineups
// ────────────────────────────────────────────────────────────
@Cron("*/15 * * * *")
async syncLiveMatches(): Promise<void> {
if (this.shouldSkipInHistoricalMode("syncLiveMatches")) return;
this.logger.log("━━━ syncLiveMatches START ━━━");
const today = new Date().toISOString().split("T")[0];
// Phase 1: Match list (football + basketball)
await this.syncMatchList(today);
// Phase 2: Live score updates
await this.updateLiveScores();
// Phase 3: Odds + referee + lineups + sidelined (via processMatchOdds)
await this.fetchOddsForMatches();
// Phase 4: Fill missing lineups (backup for edge cases)
await this.fillMissingLineups();
this.logger.log("━━━ syncLiveMatches END ━━━");
await this.taskLock.runWithLease(
"syncLiveMatches",
30 * 60 * 1000,
async () => {
await this.runLiveSync();
},
this.logger,
);
}
// ────────────────────────────────────────────────────────────
// CRON 2: Daily cleanup + full sync 07:00 Istanbul
// Truncates live_matches, then runs full sync
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
// CRON 2: Daily cleanup + full sync — 07:00 Istanbul
// Preserve yesterday as a fallback until the 08:00 archive job completes.
// ────────────────────────────────────────────────────────────
@Cron("0 7 * * *", { timeZone: "Europe/Istanbul" })
async cleanAndFullSync(): Promise<void> {
if (this.shouldSkipInHistoricalMode("cleanAndFullSync")) return;
this.logger.log("🧹 cleanAndFullSync: Truncating live_matches...");
await this.taskLock.runWithLease(
"cleanAndFullSync",
2 * 60 * 60 * 1000,
async () => {
this.logger.log(
"cleanAndFullSync: Pruning stale live_matches while preserving yesterday for archive fallback...",
);
try {
const deleted = await this.prisma.liveMatch.deleteMany({});
this.logger.log(
`🧹 Deleted ${deleted.count} live matches. Starting full sync...`,
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Truncate failed: ${message}`);
return;
}
try {
const yesterdayDate = getShiftedDateStringInTimeZone(
-1,
this.timeZone,
);
const { startMs: yesterdayStartMs } = getDayBoundsForTimeZone(
yesterdayDate,
this.timeZone,
);
const cutoffDate = new Date(yesterdayStartMs);
// Run full sync immediately after cleanup
await this.syncLiveMatches();
const deleted = await this.prisma.liveMatch.deleteMany({
where: {
OR: [
{ mstUtc: { lt: BigInt(yesterdayStartMs) } },
{
AND: [
{ mstUtc: null },
{ updatedAt: { lt: cutoffDate } },
{
OR: [
{ status: { in: FINISHED_STATUS_VALUES_FOR_DB } },
{ state: { in: FINISHED_STATE_VALUES_FOR_DB } },
],
},
],
},
],
},
});
this.logger.log(
`Pruned ${deleted.count} stale live matches. Starting full sync...`,
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Stale live_match cleanup failed: ${message}`);
return;
}
await this.runLiveSync();
},
this.logger,
);
}
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
// Phase 1: Fetch match list for all sports
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
private async runLiveSync(): Promise<void> {
if (this.shouldSkipInHistoricalMode("syncLiveMatches")) return;
this.logger.log("syncLiveMatches START");
const today = getDateStringInTimeZone(new Date(), this.timeZone);
await this.syncMatchList(today);
await this.updateLiveScores();
await this.fetchOddsForMatches();
await this.fillMissingLineups();
this.logger.log("syncLiveMatches END");
}
private async syncMatchList(date: string): Promise<void> {
// Football
@@ -141,7 +197,7 @@ export class DataFetcherTask {
await this.fetchMatchesForSport("football", date, footballLeagues);
} else {
this.logger.warn(
"top_leagues.json is missing/empty writing ALL football matches",
"top_leagues.json is missing/empty — writing ALL football matches",
);
await this.fetchMatchesForSport("football", date, new Set());
}
@@ -170,17 +226,18 @@ export class DataFetcherTask {
}
}
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
// Phase 2: Live score updates (merged from live-updater.task)
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
private async updateLiveScores(): Promise<void> {
try {
const liveMatches = await this.prisma.liveMatch.findMany({
where: {
state: {
in: ["live", "firsthalf", "secondhalf", "1H", "2H", "HT", "LIVE"],
},
OR: [
{ state: { in: LIVE_STATE_VALUES_FOR_DB } },
{ status: { in: LIVE_STATUS_VALUES_FOR_DB } },
],
},
select: { id: true, matchSlug: true },
});
@@ -191,7 +248,7 @@ export class DataFetcherTask {
}
this.logger.log(
`📡 Updating scores for ${liveMatches.length} live matches`,
`📡 Updating scores for ${liveMatches.length} live matches`,
);
for (const match of liveMatches) {
@@ -219,19 +276,19 @@ export class DataFetcherTask {
}
}
this.logger.log("📡 Live score update complete");
this.logger.log("📡 Live score update complete");
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Live score update failed: ${message}`);
}
}
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
// Phase 3: Odds + referee + lineups + sidelined
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
private async fetchOddsForMatches(): Promise<void> {
this.logger.log("💰 Fetching odds for live matches...");
this.logger.log("💰 Fetching odds for live matches...");
try {
// Load both league filters
@@ -266,11 +323,11 @@ export class DataFetcherTask {
});
if (matchesToFetch.length === 0) {
this.logger.log("💰 No matches to fetch odds for");
this.logger.log("💰 No matches to fetch odds for");
return;
}
this.logger.log(`💰 Fetching odds for ${matchesToFetch.length} matches`);
this.logger.log(`💰 Fetching odds for ${matchesToFetch.length} matches`);
let successCount = 0;
let errorCount = 0;
@@ -299,7 +356,7 @@ export class DataFetcherTask {
// Retry failed matches (502/Timeout)
if (failedMatches.length > 0) {
this.logger.warn(
`⚠️ Retrying ${failedMatches.length} failed matches (502/Timeout)...`,
`⚠️ Retrying ${failedMatches.length} failed matches (502/Timeout)...`,
);
for (const match of failedMatches) {
@@ -307,19 +364,19 @@ export class DataFetcherTask {
try {
await this.processMatchOdds(match);
successCount++;
this.logger.log(` Retry successful for match ${match.id}`);
this.logger.log(`✅ Retry successful for match ${match.id}`);
} catch (retryErr: unknown) {
const message =
retryErr instanceof Error ? retryErr.message : String(retryErr);
this.logger.error(
` Retry failed for match ${match.id}: ${message}`,
`❌ Retry failed for match ${match.id}: ${message}`,
);
}
}
}
this.logger.log(
`💰 Odds complete: ${successCount} success, ${errorCount} errors (initially)`,
`💰 Odds complete: ${successCount} success, ${errorCount} errors (initially)`,
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
@@ -327,14 +384,36 @@ export class DataFetcherTask {
}
}
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
// Phase 4: Fill missing lineups (backup)
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
private async fillMissingLineups(): Promise<void> {
try {
const matchesToUpdate = await this.prisma.liveMatch.findMany({
where: { status: { notIn: ["FT", "post", "postGame"] } },
where: {
sport: "football",
NOT: {
OR: [
{ status: { in: FINISHED_STATUS_VALUES_FOR_DB } },
{ state: { in: FINISHED_STATE_VALUES_FOR_DB } },
{
AND: [
{ scoreHome: { not: null } },
{ scoreAway: { not: null } },
{
NOT: {
OR: [
{ status: { in: LIVE_STATUS_VALUES_FOR_DB } },
{ state: { in: LIVE_STATE_VALUES_FOR_DB } },
],
},
},
],
},
],
},
},
select: { id: true, matchSlug: true, lineups: true, sport: true },
take: 30,
});
@@ -345,11 +424,11 @@ export class DataFetcherTask {
);
if (toUpdate.length === 0) {
this.logger.debug("👕 All lineups already filled");
this.logger.debug("👕 All lineups already filled");
return;
}
this.logger.log(`👕 Filling lineups for ${toUpdate.length} matches...`);
this.logger.log(`👕 Filling lineups for ${toUpdate.length} matches...`);
for (const match of toUpdate) {
try {
@@ -374,7 +453,7 @@ export class DataFetcherTask {
},
});
this.logger.log(`👕 Lineups filled for match ${match.id}`);
this.logger.log(`👕 Lineups filled for match ${match.id}`);
await this.delay(500);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
@@ -387,9 +466,9 @@ export class DataFetcherTask {
}
}
// ────────────────────────────────────────────────────────────
// Unified match fetcher DRY for football + basketball
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
// Unified match fetcher — DRY for football + basketball
// ────────────────────────────────────────────────────────────
private async fetchMatchesForSport(
sport: SportType,
@@ -650,7 +729,7 @@ export class DataFetcherTask {
upsertCount + skippedCount === targetMatches.length
) {
this.logger.log(
`[${sport}] Progress: ${upsertCount + skippedCount}/${targetMatches.length} (Saved: ${upsertCount}, Skipped: ${skippedCount})`,
`[${sport}] ⏳ Progress: ${upsertCount + skippedCount}/${targetMatches.length} (Saved: ${upsertCount}, Skipped: ${skippedCount})`,
);
}
} catch (err: unknown) {
@@ -668,10 +747,10 @@ export class DataFetcherTask {
}
}
// ────────────────────────────────────────────────────────────
// processMatchOdds odds + referee + lineups + sidelined
// (Preserved from original no logic changes)
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
// processMatchOdds — odds + referee + lineups + sidelined
// (Preserved from original — no logic changes)
// ────────────────────────────────────────────────────────────
private async processMatchOdds(match: LiveMatchOddsTarget): Promise<void> {
const matchSlug = match.matchSlug || "match";
@@ -687,7 +766,7 @@ export class DataFetcherTask {
let lineups: LiveLineupsJson | null = null;
let sidelined: SidelinedResponse | null = null;
// 1. Fetch Odds from İddaa page
// 1. Fetch Odds from İddaa page
const oddsUrl = `https://www.mackolik.com/${sportPath}/${matchSlug}/iddaa/${match.id}`;
try {
const response = await firstValueFrom(
@@ -722,7 +801,7 @@ export class DataFetcherTask {
typeof mainResp.data === "string" ? mainResp.data : "",
);
} catch {
// Non-critical referee is optional
// Non-critical — referee is optional
}
}
@@ -751,7 +830,7 @@ export class DataFetcherTask {
subs: substitutions?.stats?.away || [],
},
};
this.logger.log(`👥 Lineups found for ${match.matchName}`);
this.logger.log(`👥 Lineups found for ${match.matchName}`);
} else {
this.logger.debug(`No lineups (yet) for ${match.matchName}`);
}
@@ -779,7 +858,7 @@ export class DataFetcherTask {
sidelined.awayTeam?.totalSidelined > 0
) {
this.logger.log(
`🚑 Sidelined: ${sidelined.homeTeam.totalSidelined}(H) - ${sidelined.awayTeam.totalSidelined}(A) for ${match.matchName}`,
`🚑 Sidelined: ${sidelined.homeTeam.totalSidelined}(H) - ${sidelined.awayTeam.totalSidelined}(A) for ${match.matchName}`,
);
}
}
@@ -813,22 +892,22 @@ export class DataFetcherTask {
sidelined.awayTeam.totalSidelined > 0))
) {
this.logger.log(
` Loop update: ${match.matchName} | Odds: ${Object.keys(odds).length} | Ref: ${refereeName || "N/A"} | Lineups: ${lineups ? "Yes" : "No"} | Sidelined: ${sidelined ? "Yes" : "No"}`,
`✅ Loop update: ${match.matchName} | Odds: ${Object.keys(odds).length} | Ref: ${refereeName || "N/A"} | Lineups: ${lineups ? "Yes" : "No"} | Sidelined: ${sidelined ? "Yes" : "No"}`,
);
} else {
this.logger.debug(
` No detailed data for ${match.matchName}, marked check.`,
`❕ No detailed data for ${match.matchName}, marked check.`,
);
}
}
// ────────────────────────────────────────────────────────────
// HTML Extraction Helpers (preserved no logic changes)
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
// HTML Extraction Helpers (preserved — no logic changes)
// ────────────────────────────────────────────────────────────
/**
* Extract odds from Mackolik HTML page
* Returns structured odds object: { "MS": {"1": 2.10, "X": 3.40}, "AU25": {"Alt": 2.05, "Üst": 1.75} }
* Returns structured odds object: { "MS": {"1": 2.10, "X": 3.40}, "AU25": {"Alt": 2.05, "Üst": 1.75} }
*/
private extractOddsFromHtml(
html: string,
@@ -914,17 +993,17 @@ export class DataFetcherTask {
const lower = name.toLowerCase();
// Specific & Compound names FIRST
if (lower.includes("ilk yarı/maç sonucu")) return "HTFT";
if (lower.includes("1. yarı sonucu")) return "HT";
if (lower.includes("çifte şans")) return "CS";
if (lower.includes("ilk yarı/maç sonucu")) return "HTFT";
if (lower.includes("1. yarı sonucu")) return "HT";
if (lower.includes("çifte şans")) return "CS";
// General names LATER
if (lower.includes("maç sonucu") && !lower.includes("handikap"))
if (lower.includes("maç sonucu") && !lower.includes("handikap"))
return "MS";
if (lower.includes("karşılıklı gol")) return "KG";
if (lower.includes("2,5 alt/üst") || lower.includes("2.5")) return "AU25";
if (lower.includes("1,5 alt/üst") || lower.includes("1.5")) return "AU15";
if (lower.includes("3,5 alt/üst") || lower.includes("3.5")) return "AU35";
if (lower.includes("karşılıklı gol")) return "KG";
if (lower.includes("2,5 alt/üst") || lower.includes("2.5")) return "AU25";
if (lower.includes("1,5 alt/üst") || lower.includes("1.5")) return "AU15";
if (lower.includes("3,5 alt/üst") || lower.includes("3.5")) return "AU35";
return null;
}
@@ -934,7 +1013,7 @@ export class DataFetcherTask {
*/
private extractRefereeFromHtml(html: string): string | null {
try {
// Strategy 1: Mackolik officials section head referee in '--main' list item
// Strategy 1: Mackolik officials section — head referee in '--main' list item
const mainOfficialPattern =
/official-list-item--main[^>]*>\s*(?:<[^>]*>\s*)*?<span[^>]*official-name[^>]*>\s*([^<]+)/i;
const mainMatch = mainOfficialPattern.exec(html);
@@ -970,9 +1049,9 @@ export class DataFetcherTask {
return null;
}
// ────────────────────────────────────────────────────────────
// Low-level Helpers (preserved no logic changes)
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
// Low-level Helpers (preserved — no logic changes)
// ────────────────────────────────────────────────────────────
private shouldSkipInHistoricalMode(jobName: string): boolean {
if (process.env.FEEDER_MODE === "historical") {
+23 -12
View File
@@ -1,12 +1,16 @@
import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { FeederService } from "../modules/feeder/feeder.service";
import { TaskLockService } from "./task-lock.service";
@Injectable()
export class HistoricalResultsSyncTask {
private readonly logger = new Logger(HistoricalResultsSyncTask.name);
constructor(private readonly feederService: FeederService) {}
constructor(
private readonly feederService: FeederService,
private readonly taskLock: TaskLockService,
) {}
private shouldSkipInHistoricalMode(jobName: string): boolean {
if (process.env.FEEDER_MODE === "historical") {
@@ -25,17 +29,24 @@ export class HistoricalResultsSyncTask {
return;
}
this.logger.log(
"Starting previous-day completed match sync for football and basketball...",
);
await this.taskLock.runWithLease(
"syncPreviousDayCompletedMatches",
6 * 60 * 60 * 1000,
async () => {
this.logger.log(
"Starting previous-day completed match sync for football and basketball...",
);
try {
await this.feederService.runPreviousDayCompletedMatchesScan();
this.logger.log("Previous-day completed match sync finished");
} catch (error: any) {
this.logger.error(
`Previous-day completed match sync failed: ${error.message}`,
);
}
try {
await this.feederService.runPreviousDayCompletedMatchesScan();
this.logger.log("Previous-day completed match sync finished");
} catch (error: any) {
this.logger.error(
`Previous-day completed match sync failed: ${error.message}`,
);
}
},
this.logger,
);
}
}
+126 -70
View File
@@ -1,12 +1,28 @@
import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { PrismaService } from "../database/prisma.service";
import {
FINISHED_STATE_VALUES_FOR_DB,
FINISHED_STATUS_VALUES_FOR_DB,
LIVE_STATE_VALUES_FOR_DB,
LIVE_STATUS_VALUES_FOR_DB,
} from "../common/utils/match-status.util";
import {
getDateOnlyValueForTimeZone,
getShiftedDateStringInTimeZone,
getDayBoundsForTimeZone,
} from "../common/utils/timezone.util";
import { TaskLockService } from "./task-lock.service";
@Injectable()
export class LimitResetterTask {
private readonly logger = new Logger(LimitResetterTask.name);
private readonly timeZone = "Europe/Istanbul";
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly taskLock: TaskLockService,
) {}
private shouldSkipInHistoricalMode(jobName: string): boolean {
if (process.env.FEEDER_MODE === "historical") {
@@ -22,34 +38,39 @@ export class LimitResetterTask {
@Cron("0 3 * * *", { timeZone: "Europe/Istanbul" })
async resetUsageLimits() {
if (this.shouldSkipInHistoricalMode("resetUsageLimits")) return;
this.logger.log("Starting daily usage limit reset job...");
await this.taskLock.runWithLease(
"resetUsageLimits",
30 * 60 * 1000,
async () => {
this.logger.log("Starting daily usage limit reset job...");
try {
const today = new Date();
today.setHours(0, 0, 0, 0);
try {
const today = getDateOnlyValueForTimeZone(this.timeZone);
// Reset all limits that were last reset before today
const result = await this.prisma.usageLimit.updateMany({
where: {
lastResetDate: { lt: today },
},
data: {
analysisCount: 0,
couponCount: 0,
lastResetDate: today,
},
});
const result = await this.prisma.usageLimit.updateMany({
where: {
lastResetDate: { lt: today },
},
data: {
analysisCount: 0,
couponCount: 0,
lastResetDate: today,
},
});
if (result.count > 0) {
this.logger.log(
`Usage limits for ${result.count} users have been reset`,
);
} else {
this.logger.log("No user limits needed resetting");
}
} catch (error: any) {
this.logger.error(`Limit reset job failed: ${error.message}`);
}
if (result.count > 0) {
this.logger.log(
`Usage limits for ${result.count} users have been reset`,
);
} else {
this.logger.log("No user limits needed resetting");
}
} catch (error: any) {
this.logger.error(`Limit reset job failed: ${error.message}`);
}
},
this.logger,
);
}
/**
@@ -58,37 +79,65 @@ export class LimitResetterTask {
@Cron("0 4 * * *", { timeZone: "Europe/Istanbul" })
async cleanupOldData() {
if (this.shouldSkipInHistoricalMode("cleanupOldData")) return;
this.logger.log("Starting data cleanup job...");
await this.taskLock.runWithLease(
"cleanupOldData",
60 * 60 * 1000,
async () => {
this.logger.log("Starting data cleanup job...");
try {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
try {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Delete old AI prediction logs
const deletedLogs = await this.prisma.aiPredictionsLog.deleteMany({
where: {
createdAt: { lt: thirtyDaysAgo },
},
});
const deletedLogs = await this.prisma.aiPredictionsLog.deleteMany({
where: {
createdAt: { lt: thirtyDaysAgo },
},
});
// Delete old live matches (finished more than 1 day ago)
// Historical data is already persisted in the 'matches' table
const oneDayAgo = new Date();
oneDayAgo.setDate(oneDayAgo.getDate() - 1);
const yesterdayDate = getShiftedDateStringInTimeZone(
-1,
this.timeZone,
);
const { startMs: yesterdayStartMs } = getDayBoundsForTimeZone(
yesterdayDate,
this.timeZone,
);
const liveMatchCutoff = new Date(yesterdayStartMs);
const deletedLiveMatches = await this.prisma.liveMatch.deleteMany({
where: {
state: "Finished",
updatedAt: { lt: oneDayAgo },
},
});
const deletedLiveMatches = await this.prisma.liveMatch.deleteMany({
where: {
updatedAt: { lt: liveMatchCutoff },
OR: [
{ status: { in: FINISHED_STATUS_VALUES_FOR_DB } },
{ state: { in: FINISHED_STATE_VALUES_FOR_DB } },
{
AND: [
{ scoreHome: { not: null } },
{ scoreAway: { not: null } },
{
NOT: {
OR: [
{ status: { in: LIVE_STATUS_VALUES_FOR_DB } },
{ state: { in: LIVE_STATE_VALUES_FOR_DB } },
],
},
},
],
},
],
},
});
this.logger.log(
`Cleanup complete: ${deletedLogs.count} old logs, ${deletedLiveMatches.count} old live matches`,
);
} catch (error: any) {
this.logger.error(`Cleanup job failed: ${error.message}`);
}
this.logger.log(
`Cleanup complete: ${deletedLogs.count} old logs, ${deletedLiveMatches.count} old live matches`,
);
} catch (error: any) {
this.logger.error(`Cleanup job failed: ${error.message}`);
}
},
this.logger,
);
}
/**
@@ -97,26 +146,33 @@ export class LimitResetterTask {
@Cron("0 0 * * *", { timeZone: "Europe/Istanbul" })
async checkSubscriptions() {
if (this.shouldSkipInHistoricalMode("checkSubscriptions")) return;
this.logger.log("Checking expired subscriptions...");
await this.taskLock.runWithLease(
"checkSubscriptions",
30 * 60 * 1000,
async () => {
this.logger.log("Checking expired subscriptions...");
try {
const now = new Date();
try {
const now = new Date();
const result = await this.prisma.user.updateMany({
where: {
subscriptionStatus: "active",
subscriptionExpiresAt: { lt: now },
},
data: {
subscriptionStatus: "expired",
},
});
const result = await this.prisma.user.updateMany({
where: {
subscriptionStatus: "active",
subscriptionExpiresAt: { lt: now },
},
data: {
subscriptionStatus: "expired",
},
});
if (result.count > 0) {
this.logger.log(`${result.count} subscriptions marked as expired`);
}
} catch (error: any) {
this.logger.error(`Subscription check failed: ${error.message}`);
}
if (result.count > 0) {
this.logger.log(`${result.count} subscriptions marked as expired`);
}
} catch (error: any) {
this.logger.error(`Subscription check failed: ${error.message}`);
}
},
this.logger,
);
}
}
+80
View File
@@ -0,0 +1,80 @@
import { Injectable, Logger } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../database/prisma.service";
@Injectable()
export class TaskLockService {
private readonly logger = new Logger(TaskLockService.name);
private readonly activeTasks = new Set<string>();
constructor(private readonly prisma: PrismaService) {}
async runWithLease<T>(
key: string,
ttlMs: number,
task: () => Promise<T>,
logger: Logger,
): Promise<T | null> {
if (this.activeTasks.has(key)) {
logger.warn(`Skipping ${key}: task is already running in this process`);
return null;
}
const owner = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const acquired = await this.acquireLease(key, owner, ttlMs);
if (!acquired) {
logger.warn(`Skipping ${key}: lease is already held by another instance`);
return null;
}
this.activeTasks.add(key);
try {
return await task();
} finally {
this.activeTasks.delete(key);
await this.releaseLease(key, owner);
}
}
private async acquireLease(
key: string,
owner: string,
ttlMs: number,
): Promise<boolean> {
const rows = await this.prisma.$queryRaw<{ key: string }[]>(
Prisma.sql`
INSERT INTO app_settings (key, value, updated_at)
VALUES (${this.getDbKey(key)}, ${owner}, NOW() + (${ttlMs} * INTERVAL '1 millisecond'))
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
updated_at = EXCLUDED.updated_at
WHERE app_settings.updated_at < NOW()
OR app_settings.value = ${owner}
RETURNING key
`,
);
return rows.length > 0;
}
private async releaseLease(key: string, owner: string): Promise<void> {
try {
await this.prisma.$executeRaw(
Prisma.sql`
DELETE FROM app_settings
WHERE key = ${this.getDbKey(key)}
AND value = ${owner}
`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to release task lease ${key}: ${message}`);
}
}
private getDbKey(key: string): string {
return `task_lock:${key}`;
}
}
+7 -3
View File
@@ -1,15 +1,14 @@
import { Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule";
import { HttpModule } from "@nestjs/axios";
import { DataFetcherTask } from "./data-fetcher.task";
import { HistoricalResultsSyncTask } from "./historical-results-sync.task";
import { LimitResetterTask } from "./limit-resetter.task";
import { TaskLockService } from "./task-lock.service";
import { DatabaseModule } from "../database/database.module";
import { FeederModule } from "../modules/feeder/feeder.module";
@Module({
imports: [
ScheduleModule.forRoot(),
HttpModule.register({
timeout: 30000,
headers: {
@@ -20,7 +19,12 @@ import { FeederModule } from "../modules/feeder/feeder.module";
DatabaseModule,
FeederModule,
],
providers: [DataFetcherTask, HistoricalResultsSyncTask, LimitResetterTask],
providers: [
TaskLockService,
DataFetcherTask,
HistoricalResultsSyncTask,
LimitResetterTask,
],
exports: [DataFetcherTask, HistoricalResultsSyncTask, LimitResetterTask],
})
export class TasksModule {}