Compare commits
2 Commits
cron
...
prod-ready
| Author | SHA1 | Date | |
|---|---|---|---|
| 1346924387 | |||
| e4c74025e5 |
+330
-695
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,
|
premiumUsers,
|
||||||
totalMatches,
|
totalMatches,
|
||||||
totalPredictions,
|
totalPredictions,
|
||||||
|
totalCoupons,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.prisma.user.count(),
|
this.prisma.user.count(),
|
||||||
this.prisma.user.count({ where: { isActive: true } }),
|
this.prisma.user.count({ where: { isActive: true } }),
|
||||||
this.prisma.user.count({ where: { subscriptionStatus: "active" } }),
|
this.prisma.user.count({ where: { subscriptionStatus: "active" } }),
|
||||||
this.prisma.match.count(),
|
this.prisma.match.count(),
|
||||||
this.prisma.prediction.count(),
|
this.prisma.prediction.count(),
|
||||||
|
this.prisma.userCoupon.count(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
|
totalUsers,
|
||||||
|
activeUsers,
|
||||||
|
totalPredictions,
|
||||||
|
totalCoupons,
|
||||||
users: {
|
users: {
|
||||||
total: totalUsers,
|
total: totalUsers,
|
||||||
active: activeUsers,
|
active: activeUsers,
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import {
|
|||||||
ROLES_KEY,
|
ROLES_KEY,
|
||||||
PERMISSIONS_KEY,
|
PERMISSIONS_KEY,
|
||||||
} from "../../../common/decorators";
|
} from "../../../common/decorators";
|
||||||
|
import { normalizeRole } from "../../../common/constants/roles";
|
||||||
|
|
||||||
interface AuthenticatedUser {
|
interface AuthenticatedUser {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
|
role?: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,11 +90,28 @@ export class RolesGuard implements CanActivate {
|
|||||||
|
|
||||||
const user = req.user as AuthenticatedUser | undefined;
|
const user = req.user as AuthenticatedUser | undefined;
|
||||||
|
|
||||||
if (!user || !user.roles) {
|
if (!user) {
|
||||||
return false;
|
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) {
|
if (!hasRole) {
|
||||||
throw new ForbiddenException("PERMISSION_DENIED");
|
throw new ForbiddenException("PERMISSION_DENIED");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PassportStrategy } from "@nestjs/passport";
|
|||||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { AuthService, JwtPayload } from "../auth.service";
|
import { AuthService, JwtPayload } from "../auth.service";
|
||||||
|
import { normalizeRole } from "../../../common/constants/roles";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
@@ -29,9 +30,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedRole = normalizeRole(payload.role);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
role: payload.role,
|
role: normalizedRole,
|
||||||
|
roles: normalizedRole ? [normalizedRole] : [],
|
||||||
|
permissions: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
|
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
|
||||||
import axios from "axios";
|
|
||||||
import { GeminiService } from "../../gemini/gemini.service";
|
import { GeminiService } from "../../gemini/gemini.service";
|
||||||
|
import {
|
||||||
|
AiEngineClient,
|
||||||
|
AiEngineRequestError,
|
||||||
|
} from "../../../common/utils/ai-engine-client";
|
||||||
|
|
||||||
export type PredictionRiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
|
export type PredictionRiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
|
||||||
export type PredictionDataQuality = "HIGH" | "MEDIUM" | "LOW";
|
export type PredictionDataQuality = "HIGH" | "MEDIUM" | "LOW";
|
||||||
@@ -126,24 +129,34 @@ export interface SmartCouponResult {
|
|||||||
export class SmartCouponService {
|
export class SmartCouponService {
|
||||||
private readonly logger = new Logger(SmartCouponService.name);
|
private readonly logger = new Logger(SmartCouponService.name);
|
||||||
private readonly aiEngineUrl: string;
|
private readonly aiEngineUrl: string;
|
||||||
|
private readonly aiEngineClient: AiEngineClient;
|
||||||
|
|
||||||
constructor(private readonly geminiService: GeminiService) {
|
constructor(private readonly geminiService: GeminiService) {
|
||||||
this.aiEngineUrl = process.env.AI_ENGINE_URL || "http://ai-engine:8000";
|
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> {
|
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
|
||||||
let prediction: SingleMatchPredictionPackage;
|
let prediction: SingleMatchPredictionPackage;
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<SingleMatchPredictionPackage>(
|
const response = await this.aiEngineClient.post<SingleMatchPredictionPackage>(
|
||||||
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
|
`/v20plus/analyze/${matchId}`,
|
||||||
);
|
);
|
||||||
prediction = response.data;
|
prediction = response.data;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (error instanceof AiEngineRequestError) {
|
||||||
const detail = error.response?.data?.detail || error.message;
|
const detail =
|
||||||
|
typeof error.detail === "string" ? error.detail : error.message;
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
`AI analyze failed: ${detail}`,
|
`AI analyze failed: ${detail}`,
|
||||||
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
|
error.status || HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@@ -205,8 +218,8 @@ export class SmartCouponService {
|
|||||||
options: { maxMatches?: number; minConfidence?: number } = {},
|
options: { maxMatches?: number; minConfidence?: number } = {},
|
||||||
): Promise<SmartCouponResult> {
|
): Promise<SmartCouponResult> {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<SmartCouponResult>(
|
const response = await this.aiEngineClient.post<SmartCouponResult>(
|
||||||
`${this.aiEngineUrl}/v20plus/coupon`,
|
"/v20plus/coupon",
|
||||||
{
|
{
|
||||||
match_ids: matchIds,
|
match_ids: matchIds,
|
||||||
strategy,
|
strategy,
|
||||||
@@ -215,13 +228,14 @@ export class SmartCouponService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
this.logger.error("Failed to generate smart coupon", error);
|
this.logger.error("Failed to generate smart coupon", error);
|
||||||
if (axios.isAxiosError(error)) {
|
if (error instanceof AiEngineRequestError) {
|
||||||
const detail = error.response?.data?.detail || error.message;
|
const detail =
|
||||||
|
typeof error.detail === "string" ? error.detail : error.message;
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
`Coupon generation failed: ${detail}`,
|
`Coupon generation failed: ${detail}`,
|
||||||
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
|
error.status || HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
BasketballTeamStats,
|
BasketballTeamStats,
|
||||||
} from "./feeder.types";
|
} from "./feeder.types";
|
||||||
import { ImageUtils } from "../../common/utils/image.util";
|
import { ImageUtils } from "../../common/utils/image.util";
|
||||||
|
import { deriveStoredMatchStatus } from "../../common/utils/match-status.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FeederPersistenceService {
|
export class FeederPersistenceService {
|
||||||
@@ -311,33 +312,15 @@ export class FeederPersistenceService {
|
|||||||
headerData?.htScoreAway ??
|
headerData?.htScoreAway ??
|
||||||
this.safeInt(matchSummary.score?.ht?.away);
|
this.safeInt(matchSummary.score?.ht?.away);
|
||||||
|
|
||||||
let status = "NS";
|
const status = deriveStoredMatchStatus({
|
||||||
if (headerData?.matchStatus) {
|
state: headerData?.matchStatus ?? matchSummary.state,
|
||||||
if (
|
status: matchSummary.status,
|
||||||
headerData.matchStatus === "postGame" ||
|
substate: matchSummary.substate,
|
||||||
headerData.matchStatus === "post"
|
statusBoxContent: matchSummary.statusBoxContent,
|
||||||
) {
|
scoreHome: finalScoreHome,
|
||||||
status = "FT";
|
scoreAway: finalScoreAway,
|
||||||
} else if (
|
score: matchSummary.score,
|
||||||
headerData.matchStatus === "live" ||
|
});
|
||||||
headerData.matchStatus === "liveGame"
|
|
||||||
) {
|
|
||||||
status = "LIVE";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Postponed Matches (ERT)
|
|
||||||
if (matchSummary.statusBoxContent === "ERT") {
|
|
||||||
status = "POSTPONED";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
status === "NS" &&
|
|
||||||
finalScoreHome !== null &&
|
|
||||||
finalScoreAway !== null
|
|
||||||
) {
|
|
||||||
status = "FT";
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.match.upsert({
|
await tx.match.upsert({
|
||||||
where: { id: matchId },
|
where: { id: matchId },
|
||||||
@@ -870,15 +853,11 @@ export class FeederPersistenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getExistingMatchIds(matchIds: string[]): Promise<string[]> {
|
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({
|
const matches = await this.prisma.match.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: matchIds },
|
id: { in: matchIds },
|
||||||
AND: [
|
AND: [
|
||||||
{ oddCategories: { some: {} } },
|
{ oddCategories: { some: {} } },
|
||||||
{ playerEvents: { some: {} } },
|
|
||||||
{ officials: { some: {} } },
|
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
{ footballTeamStats: { some: {} } },
|
{ footballTeamStats: { some: {} } },
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
DbEventPayload,
|
DbEventPayload,
|
||||||
DbMarketPayload,
|
DbMarketPayload,
|
||||||
} from "./feeder.types";
|
} from "./feeder.types";
|
||||||
|
import { isMatchCompleted } from "../../common/utils/match-status.util";
|
||||||
|
|
||||||
interface ProcessDateOptions {
|
interface ProcessDateOptions {
|
||||||
onlyCompletedMatches?: boolean;
|
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 {
|
private isCompletedMatchSummary(match: MatchSummary): boolean {
|
||||||
if (match.statusBoxContent === "ERT") return false;
|
return isMatchCompleted({
|
||||||
|
state: match.state,
|
||||||
const normalizedState = String(match.state || "")
|
status: match.status,
|
||||||
.trim()
|
substate: match.substate,
|
||||||
.toLowerCase();
|
statusBoxContent: match.statusBoxContent,
|
||||||
const normalizedStatus = String(match.status || "")
|
score: match.score,
|
||||||
.trim()
|
scoreHome: match.homeScore,
|
||||||
.toLowerCase();
|
scoreAway: match.awayScore,
|
||||||
const normalizedSubstate = String(match.substate || "")
|
});
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
if (["postgame", "post"].includes(normalizedState)) return true;
|
|
||||||
|
|
||||||
if (
|
|
||||||
["played", "finished", "ft", "afterpenalties", "penalties"].includes(
|
|
||||||
normalizedStatus,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
["postgame", "post", "played", "finished", "ft"].includes(
|
|
||||||
normalizedSubstate,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const homeScore = this.parseScoreValue(
|
|
||||||
match.score?.home ?? match.homeScore,
|
|
||||||
);
|
|
||||||
const awayScore = this.parseScoreValue(
|
|
||||||
match.score?.away ?? match.awayScore,
|
|
||||||
);
|
|
||||||
|
|
||||||
return homeScore !== null && awayScore !== null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async runPreviousDayCompletedMatchesScan(
|
async runPreviousDayCompletedMatchesScan(
|
||||||
@@ -957,15 +923,30 @@ export class FeederService {
|
|||||||
*/
|
*/
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
if (saved && hasCriticalError) {
|
const completedMatch = isMatchCompleted({
|
||||||
// Collect missing components
|
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[] = [];
|
const missingParts: string[] = [];
|
||||||
if (!stats) missingParts.push("Stats");
|
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 (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(
|
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 };
|
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 { ApiTags, ApiOperation } from "@nestjs/swagger";
|
||||||
import {
|
import { Response } from "express";
|
||||||
HealthCheck,
|
|
||||||
HealthCheckService,
|
|
||||||
PrismaHealthIndicator,
|
|
||||||
} from "@nestjs/terminus";
|
|
||||||
import { Public } from "../../common/decorators";
|
import { Public } from "../../common/decorators";
|
||||||
import { PrismaService } from "../../database/prisma.service";
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
|
import { PredictionsService } from "../predictions/predictions.service";
|
||||||
|
|
||||||
@ApiTags("Health")
|
@ApiTags("Health")
|
||||||
@Controller("health")
|
@Controller("health")
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(
|
constructor(
|
||||||
private health: HealthCheckService,
|
|
||||||
private prismaHealth: PrismaHealthIndicator,
|
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
|
private readonly predictionsService: PredictionsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Public()
|
@Public()
|
||||||
@HealthCheck()
|
|
||||||
@ApiOperation({ summary: "Basic health check" })
|
@ApiOperation({ summary: "Basic health check" })
|
||||||
check() {
|
async check(@Res() response: Response) {
|
||||||
return this.health.check([]);
|
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")
|
@Get("ready")
|
||||||
@Public()
|
@Public()
|
||||||
@HealthCheck()
|
|
||||||
@ApiOperation({ summary: "Readiness check (includes database)" })
|
@ApiOperation({ summary: "Readiness check (includes database)" })
|
||||||
readiness() {
|
async readiness(@Res() response: Response) {
|
||||||
return this.health.check([
|
const database = await this.getDatabaseHealth();
|
||||||
() => this.prismaHealth.pingCheck("database", this.prisma),
|
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")
|
@Get("live")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Liveness check" })
|
@ApiOperation({ summary: "Liveness check" })
|
||||||
liveness() {
|
liveness(@Res() response: Response) {
|
||||||
return { status: "ok", timestamp: new Date().toISOString() };
|
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 { Module } from "@nestjs/common";
|
||||||
import { TerminusModule } from "@nestjs/terminus";
|
|
||||||
import { PrismaHealthIndicator } from "@nestjs/terminus";
|
|
||||||
import { HealthController } from "./health.controller";
|
import { HealthController } from "./health.controller";
|
||||||
|
import { PredictionsModule } from "../predictions/predictions.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TerminusModule],
|
imports: [PredictionsModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [PrismaHealthIndicator],
|
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import {
|
|||||||
ActiveLeagueDto,
|
ActiveLeagueDto,
|
||||||
} from "./dto";
|
} from "./dto";
|
||||||
import { Prisma } from "@prisma/client";
|
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()
|
@Injectable()
|
||||||
export class MatchesService {
|
export class MatchesService {
|
||||||
@@ -38,23 +45,12 @@ export class MatchesService {
|
|||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
status: {
|
status: {
|
||||||
in: [
|
in: LIVE_STATUS_VALUES_FOR_DB,
|
||||||
"LIVE",
|
|
||||||
"1H",
|
|
||||||
"2H",
|
|
||||||
"HT",
|
|
||||||
"1Q",
|
|
||||||
"2Q",
|
|
||||||
"3Q",
|
|
||||||
"4Q",
|
|
||||||
"Playing",
|
|
||||||
"Half Time",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
state: {
|
state: {
|
||||||
in: ["live", "firsthalf", "secondhalf"],
|
in: LIVE_STATE_VALUES_FOR_DB,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -66,14 +62,23 @@ export class MatchesService {
|
|||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
status: {
|
status: {
|
||||||
in: ["Finished", "Played", "FT", "AET", "PEN", "Ended"],
|
in: FINISHED_STATUS_VALUES_FOR_DB,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
state: {
|
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
|
// Map status for frontend
|
||||||
let displayStatus = match.status || "NS";
|
const displayStatus = getDisplayMatchStatus({
|
||||||
if (match.state === "live") {
|
state: match.state,
|
||||||
displayStatus = "LIVE";
|
status: match.status,
|
||||||
} else if (
|
substate: match.substate,
|
||||||
match.state === "post" ||
|
scoreHome: match.scoreHome,
|
||||||
match.state === "FT" ||
|
scoreAway: match.scoreAway,
|
||||||
match.status === "Finished"
|
});
|
||||||
) {
|
|
||||||
displayStatus = "Finished";
|
|
||||||
}
|
|
||||||
|
|
||||||
league.matches.push({
|
league.matches.push({
|
||||||
id: match.id,
|
id: match.id,
|
||||||
@@ -562,16 +564,13 @@ export class MatchesService {
|
|||||||
|
|
||||||
if (liveMatch) {
|
if (liveMatch) {
|
||||||
// Map liveMatch status
|
// Map liveMatch status
|
||||||
let displayStatus = liveMatch.status || "NS";
|
const displayStatus = getDisplayMatchStatus({
|
||||||
if (liveMatch.state === "live") {
|
state: liveMatch.state,
|
||||||
displayStatus = "LIVE";
|
status: liveMatch.status,
|
||||||
} else if (
|
substate: liveMatch.substate,
|
||||||
liveMatch.state === "post" ||
|
scoreHome: liveMatch.scoreHome,
|
||||||
liveMatch.state === "FT" ||
|
scoreAway: liveMatch.scoreAway,
|
||||||
liveMatch.status === "Finished"
|
});
|
||||||
) {
|
|
||||||
displayStatus = "Finished";
|
|
||||||
}
|
|
||||||
|
|
||||||
match = {
|
match = {
|
||||||
...liveMatch,
|
...liveMatch,
|
||||||
|
|||||||
@@ -461,6 +461,21 @@ export class AIHealthDto {
|
|||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
predictionServiceReady: boolean;
|
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";
|
export * from "./smart-coupon.dto";
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ import {
|
|||||||
ValueBetDto,
|
ValueBetDto,
|
||||||
AIHealthDto,
|
AIHealthDto,
|
||||||
} from "./dto";
|
} from "./dto";
|
||||||
import axios, { AxiosError } from "axios";
|
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { FeederService } from "../feeder/feeder.service";
|
import { FeederService } from "../feeder/feeder.service";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import {
|
||||||
|
AiEngineClient,
|
||||||
|
AiEngineRequestError,
|
||||||
|
} from "../../common/utils/ai-engine-client";
|
||||||
|
|
||||||
type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW";
|
type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW";
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly logger = new Logger(PredictionsService.name);
|
private readonly logger = new Logger(PredictionsService.name);
|
||||||
private queueEvents: QueueEvents | null = null;
|
private queueEvents: QueueEvents | null = null;
|
||||||
private readonly aiEngineUrl: string;
|
private readonly aiEngineUrl: string;
|
||||||
|
private readonly aiEngineClient: AiEngineClient;
|
||||||
private readonly topLeagueIds = new Set<string>();
|
private readonly topLeagueIds = new Set<string>();
|
||||||
private readonly reasonTranslations: Record<string, string> = {
|
private readonly reasonTranslations: Record<string, string> = {
|
||||||
confidence_below_threshold: "Güven eşiğin altında",
|
confidence_below_threshold: "Güven eşiğin altında",
|
||||||
@@ -125,6 +129,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
"AI_ENGINE_URL",
|
"AI_ENGINE_URL",
|
||||||
"http://localhost:8000",
|
"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();
|
this.topLeagueIds = this.loadTopLeagueIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +161,50 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkHealth(): Promise<AIHealthDto> {
|
async checkHealth(): Promise<AIHealthDto> {
|
||||||
return Promise.resolve({
|
const circuit = this.aiEngineClient.getSnapshot();
|
||||||
status: "healthy",
|
|
||||||
modelLoaded: true,
|
try {
|
||||||
predictionServiceReady: true,
|
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> {
|
async getPredictionById(matchId: string): Promise<MatchPredictionDto | null> {
|
||||||
@@ -182,22 +232,21 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
// Direct HTTP mode (no Redis)
|
// Direct HTTP mode (no Redis)
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(
|
const response = await this.aiEngineClient.post<MatchPredictionDto>(
|
||||||
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
|
`/v20plus/analyze/${matchId}`,
|
||||||
{},
|
{},
|
||||||
{ timeout: 60000 },
|
|
||||||
);
|
);
|
||||||
return this.enrichPredictionResponse(
|
return this.enrichPredictionResponse(
|
||||||
response.data as MatchPredictionDto,
|
response.data as MatchPredictionDto,
|
||||||
matchContext,
|
matchContext,
|
||||||
);
|
);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const error = e as AxiosError<Record<string, unknown>>;
|
const requestError =
|
||||||
const status = error?.response?.status;
|
e instanceof AiEngineRequestError
|
||||||
const detail =
|
? e
|
||||||
error?.response?.data?.detail ||
|
: new AiEngineRequestError("AI Engine request failed");
|
||||||
error?.response?.data ||
|
const status = requestError.status;
|
||||||
error?.message;
|
const detail = requestError.detail || requestError.message;
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Direct AI Engine call failed for ${matchId}: status=${status}, detail=${JSON.stringify(detail)}`,
|
`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
|
// Direct HTTP mode
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(
|
const response = await this.aiEngineClient.post(
|
||||||
`${this.aiEngineUrl}/smart-coupon`,
|
"/smart-coupon",
|
||||||
{ match_ids: matchIds, strategy, ...options },
|
{ match_ids: matchIds, strategy, ...options },
|
||||||
{ timeout: 60000 },
|
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message =
|
||||||
|
error instanceof AiEngineRequestError
|
||||||
|
? error.message
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: String(error);
|
||||||
this.logger.error(`Direct smart coupon call failed: ${message}`);
|
this.logger.error(`Direct smart coupon call failed: ${message}`);
|
||||||
this.throwAiError(message);
|
this.throwAiError(message);
|
||||||
}
|
}
|
||||||
@@ -1018,6 +1071,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
HttpStatus.BAD_GATEWAY,
|
HttpStatus.BAD_GATEWAY,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (message.includes("circuit breaker is open")) {
|
||||||
|
throw new HttpException(
|
||||||
|
"AI Engine is temporarily unavailable",
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
"Failed to get prediction from AI Engine",
|
"Failed to get prediction from AI Engine",
|
||||||
HttpStatus.SERVICE_UNAVAILABLE,
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { RolesGuard } from "../auth/guards/auth.guards";
|
|||||||
@ApiTags("Social Poster")
|
@ApiTags("Social Poster")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@Roles("admin")
|
@Roles("superadmin")
|
||||||
@Controller("social-poster")
|
@Controller("social-poster")
|
||||||
export class SocialPosterController {
|
export class SocialPosterController {
|
||||||
constructor(private readonly socialPosterService: SocialPosterService) {}
|
constructor(private readonly socialPosterService: SocialPosterService) {}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export class SporTotoController {
|
|||||||
|
|
||||||
@Post("sync")
|
@Post("sync")
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Roles("admin")
|
@Roles("superadmin")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -114,7 +114,7 @@ export class SporTotoController {
|
|||||||
|
|
||||||
@Post("bulletins")
|
@Post("bulletins")
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Roles("admin")
|
@Roles("superadmin")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -135,7 +135,7 @@ export class SporTotoController {
|
|||||||
|
|
||||||
@Patch("bulletins/:id/results")
|
@Patch("bulletins/:id/results")
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Roles("admin")
|
@Roles("superadmin")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export class UsersController extends BaseController<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Override create to require admin role
|
// Override create to require admin role
|
||||||
@Roles("admin")
|
@Roles("superadmin")
|
||||||
async create(
|
async create(
|
||||||
...args: Parameters<
|
...args: Parameters<
|
||||||
BaseController<User, CreateUserDto, UpdateUserDto>["create"]
|
BaseController<User, CreateUserDto, UpdateUserDto>["create"]
|
||||||
@@ -94,7 +94,7 @@ export class UsersController extends BaseController<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Override delete to require admin role
|
// Override delete to require admin role
|
||||||
@Roles("admin")
|
@Roles("superadmin")
|
||||||
async delete(
|
async delete(
|
||||||
...args: Parameters<
|
...args: Parameters<
|
||||||
BaseController<User, CreateUserDto, UpdateUserDto>["delete"]
|
BaseController<User, CreateUserDto, UpdateUserDto>["delete"]
|
||||||
|
|||||||
+28
-15
@@ -1,7 +1,9 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { HttpService } from "@nestjs/axios";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { firstValueFrom } from "rxjs";
|
import {
|
||||||
|
AiEngineClient,
|
||||||
|
AiEngineRequestError,
|
||||||
|
} from "../common/utils/ai-engine-client";
|
||||||
|
|
||||||
export interface AIPredictionResult {
|
export interface AIPredictionResult {
|
||||||
matchId: string;
|
matchId: string;
|
||||||
@@ -40,13 +42,21 @@ export interface AIPredictionResult {
|
|||||||
export class AiService {
|
export class AiService {
|
||||||
private readonly logger = new Logger(AiService.name);
|
private readonly logger = new Logger(AiService.name);
|
||||||
private readonly pythonEngineUrl: string;
|
private readonly pythonEngineUrl: string;
|
||||||
|
private readonly aiEngineClient: AiEngineClient;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly httpService: HttpService,
|
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.pythonEngineUrl =
|
this.pythonEngineUrl =
|
||||||
this.configService.get("AI_ENGINE_URL") || "http://127.0.0.1:8000";
|
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}`,
|
`Calling Python V25 Engine for ${matchDetails.homeTeam} vs ${matchDetails.awayTeam}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await firstValueFrom(
|
const response = await this.aiEngineClient.post(
|
||||||
this.httpService.post(
|
`/v20plus/analyze/${matchId}`,
|
||||||
`${this.pythonEngineUrl}/v20plus/analyze/${matchId}`,
|
|
||||||
{},
|
{},
|
||||||
{
|
|
||||||
timeout: 30000,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
@@ -86,8 +91,14 @@ export class AiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
this.logger.warn(`Python Engine error: ${error.message}`);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,10 +297,12 @@ export class AiService {
|
|||||||
*/
|
*/
|
||||||
async checkHealth(): Promise<boolean> {
|
async checkHealth(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await this.aiEngineClient.get<{ status?: string }>(
|
||||||
this.httpService.get(`${this.pythonEngineUrl}/health`, {
|
"/health",
|
||||||
|
{
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
}),
|
retryCount: 0,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return response.data?.status === "healthy";
|
return response.data?.status === "healthy";
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+170
-91
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { Cron } from "@nestjs/schedule";
|
import { Cron } from "@nestjs/schedule";
|
||||||
import { HttpService } from "@nestjs/axios";
|
import { HttpService } from "@nestjs/axios";
|
||||||
import { PrismaService } from "../database/prisma.service";
|
import { PrismaService } from "../database/prisma.service";
|
||||||
@@ -8,10 +8,22 @@ import * as fs from "fs";
|
|||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { SidelinedResponse } from "../modules/feeder/feeder.types";
|
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
|
// Types
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface LiveScoreTeamPayload {
|
interface LiveScoreTeamPayload {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -64,75 +76,119 @@ interface LiveLineupsJson {
|
|||||||
|
|
||||||
type SportType = "football" | "basketball";
|
type SportType = "football" | "basketball";
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
// Service
|
// Service
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataFetcherTask {
|
export class DataFetcherTask {
|
||||||
private readonly logger = new Logger(DataFetcherTask.name);
|
private readonly logger = new Logger(DataFetcherTask.name);
|
||||||
|
private readonly timeZone = "Europe/Istanbul";
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly scraper: FeederScraperService,
|
private readonly scraper: FeederScraperService,
|
||||||
|
private readonly taskLock: TaskLockService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
// CRON 1: Main sync — every 15 minutes
|
// CRON 1: Main sync — every 15 minutes
|
||||||
// Phases: match list → live scores → odds → lineups
|
// Phases: match list → live scores → odds → lineups
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Cron("*/15 * * * *")
|
@Cron("*/15 * * * *")
|
||||||
async syncLiveMatches(): Promise<void> {
|
async syncLiveMatches(): Promise<void> {
|
||||||
if (this.shouldSkipInHistoricalMode("syncLiveMatches")) return;
|
if (this.shouldSkipInHistoricalMode("syncLiveMatches")) return;
|
||||||
this.logger.log("━━━ syncLiveMatches START ━━━");
|
await this.taskLock.runWithLease(
|
||||||
|
"syncLiveMatches",
|
||||||
const today = new Date().toISOString().split("T")[0];
|
30 * 60 * 1000,
|
||||||
|
async () => {
|
||||||
// Phase 1: Match list (football + basketball)
|
await this.runLiveSync();
|
||||||
await this.syncMatchList(today);
|
},
|
||||||
|
this.logger,
|
||||||
// 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 ━━━");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
// CRON 2: Daily cleanup + full sync — 07:00 Istanbul
|
// CRON 2: Daily cleanup + full sync — 07:00 Istanbul
|
||||||
// Truncates live_matches, then runs full sync
|
// Preserve yesterday as a fallback until the 08:00 archive job completes.
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Cron("0 7 * * *", { timeZone: "Europe/Istanbul" })
|
@Cron("0 7 * * *", { timeZone: "Europe/Istanbul" })
|
||||||
async cleanAndFullSync(): Promise<void> {
|
async cleanAndFullSync(): Promise<void> {
|
||||||
if (this.shouldSkipInHistoricalMode("cleanAndFullSync")) return;
|
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 {
|
try {
|
||||||
const deleted = await this.prisma.liveMatch.deleteMany({});
|
const yesterdayDate = getShiftedDateStringInTimeZone(
|
||||||
|
-1,
|
||||||
|
this.timeZone,
|
||||||
|
);
|
||||||
|
const { startMs: yesterdayStartMs } = getDayBoundsForTimeZone(
|
||||||
|
yesterdayDate,
|
||||||
|
this.timeZone,
|
||||||
|
);
|
||||||
|
const cutoffDate = new Date(yesterdayStartMs);
|
||||||
|
|
||||||
|
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(
|
this.logger.log(
|
||||||
`🧹 Deleted ${deleted.count} live matches. Starting full sync...`,
|
`Pruned ${deleted.count} stale live matches. Starting full sync...`,
|
||||||
);
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
this.logger.error(`Truncate failed: ${message}`);
|
this.logger.error(`Stale live_match cleanup failed: ${message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run full sync immediately after cleanup
|
await this.runLiveSync();
|
||||||
await this.syncLiveMatches();
|
},
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
// Phase 1: Fetch match list for all sports
|
// 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> {
|
private async syncMatchList(date: string): Promise<void> {
|
||||||
// Football
|
// Football
|
||||||
@@ -141,7 +197,7 @@ export class DataFetcherTask {
|
|||||||
await this.fetchMatchesForSport("football", date, footballLeagues);
|
await this.fetchMatchesForSport("football", date, footballLeagues);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(
|
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());
|
await this.fetchMatchesForSport("football", date, new Set());
|
||||||
}
|
}
|
||||||
@@ -170,17 +226,18 @@ export class DataFetcherTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
// Phase 2: Live score updates (merged from live-updater.task)
|
// Phase 2: Live score updates (merged from live-updater.task)
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async updateLiveScores(): Promise<void> {
|
private async updateLiveScores(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const liveMatches = await this.prisma.liveMatch.findMany({
|
const liveMatches = await this.prisma.liveMatch.findMany({
|
||||||
where: {
|
where: {
|
||||||
state: {
|
OR: [
|
||||||
in: ["live", "firsthalf", "secondhalf", "1H", "2H", "HT", "LIVE"],
|
{ state: { in: LIVE_STATE_VALUES_FOR_DB } },
|
||||||
},
|
{ status: { in: LIVE_STATUS_VALUES_FOR_DB } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
select: { id: true, matchSlug: true },
|
select: { id: true, matchSlug: true },
|
||||||
});
|
});
|
||||||
@@ -191,7 +248,7 @@ export class DataFetcherTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`📡 Updating scores for ${liveMatches.length} live matches`,
|
`📡 Updating scores for ${liveMatches.length} live matches`,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const match of liveMatches) {
|
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) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
this.logger.error(`Live score update failed: ${message}`);
|
this.logger.error(`Live score update failed: ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
// Phase 3: Odds + referee + lineups + sidelined
|
// Phase 3: Odds + referee + lineups + sidelined
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async fetchOddsForMatches(): Promise<void> {
|
private async fetchOddsForMatches(): Promise<void> {
|
||||||
this.logger.log("💰 Fetching odds for live matches...");
|
this.logger.log("💰 Fetching odds for live matches...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load both league filters
|
// Load both league filters
|
||||||
@@ -266,11 +323,11 @@ export class DataFetcherTask {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (matchesToFetch.length === 0) {
|
if (matchesToFetch.length === 0) {
|
||||||
this.logger.log("💰 No matches to fetch odds for");
|
this.logger.log("💰 No matches to fetch odds for");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`💰 Fetching odds for ${matchesToFetch.length} matches`);
|
this.logger.log(`💰 Fetching odds for ${matchesToFetch.length} matches`);
|
||||||
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
@@ -299,7 +356,7 @@ export class DataFetcherTask {
|
|||||||
// Retry failed matches (502/Timeout)
|
// Retry failed matches (502/Timeout)
|
||||||
if (failedMatches.length > 0) {
|
if (failedMatches.length > 0) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`⚠️ Retrying ${failedMatches.length} failed matches (502/Timeout)...`,
|
`âš ï¸ Retrying ${failedMatches.length} failed matches (502/Timeout)...`,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const match of failedMatches) {
|
for (const match of failedMatches) {
|
||||||
@@ -307,19 +364,19 @@ export class DataFetcherTask {
|
|||||||
try {
|
try {
|
||||||
await this.processMatchOdds(match);
|
await this.processMatchOdds(match);
|
||||||
successCount++;
|
successCount++;
|
||||||
this.logger.log(`✅ Retry successful for match ${match.id}`);
|
this.logger.log(`✅ Retry successful for match ${match.id}`);
|
||||||
} catch (retryErr: unknown) {
|
} catch (retryErr: unknown) {
|
||||||
const message =
|
const message =
|
||||||
retryErr instanceof Error ? retryErr.message : String(retryErr);
|
retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`❌ Retry failed for match ${match.id}: ${message}`,
|
`⌠Retry failed for match ${match.id}: ${message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`💰 Odds complete: ${successCount} success, ${errorCount} errors (initially)`,
|
`💰 Odds complete: ${successCount} success, ${errorCount} errors (initially)`,
|
||||||
);
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
@@ -327,14 +384,36 @@ export class DataFetcherTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
// Phase 4: Fill missing lineups (backup)
|
// Phase 4: Fill missing lineups (backup)
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async fillMissingLineups(): Promise<void> {
|
private async fillMissingLineups(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const matchesToUpdate = await this.prisma.liveMatch.findMany({
|
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 },
|
select: { id: true, matchSlug: true, lineups: true, sport: true },
|
||||||
take: 30,
|
take: 30,
|
||||||
});
|
});
|
||||||
@@ -345,11 +424,11 @@ export class DataFetcherTask {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (toUpdate.length === 0) {
|
if (toUpdate.length === 0) {
|
||||||
this.logger.debug("👕 All lineups already filled");
|
this.logger.debug("👕 All lineups already filled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`👕 Filling lineups for ${toUpdate.length} matches...`);
|
this.logger.log(`👕 Filling lineups for ${toUpdate.length} matches...`);
|
||||||
|
|
||||||
for (const match of toUpdate) {
|
for (const match of toUpdate) {
|
||||||
try {
|
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);
|
await this.delay(500);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
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(
|
private async fetchMatchesForSport(
|
||||||
sport: SportType,
|
sport: SportType,
|
||||||
@@ -650,7 +729,7 @@ export class DataFetcherTask {
|
|||||||
upsertCount + skippedCount === targetMatches.length
|
upsertCount + skippedCount === targetMatches.length
|
||||||
) {
|
) {
|
||||||
this.logger.log(
|
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) {
|
} catch (err: unknown) {
|
||||||
@@ -668,10 +747,10 @@ export class DataFetcherTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
// processMatchOdds — odds + referee + lineups + sidelined
|
// processMatchOdds — odds + referee + lineups + sidelined
|
||||||
// (Preserved from original — no logic changes)
|
// (Preserved from original — no logic changes)
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async processMatchOdds(match: LiveMatchOddsTarget): Promise<void> {
|
private async processMatchOdds(match: LiveMatchOddsTarget): Promise<void> {
|
||||||
const matchSlug = match.matchSlug || "match";
|
const matchSlug = match.matchSlug || "match";
|
||||||
@@ -687,7 +766,7 @@ export class DataFetcherTask {
|
|||||||
let lineups: LiveLineupsJson | null = null;
|
let lineups: LiveLineupsJson | null = null;
|
||||||
let sidelined: SidelinedResponse | 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}`;
|
const oddsUrl = `https://www.mackolik.com/${sportPath}/${matchSlug}/iddaa/${match.id}`;
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
@@ -722,7 +801,7 @@ export class DataFetcherTask {
|
|||||||
typeof mainResp.data === "string" ? mainResp.data : "",
|
typeof mainResp.data === "string" ? mainResp.data : "",
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical — referee is optional
|
// Non-critical — referee is optional
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,7 +830,7 @@ export class DataFetcherTask {
|
|||||||
subs: substitutions?.stats?.away || [],
|
subs: substitutions?.stats?.away || [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
this.logger.log(`👥 Lineups found for ${match.matchName}`);
|
this.logger.log(`👥 Lineups found for ${match.matchName}`);
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(`No lineups (yet) for ${match.matchName}`);
|
this.logger.debug(`No lineups (yet) for ${match.matchName}`);
|
||||||
}
|
}
|
||||||
@@ -779,7 +858,7 @@ export class DataFetcherTask {
|
|||||||
sidelined.awayTeam?.totalSidelined > 0
|
sidelined.awayTeam?.totalSidelined > 0
|
||||||
) {
|
) {
|
||||||
this.logger.log(
|
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))
|
sidelined.awayTeam.totalSidelined > 0))
|
||||||
) {
|
) {
|
||||||
this.logger.log(
|
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 {
|
} else {
|
||||||
this.logger.debug(
|
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
|
* 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(
|
private extractOddsFromHtml(
|
||||||
html: string,
|
html: string,
|
||||||
@@ -914,17 +993,17 @@ export class DataFetcherTask {
|
|||||||
const lower = name.toLowerCase();
|
const lower = name.toLowerCase();
|
||||||
|
|
||||||
// Specific & Compound names FIRST
|
// Specific & Compound names FIRST
|
||||||
if (lower.includes("ilk yarı/maç sonucu")) return "HTFT";
|
if (lower.includes("ilk yarı/maç sonucu")) return "HTFT";
|
||||||
if (lower.includes("1. yarı sonucu")) return "HT";
|
if (lower.includes("1. yarı sonucu")) return "HT";
|
||||||
if (lower.includes("çifte şans")) return "CS";
|
if (lower.includes("çifte şans")) return "CS";
|
||||||
|
|
||||||
// General names LATER
|
// General names LATER
|
||||||
if (lower.includes("maç sonucu") && !lower.includes("handikap"))
|
if (lower.includes("maç sonucu") && !lower.includes("handikap"))
|
||||||
return "MS";
|
return "MS";
|
||||||
if (lower.includes("karşılıklı gol")) return "KG";
|
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("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("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("3,5 alt/üst") || lower.includes("3.5")) return "AU35";
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -934,7 +1013,7 @@ export class DataFetcherTask {
|
|||||||
*/
|
*/
|
||||||
private extractRefereeFromHtml(html: string): string | null {
|
private extractRefereeFromHtml(html: string): string | null {
|
||||||
try {
|
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 =
|
const mainOfficialPattern =
|
||||||
/official-list-item--main[^>]*>\s*(?:<[^>]*>\s*)*?<span[^>]*official-name[^>]*>\s*([^<]+)/i;
|
/official-list-item--main[^>]*>\s*(?:<[^>]*>\s*)*?<span[^>]*official-name[^>]*>\s*([^<]+)/i;
|
||||||
const mainMatch = mainOfficialPattern.exec(html);
|
const mainMatch = mainOfficialPattern.exec(html);
|
||||||
@@ -970,9 +1049,9 @@ export class DataFetcherTask {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
// Low-level Helpers (preserved — no logic changes)
|
// Low-level Helpers (preserved — no logic changes)
|
||||||
// ────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private shouldSkipInHistoricalMode(jobName: string): boolean {
|
private shouldSkipInHistoricalMode(jobName: string): boolean {
|
||||||
if (process.env.FEEDER_MODE === "historical") {
|
if (process.env.FEEDER_MODE === "historical") {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { Cron } from "@nestjs/schedule";
|
import { Cron } from "@nestjs/schedule";
|
||||||
import { FeederService } from "../modules/feeder/feeder.service";
|
import { FeederService } from "../modules/feeder/feeder.service";
|
||||||
|
import { TaskLockService } from "./task-lock.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HistoricalResultsSyncTask {
|
export class HistoricalResultsSyncTask {
|
||||||
private readonly logger = new Logger(HistoricalResultsSyncTask.name);
|
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 {
|
private shouldSkipInHistoricalMode(jobName: string): boolean {
|
||||||
if (process.env.FEEDER_MODE === "historical") {
|
if (process.env.FEEDER_MODE === "historical") {
|
||||||
@@ -25,6 +29,10 @@ export class HistoricalResultsSyncTask {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.taskLock.runWithLease(
|
||||||
|
"syncPreviousDayCompletedMatches",
|
||||||
|
6 * 60 * 60 * 1000,
|
||||||
|
async () => {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
"Starting previous-day completed match sync for football and basketball...",
|
"Starting previous-day completed match sync for football and basketball...",
|
||||||
);
|
);
|
||||||
@@ -37,5 +45,8 @@ export class HistoricalResultsSyncTask {
|
|||||||
`Previous-day completed match sync failed: ${error.message}`,
|
`Previous-day completed match sync failed: ${error.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { Cron } from "@nestjs/schedule";
|
import { Cron } from "@nestjs/schedule";
|
||||||
import { PrismaService } from "../database/prisma.service";
|
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()
|
@Injectable()
|
||||||
export class LimitResetterTask {
|
export class LimitResetterTask {
|
||||||
private readonly logger = new Logger(LimitResetterTask.name);
|
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 {
|
private shouldSkipInHistoricalMode(jobName: string): boolean {
|
||||||
if (process.env.FEEDER_MODE === "historical") {
|
if (process.env.FEEDER_MODE === "historical") {
|
||||||
@@ -22,13 +38,15 @@ export class LimitResetterTask {
|
|||||||
@Cron("0 3 * * *", { timeZone: "Europe/Istanbul" })
|
@Cron("0 3 * * *", { timeZone: "Europe/Istanbul" })
|
||||||
async resetUsageLimits() {
|
async resetUsageLimits() {
|
||||||
if (this.shouldSkipInHistoricalMode("resetUsageLimits")) return;
|
if (this.shouldSkipInHistoricalMode("resetUsageLimits")) return;
|
||||||
|
await this.taskLock.runWithLease(
|
||||||
|
"resetUsageLimits",
|
||||||
|
30 * 60 * 1000,
|
||||||
|
async () => {
|
||||||
this.logger.log("Starting daily usage limit reset job...");
|
this.logger.log("Starting daily usage limit reset job...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const today = new Date();
|
const today = getDateOnlyValueForTimeZone(this.timeZone);
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
// Reset all limits that were last reset before today
|
|
||||||
const result = await this.prisma.usageLimit.updateMany({
|
const result = await this.prisma.usageLimit.updateMany({
|
||||||
where: {
|
where: {
|
||||||
lastResetDate: { lt: today },
|
lastResetDate: { lt: today },
|
||||||
@@ -50,6 +68,9 @@ export class LimitResetterTask {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Limit reset job failed: ${error.message}`);
|
this.logger.error(`Limit reset job failed: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,28 +79,53 @@ export class LimitResetterTask {
|
|||||||
@Cron("0 4 * * *", { timeZone: "Europe/Istanbul" })
|
@Cron("0 4 * * *", { timeZone: "Europe/Istanbul" })
|
||||||
async cleanupOldData() {
|
async cleanupOldData() {
|
||||||
if (this.shouldSkipInHistoricalMode("cleanupOldData")) return;
|
if (this.shouldSkipInHistoricalMode("cleanupOldData")) return;
|
||||||
|
await this.taskLock.runWithLease(
|
||||||
|
"cleanupOldData",
|
||||||
|
60 * 60 * 1000,
|
||||||
|
async () => {
|
||||||
this.logger.log("Starting data cleanup job...");
|
this.logger.log("Starting data cleanup job...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const thirtyDaysAgo = new Date();
|
const thirtyDaysAgo = new Date();
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
// Delete old AI prediction logs
|
|
||||||
const deletedLogs = await this.prisma.aiPredictionsLog.deleteMany({
|
const deletedLogs = await this.prisma.aiPredictionsLog.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
createdAt: { lt: thirtyDaysAgo },
|
createdAt: { lt: thirtyDaysAgo },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete old live matches (finished more than 1 day ago)
|
const yesterdayDate = getShiftedDateStringInTimeZone(
|
||||||
// Historical data is already persisted in the 'matches' table
|
-1,
|
||||||
const oneDayAgo = new Date();
|
this.timeZone,
|
||||||
oneDayAgo.setDate(oneDayAgo.getDate() - 1);
|
);
|
||||||
|
const { startMs: yesterdayStartMs } = getDayBoundsForTimeZone(
|
||||||
|
yesterdayDate,
|
||||||
|
this.timeZone,
|
||||||
|
);
|
||||||
|
const liveMatchCutoff = new Date(yesterdayStartMs);
|
||||||
|
|
||||||
const deletedLiveMatches = await this.prisma.liveMatch.deleteMany({
|
const deletedLiveMatches = await this.prisma.liveMatch.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
state: "Finished",
|
updatedAt: { lt: liveMatchCutoff },
|
||||||
updatedAt: { lt: oneDayAgo },
|
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 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,6 +135,9 @@ export class LimitResetterTask {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Cleanup job failed: ${error.message}`);
|
this.logger.error(`Cleanup job failed: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,6 +146,10 @@ export class LimitResetterTask {
|
|||||||
@Cron("0 0 * * *", { timeZone: "Europe/Istanbul" })
|
@Cron("0 0 * * *", { timeZone: "Europe/Istanbul" })
|
||||||
async checkSubscriptions() {
|
async checkSubscriptions() {
|
||||||
if (this.shouldSkipInHistoricalMode("checkSubscriptions")) return;
|
if (this.shouldSkipInHistoricalMode("checkSubscriptions")) return;
|
||||||
|
await this.taskLock.runWithLease(
|
||||||
|
"checkSubscriptions",
|
||||||
|
30 * 60 * 1000,
|
||||||
|
async () => {
|
||||||
this.logger.log("Checking expired subscriptions...");
|
this.logger.log("Checking expired subscriptions...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -118,5 +171,8 @@ export class LimitResetterTask {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Subscription check failed: ${error.message}`);
|
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 { Module } from "@nestjs/common";
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
|
||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { DataFetcherTask } from "./data-fetcher.task";
|
import { DataFetcherTask } from "./data-fetcher.task";
|
||||||
import { HistoricalResultsSyncTask } from "./historical-results-sync.task";
|
import { HistoricalResultsSyncTask } from "./historical-results-sync.task";
|
||||||
import { LimitResetterTask } from "./limit-resetter.task";
|
import { LimitResetterTask } from "./limit-resetter.task";
|
||||||
|
import { TaskLockService } from "./task-lock.service";
|
||||||
import { DatabaseModule } from "../database/database.module";
|
import { DatabaseModule } from "../database/database.module";
|
||||||
import { FeederModule } from "../modules/feeder/feeder.module";
|
import { FeederModule } from "../modules/feeder/feeder.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ScheduleModule.forRoot(),
|
|
||||||
HttpModule.register({
|
HttpModule.register({
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -20,7 +19,12 @@ import { FeederModule } from "../modules/feeder/feeder.module";
|
|||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
FeederModule,
|
FeederModule,
|
||||||
],
|
],
|
||||||
providers: [DataFetcherTask, HistoricalResultsSyncTask, LimitResetterTask],
|
providers: [
|
||||||
|
TaskLockService,
|
||||||
|
DataFetcherTask,
|
||||||
|
HistoricalResultsSyncTask,
|
||||||
|
LimitResetterTask,
|
||||||
|
],
|
||||||
exports: [DataFetcherTask, HistoricalResultsSyncTask, LimitResetterTask],
|
exports: [DataFetcherTask, HistoricalResultsSyncTask, LimitResetterTask],
|
||||||
})
|
})
|
||||||
export class TasksModule {}
|
export class TasksModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user