gg
This commit is contained in:
@@ -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`);
|
||||
}
|
||||
Reference in New Issue
Block a user