Compare commits
2 Commits
cron
...
prod-ready
| Author | SHA1 | Date | |
|---|---|---|---|
| 1346924387 | |||
| e4c74025e5 |
+331
-696
File diff suppressed because it is too large
Load Diff
@@ -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() ?? "";
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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: {} } },
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user