This commit is contained in:
2026-04-19 13:23:00 +03:00
parent e4c74025e5
commit 1346924387
25 changed files with 1639 additions and 1076 deletions
+12
View File
@@ -0,0 +1,12 @@
import { UserRole } from "@prisma/client";
export const APP_ROLES = {
user: UserRole.user,
superadmin: UserRole.superadmin,
} as const;
export const ADMIN_ROLES = [APP_ROLES.superadmin] as const;
export function normalizeRole(role: string | null | undefined): string {
return role?.trim().toLowerCase() ?? "";
}
+267
View File
@@ -0,0 +1,267 @@
import axios, {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from "axios";
import { Logger } from "@nestjs/common";
export type AiCircuitState = "closed" | "open";
export interface AiEngineClientOptions {
baseUrl: string;
logger: Logger;
serviceName: string;
timeoutMs?: number;
maxRetries?: number;
retryDelayMs?: number;
circuitBreakerThreshold?: number;
circuitBreakerCooldownMs?: number;
}
interface AiEngineRequestConfig extends AxiosRequestConfig {
retryCount?: number;
}
export interface AiEngineClientSnapshot {
state: AiCircuitState;
consecutiveFailures: number;
openedAt: string | null;
}
export class AiEngineRequestError extends Error {
status?: number;
detail?: unknown;
isCircuitOpen: boolean;
constructor(
message: string,
options: {
status?: number;
detail?: unknown;
isCircuitOpen?: boolean;
} = {},
) {
super(message);
this.name = "AiEngineRequestError";
this.status = options.status;
this.detail = options.detail;
this.isCircuitOpen = options.isCircuitOpen ?? false;
}
}
export class AiEngineClient {
private readonly axiosClient: AxiosInstance;
private readonly logger: Logger;
private readonly serviceName: string;
private readonly defaultTimeoutMs: number;
private readonly maxRetries: number;
private readonly retryDelayMs: number;
private readonly circuitBreakerThreshold: number;
private readonly circuitBreakerCooldownMs: number;
private consecutiveFailures = 0;
private circuitOpenedAt: number | null = null;
constructor(options: AiEngineClientOptions) {
this.logger = options.logger;
this.serviceName = options.serviceName;
this.defaultTimeoutMs = options.timeoutMs ?? 30000;
this.maxRetries = options.maxRetries ?? 2;
this.retryDelayMs = options.retryDelayMs ?? 750;
this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 3;
this.circuitBreakerCooldownMs =
options.circuitBreakerCooldownMs ?? 30000;
this.axiosClient = axios.create({
baseURL: options.baseUrl,
timeout: this.defaultTimeoutMs,
});
}
async get<T>(
path: string,
config?: AiEngineRequestConfig,
): Promise<AxiosResponse<T>> {
return this.request<T>({
method: "get",
url: path,
...config,
});
}
async post<T>(
path: string,
data?: unknown,
config?: AiEngineRequestConfig,
): Promise<AxiosResponse<T>> {
return this.request<T>({
method: "post",
url: path,
data,
...config,
});
}
getSnapshot(): AiEngineClientSnapshot {
return {
state: this.isCircuitOpen() ? "open" : "closed",
consecutiveFailures: this.consecutiveFailures,
openedAt: this.circuitOpenedAt
? new Date(this.circuitOpenedAt).toISOString()
: null,
};
}
private async request<T>(config: AiEngineRequestConfig): Promise<AxiosResponse<T>> {
this.ensureCircuitAvailable();
const retries = this.resolveRetryCount(config);
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const response = await this.axiosClient.request<T>({
timeout: this.defaultTimeoutMs,
...config,
});
this.resetFailures();
return response;
} catch (error) {
lastError = error;
const shouldRetry = attempt < retries && this.isRetriableError(error);
if (!shouldRetry) {
this.registerFailure(error);
throw this.toRequestError(error);
}
this.logger.warn(
`[${this.serviceName}] AI request retry ${attempt + 1}/${retries} for ${config.method?.toUpperCase()} ${config.url}`,
);
await this.delay(this.retryDelayMs * (attempt + 1));
}
}
this.registerFailure(lastError);
throw this.toRequestError(lastError);
}
private resolveRetryCount(config: AiEngineRequestConfig): number {
if (typeof config.retryCount === "number" && config.retryCount >= 0) {
return config.retryCount;
}
return this.maxRetries;
}
private ensureCircuitAvailable() {
if (!this.isCircuitOpen()) {
return;
}
const remainingCooldown =
this.circuitBreakerCooldownMs - (Date.now() - (this.circuitOpenedAt ?? 0));
if (remainingCooldown > 0) {
throw new AiEngineRequestError("AI engine circuit breaker is open", {
status: 503,
detail: {
cooldownRemainingMs: remainingCooldown,
},
isCircuitOpen: true,
});
}
this.logger.warn(
`[${this.serviceName}] AI circuit breaker cooldown elapsed, allowing a recovery attempt`,
);
this.circuitOpenedAt = null;
}
private isCircuitOpen(): boolean {
return this.circuitOpenedAt !== null;
}
private resetFailures() {
this.consecutiveFailures = 0;
this.circuitOpenedAt = null;
}
private registerFailure(error: unknown) {
this.consecutiveFailures += 1;
const normalizedError = this.toRequestError(error);
this.logger.warn(
`[${this.serviceName}] AI request failed (${this.consecutiveFailures}/${this.circuitBreakerThreshold}): ${normalizedError.message}`,
);
if (this.consecutiveFailures >= this.circuitBreakerThreshold) {
this.circuitOpenedAt = Date.now();
this.logger.error(
`[${this.serviceName}] AI circuit breaker opened after ${this.consecutiveFailures} consecutive failures`,
);
}
}
private isRetriableError(error: unknown): boolean {
if (!axios.isAxiosError(error)) {
return false;
}
if (!error.response) {
return true;
}
const status = error.response.status;
return status >= 500 || status === 429 || error.code === "ECONNABORTED";
}
private toRequestError(error: unknown): AiEngineRequestError {
if (error instanceof AiEngineRequestError) {
return error;
}
if (axios.isAxiosError(error)) {
const detail = error.response?.data ?? error.message;
const status = error.response?.status;
const message = this.buildAxiosErrorMessage(error);
return new AiEngineRequestError(message, {
status,
detail,
});
}
if (error instanceof Error) {
return new AiEngineRequestError(error.message);
}
return new AiEngineRequestError("Unknown AI engine error", {
detail: error,
});
}
private buildAxiosErrorMessage(error: AxiosError): string {
if (error.code === "ECONNABORTED") {
return "AI engine request timed out";
}
if (!error.response) {
return "AI engine is unreachable";
}
const detail =
(error.response.data as Record<string, unknown> | undefined)?.detail ??
error.message;
return typeof detail === "string"
? detail
: `AI engine request failed with status ${error.response.status}`;
}
private async delay(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
}
+203
View File
@@ -0,0 +1,203 @@
type ScoreLikeValue = number | string | null | undefined;
type ScoreLike = {
home?: ScoreLikeValue;
away?: ScoreLikeValue;
} | null;
export interface MatchStatusLike {
state?: string | null;
status?: string | null;
substate?: string | null;
statusBoxContent?: string | null;
scoreHome?: ScoreLikeValue;
scoreAway?: ScoreLikeValue;
score?: ScoreLike;
}
const LIVE_STATUS_TOKENS = [
"live",
"livegame",
"playing",
"half time",
"halftime",
"1h",
"2h",
"ht",
"1q",
"2q",
"3q",
"4q",
];
const LIVE_STATE_TOKENS = [
"live",
"livegame",
"firsthalf",
"secondhalf",
"halftime",
"1h",
"2h",
"ht",
"1q",
"2q",
"3q",
"4q",
];
const FINISHED_STATUS_TOKENS = [
"finished",
"played",
"ft",
"aet",
"pen",
"penalties",
"afterpenalties",
"ended",
"post",
"postgame",
"posted",
];
const FINISHED_STATE_TOKENS = [
"finished",
"post",
"postgame",
"posted",
"ft",
"ended",
];
export const LIVE_STATUS_VALUES_FOR_DB = [
"LIVE",
"live",
"1H",
"2H",
"HT",
"1Q",
"2Q",
"3Q",
"4Q",
"Playing",
"Half Time",
"liveGame",
];
export const LIVE_STATE_VALUES_FOR_DB = [
"live",
"liveGame",
"firsthalf",
"secondhalf",
"halfTime",
"1H",
"2H",
"HT",
"1Q",
"2Q",
"3Q",
"4Q",
];
export const FINISHED_STATUS_VALUES_FOR_DB = [
"Finished",
"Played",
"FT",
"AET",
"PEN",
"Ended",
"post",
"postGame",
"posted",
"Posted",
];
export const FINISHED_STATE_VALUES_FOR_DB = [
"Finished",
"post",
"postGame",
"postgame",
"posted",
"FT",
"Ended",
];
function normalizeToken(value: unknown): string {
return String(value || "")
.trim()
.toLowerCase();
}
function parseScoreValue(value: ScoreLikeValue): number | null {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
export function hasResolvedScore(match: MatchStatusLike): boolean {
const homeScore = parseScoreValue(match.score?.home ?? match.scoreHome);
const awayScore = parseScoreValue(match.score?.away ?? match.scoreAway);
return homeScore !== null && awayScore !== null;
}
export function isMatchLive(match: MatchStatusLike): boolean {
const state = normalizeToken(match.state);
const status = normalizeToken(match.status);
const substate = normalizeToken(match.substate);
return (
LIVE_STATE_TOKENS.includes(state) ||
LIVE_STATUS_TOKENS.includes(status) ||
LIVE_STATE_TOKENS.includes(substate)
);
}
export function isMatchCompleted(match: MatchStatusLike): boolean {
if (normalizeToken(match.statusBoxContent) === "ert") {
return false;
}
const state = normalizeToken(match.state);
const status = normalizeToken(match.status);
const substate = normalizeToken(match.substate);
if (
FINISHED_STATE_TOKENS.includes(state) ||
FINISHED_STATUS_TOKENS.includes(status) ||
FINISHED_STATE_TOKENS.includes(substate)
) {
return true;
}
return hasResolvedScore(match) && !isMatchLive(match);
}
export function deriveStoredMatchStatus(match: MatchStatusLike): string {
if (normalizeToken(match.statusBoxContent) === "ert") {
return "POSTPONED";
}
if (isMatchLive(match)) {
return "LIVE";
}
if (isMatchCompleted(match)) {
return "FT";
}
return "NS";
}
export function getDisplayMatchStatus(match: MatchStatusLike): string {
if (isMatchLive(match)) {
return "LIVE";
}
if (isMatchCompleted(match)) {
return "Finished";
}
return String(match.status || match.state || "NS");
}
+82
View File
@@ -0,0 +1,82 @@
function extractDateParts(date: Date, timeZone: string) {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const parts = formatter.formatToParts(date);
const year = Number(parts.find((part) => part.type === "year")?.value);
const month = Number(parts.find((part) => part.type === "month")?.value);
const day = Number(parts.find((part) => part.type === "day")?.value);
return { year, month, day };
}
export function getDateStringInTimeZone(
date: Date,
timeZone: string,
): string {
const { year, month, day } = extractDateParts(date, timeZone);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
export function getShiftedDateStringInTimeZone(
daysOffset: number,
timeZone: string,
baseDate: Date = new Date(),
): string {
const { year, month, day } = extractDateParts(baseDate, timeZone);
const shifted = new Date(Date.UTC(year, month - 1, day));
shifted.setUTCDate(shifted.getUTCDate() + daysOffset);
return shifted.toISOString().split("T")[0];
}
function getTimeZoneOffsetMs(date: Date, timeZone: string): number {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: "shortOffset",
});
const offsetLabel =
formatter.formatToParts(date).find((part) => part.type === "timeZoneName")
?.value || "GMT+0";
const match = offsetLabel.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/);
if (!match) return 0;
const sign = match[1] === "-" ? -1 : 1;
const hours = Number(match[2] || "0");
const minutes = Number(match[3] || "0");
return sign * (hours * 60 + minutes) * 60 * 1000;
}
export function getDayBoundsForTimeZone(
dateString: string,
timeZone: string,
): { startMs: number; endMs: number } {
const [year, month, day] = dateString.split("-").map(Number);
const startGuess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
const nextDayGuess = new Date(Date.UTC(year, month - 1, day + 1, 0, 0, 0));
const startOffsetMs = getTimeZoneOffsetMs(startGuess, timeZone);
const nextDayOffsetMs = getTimeZoneOffsetMs(nextDayGuess, timeZone);
const startMs = Date.UTC(year, month - 1, day, 0, 0, 0) - startOffsetMs;
const nextDayStartMs =
Date.UTC(year, month - 1, day + 1, 0, 0, 0) - nextDayOffsetMs;
return {
startMs,
endMs: nextDayStartMs - 1,
};
}
export function getDateOnlyValueForTimeZone(
timeZone: string,
date: Date = new Date(),
): Date {
return new Date(`${getDateStringInTimeZone(date, timeZone)}T00:00:00.000Z`);
}