gg
This commit is contained in:
@@ -259,15 +259,21 @@ export class AdminController {
|
||||
premiumUsers,
|
||||
totalMatches,
|
||||
totalPredictions,
|
||||
totalCoupons,
|
||||
] = await Promise.all([
|
||||
this.prisma.user.count(),
|
||||
this.prisma.user.count({ where: { isActive: true } }),
|
||||
this.prisma.user.count({ where: { subscriptionStatus: "active" } }),
|
||||
this.prisma.match.count(),
|
||||
this.prisma.prediction.count(),
|
||||
this.prisma.userCoupon.count(),
|
||||
]);
|
||||
|
||||
return createSuccessResponse({
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
totalPredictions,
|
||||
totalCoupons,
|
||||
users: {
|
||||
total: totalUsers,
|
||||
active: activeUsers,
|
||||
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
ROLES_KEY,
|
||||
PERMISSIONS_KEY,
|
||||
} from "../../../common/decorators";
|
||||
import { normalizeRole } from "../../../common/constants/roles";
|
||||
|
||||
interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
role?: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
@@ -88,11 +90,28 @@ export class RolesGuard implements CanActivate {
|
||||
|
||||
const user = req.user as AuthenticatedUser | undefined;
|
||||
|
||||
if (!user || !user.roles) {
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
|
||||
const normalizedUserRoles = (user.roles?.length
|
||||
? user.roles
|
||||
: user.role
|
||||
? [user.role]
|
||||
: []
|
||||
).map((role) => normalizeRole(role));
|
||||
|
||||
const normalizedRequiredRoles = requiredRoles.map((role) =>
|
||||
normalizeRole(role),
|
||||
);
|
||||
|
||||
if (normalizedUserRoles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasRole = normalizedRequiredRoles.some((role) =>
|
||||
normalizedUserRoles.includes(role),
|
||||
);
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException("PERMISSION_DENIED");
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PassportStrategy } from "@nestjs/passport";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AuthService, JwtPayload } from "../auth.service";
|
||||
import { normalizeRole } from "../../../common/constants/roles";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
@@ -29,9 +30,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedRole = normalizeRole(payload.role);
|
||||
|
||||
return {
|
||||
...user,
|
||||
role: payload.role,
|
||||
role: normalizedRole,
|
||||
roles: normalizedRole ? [normalizedRole] : [],
|
||||
permissions: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
|
||||
import axios from "axios";
|
||||
import { GeminiService } from "../../gemini/gemini.service";
|
||||
import {
|
||||
AiEngineClient,
|
||||
AiEngineRequestError,
|
||||
} from "../../../common/utils/ai-engine-client";
|
||||
|
||||
export type PredictionRiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
|
||||
export type PredictionDataQuality = "HIGH" | "MEDIUM" | "LOW";
|
||||
@@ -126,24 +129,34 @@ export interface SmartCouponResult {
|
||||
export class SmartCouponService {
|
||||
private readonly logger = new Logger(SmartCouponService.name);
|
||||
private readonly aiEngineUrl: string;
|
||||
private readonly aiEngineClient: AiEngineClient;
|
||||
|
||||
constructor(private readonly geminiService: GeminiService) {
|
||||
this.aiEngineUrl = process.env.AI_ENGINE_URL || "http://ai-engine:8000";
|
||||
this.aiEngineClient = new AiEngineClient({
|
||||
baseUrl: this.aiEngineUrl,
|
||||
logger: this.logger,
|
||||
serviceName: SmartCouponService.name,
|
||||
timeoutMs: 60000,
|
||||
maxRetries: 2,
|
||||
retryDelayMs: 750,
|
||||
});
|
||||
}
|
||||
|
||||
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
|
||||
let prediction: SingleMatchPredictionPackage;
|
||||
try {
|
||||
const response = await axios.post<SingleMatchPredictionPackage>(
|
||||
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
|
||||
const response = await this.aiEngineClient.post<SingleMatchPredictionPackage>(
|
||||
`/v20plus/analyze/${matchId}`,
|
||||
);
|
||||
prediction = response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const detail = error.response?.data?.detail || error.message;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AiEngineRequestError) {
|
||||
const detail =
|
||||
typeof error.detail === "string" ? error.detail : error.message;
|
||||
throw new HttpException(
|
||||
`AI analyze failed: ${detail}`,
|
||||
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
|
||||
error.status || HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
throw new HttpException(
|
||||
@@ -205,8 +218,8 @@ export class SmartCouponService {
|
||||
options: { maxMatches?: number; minConfidence?: number } = {},
|
||||
): Promise<SmartCouponResult> {
|
||||
try {
|
||||
const response = await axios.post<SmartCouponResult>(
|
||||
`${this.aiEngineUrl}/v20plus/coupon`,
|
||||
const response = await this.aiEngineClient.post<SmartCouponResult>(
|
||||
"/v20plus/coupon",
|
||||
{
|
||||
match_ids: matchIds,
|
||||
strategy,
|
||||
@@ -215,13 +228,14 @@ export class SmartCouponService {
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to generate smart coupon", error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
const detail = error.response?.data?.detail || error.message;
|
||||
if (error instanceof AiEngineRequestError) {
|
||||
const detail =
|
||||
typeof error.detail === "string" ? error.detail : error.message;
|
||||
throw new HttpException(
|
||||
`Coupon generation failed: ${detail}`,
|
||||
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
|
||||
error.status || HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
throw new HttpException(
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
BasketballTeamStats,
|
||||
} from "./feeder.types";
|
||||
import { ImageUtils } from "../../common/utils/image.util";
|
||||
import { deriveStoredMatchStatus } from "../../common/utils/match-status.util";
|
||||
|
||||
@Injectable()
|
||||
export class FeederPersistenceService {
|
||||
@@ -311,33 +312,15 @@ export class FeederPersistenceService {
|
||||
headerData?.htScoreAway ??
|
||||
this.safeInt(matchSummary.score?.ht?.away);
|
||||
|
||||
let status = "NS";
|
||||
if (headerData?.matchStatus) {
|
||||
if (
|
||||
headerData.matchStatus === "postGame" ||
|
||||
headerData.matchStatus === "post"
|
||||
) {
|
||||
status = "FT";
|
||||
} else if (
|
||||
headerData.matchStatus === "live" ||
|
||||
headerData.matchStatus === "liveGame"
|
||||
) {
|
||||
status = "LIVE";
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Postponed Matches (ERT)
|
||||
if (matchSummary.statusBoxContent === "ERT") {
|
||||
status = "POSTPONED";
|
||||
}
|
||||
|
||||
if (
|
||||
status === "NS" &&
|
||||
finalScoreHome !== null &&
|
||||
finalScoreAway !== null
|
||||
) {
|
||||
status = "FT";
|
||||
}
|
||||
const status = deriveStoredMatchStatus({
|
||||
state: headerData?.matchStatus ?? matchSummary.state,
|
||||
status: matchSummary.status,
|
||||
substate: matchSummary.substate,
|
||||
statusBoxContent: matchSummary.statusBoxContent,
|
||||
scoreHome: finalScoreHome,
|
||||
scoreAway: finalScoreAway,
|
||||
score: matchSummary.score,
|
||||
});
|
||||
|
||||
await tx.match.upsert({
|
||||
where: { id: matchId },
|
||||
@@ -870,15 +853,11 @@ export class FeederPersistenceService {
|
||||
}
|
||||
|
||||
async getExistingMatchIds(matchIds: string[]): Promise<string[]> {
|
||||
// Only consider matches "existing" if they have ALL key data points
|
||||
// This allows re-fetching matches that exist but have missing data
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
id: { in: matchIds },
|
||||
AND: [
|
||||
{ oddCategories: { some: {} } },
|
||||
{ playerEvents: { some: {} } },
|
||||
{ officials: { some: {} } },
|
||||
{
|
||||
OR: [
|
||||
{ footballTeamStats: { some: {} } },
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DbEventPayload,
|
||||
DbMarketPayload,
|
||||
} from "./feeder.types";
|
||||
import { isMatchCompleted } from "../../common/utils/match-status.util";
|
||||
|
||||
interface ProcessDateOptions {
|
||||
onlyCompletedMatches?: boolean;
|
||||
@@ -113,51 +114,16 @@ export class FeederService {
|
||||
};
|
||||
}
|
||||
|
||||
private parseScoreValue(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private isCompletedMatchSummary(match: MatchSummary): boolean {
|
||||
if (match.statusBoxContent === "ERT") return false;
|
||||
|
||||
const normalizedState = String(match.state || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const normalizedStatus = String(match.status || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const normalizedSubstate = String(match.substate || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (["postgame", "post"].includes(normalizedState)) return true;
|
||||
|
||||
if (
|
||||
["played", "finished", "ft", "afterpenalties", "penalties"].includes(
|
||||
normalizedStatus,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
["postgame", "post", "played", "finished", "ft"].includes(
|
||||
normalizedSubstate,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const homeScore = this.parseScoreValue(
|
||||
match.score?.home ?? match.homeScore,
|
||||
);
|
||||
const awayScore = this.parseScoreValue(
|
||||
match.score?.away ?? match.awayScore,
|
||||
);
|
||||
|
||||
return homeScore !== null && awayScore !== null;
|
||||
return isMatchCompleted({
|
||||
state: match.state,
|
||||
status: match.status,
|
||||
substate: match.substate,
|
||||
statusBoxContent: match.statusBoxContent,
|
||||
score: match.score,
|
||||
scoreHome: match.homeScore,
|
||||
scoreAway: match.awayScore,
|
||||
});
|
||||
}
|
||||
|
||||
async runPreviousDayCompletedMatchesScan(
|
||||
@@ -957,15 +923,30 @@ export class FeederService {
|
||||
*/
|
||||
// ==========================================
|
||||
|
||||
if (saved && hasCriticalError) {
|
||||
// Collect missing components
|
||||
const missingParts: string[] = [];
|
||||
if (!stats) missingParts.push("Stats");
|
||||
const completedMatch = isMatchCompleted({
|
||||
state: headerData?.matchStatus ?? matchSummary.state,
|
||||
status: matchSummary.status,
|
||||
substate: matchSummary.substate,
|
||||
statusBoxContent: matchSummary.statusBoxContent,
|
||||
scoreHome: headerData?.scoreHome ?? matchSummary.score?.home,
|
||||
scoreAway: headerData?.scoreAway ?? matchSummary.score?.away,
|
||||
});
|
||||
|
||||
const missingParts: string[] = [];
|
||||
if (scope === "all" && completedMatch) {
|
||||
if (sport === "football" && !stats) missingParts.push("Stats");
|
||||
if (sport === "basketball" && !basketballTeamStats)
|
||||
missingParts.push("BoxScore");
|
||||
if (oddsArray.length === 0) missingParts.push("Odds");
|
||||
if (officialsData.length === 0) missingParts.push("Officials");
|
||||
}
|
||||
|
||||
if (saved && (hasCriticalError || missingParts.length > 0)) {
|
||||
const reason = hasCriticalError
|
||||
? "missing data after upstream errors"
|
||||
: "incomplete completed-match payload";
|
||||
|
||||
this.logger.warn(
|
||||
`[${matchId}] Saved with MISSING DATA (502). Missing: [${missingParts.join(", ")}]. Scheduled for retry.`,
|
||||
`[${matchId}] Saved with ${reason}. Missing: [${missingParts.join(", ")}]. Scheduled for retry.`,
|
||||
);
|
||||
return { success: false, retryable: true };
|
||||
}
|
||||
|
||||
@@ -1,44 +1,90 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { Controller, Get, Res } from "@nestjs/common";
|
||||
import { ApiTags, ApiOperation } from "@nestjs/swagger";
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckService,
|
||||
PrismaHealthIndicator,
|
||||
} from "@nestjs/terminus";
|
||||
import { Response } from "express";
|
||||
import { Public } from "../../common/decorators";
|
||||
import { PrismaService } from "../../database/prisma.service";
|
||||
import { PredictionsService } from "../predictions/predictions.service";
|
||||
|
||||
@ApiTags("Health")
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private health: HealthCheckService,
|
||||
private prismaHealth: PrismaHealthIndicator,
|
||||
private prisma: PrismaService,
|
||||
private readonly predictionsService: PredictionsService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
@HealthCheck()
|
||||
@ApiOperation({ summary: "Basic health check" })
|
||||
check() {
|
||||
return this.health.check([]);
|
||||
async check(@Res() response: Response) {
|
||||
const database = await this.getDatabaseHealth();
|
||||
const aiEngine = await this.predictionsService.checkHealth();
|
||||
const ok = database.status === "up" && aiEngine.predictionServiceReady;
|
||||
|
||||
return response.status(ok ? 200 : 503).json({
|
||||
status: ok ? "ok" : "degraded",
|
||||
timestamp: new Date().toISOString(),
|
||||
checks: {
|
||||
database,
|
||||
aiEngine,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Get("ready")
|
||||
@Public()
|
||||
@HealthCheck()
|
||||
@ApiOperation({ summary: "Readiness check (includes database)" })
|
||||
readiness() {
|
||||
return this.health.check([
|
||||
() => this.prismaHealth.pingCheck("database", this.prisma),
|
||||
]);
|
||||
async readiness(@Res() response: Response) {
|
||||
const database = await this.getDatabaseHealth();
|
||||
const aiEngine = await this.predictionsService.checkHealth();
|
||||
const ready = database.status === "up" && aiEngine.predictionServiceReady;
|
||||
|
||||
return response.status(ready ? 200 : 503).json({
|
||||
status: ready ? "ready" : "not_ready",
|
||||
timestamp: new Date().toISOString(),
|
||||
checks: {
|
||||
database,
|
||||
aiEngine,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Get("live")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Liveness check" })
|
||||
liveness() {
|
||||
return { status: "ok", timestamp: new Date().toISOString() };
|
||||
liveness(@Res() response: Response) {
|
||||
return response
|
||||
.status(200)
|
||||
.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||
}
|
||||
|
||||
@Get("dependencies")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Dependency-level health details" })
|
||||
async dependencies(@Res() response: Response) {
|
||||
const database = await this.getDatabaseHealth();
|
||||
const aiEngine = await this.predictionsService.checkHealth();
|
||||
|
||||
return response.status(200).json({
|
||||
timestamp: new Date().toISOString(),
|
||||
checks: {
|
||||
database,
|
||||
aiEngine,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getDatabaseHealth() {
|
||||
try {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
return {
|
||||
status: "up",
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
status: "down",
|
||||
detail: error instanceof Error ? error.message : "Unknown database error",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TerminusModule } from "@nestjs/terminus";
|
||||
import { PrismaHealthIndicator } from "@nestjs/terminus";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { PredictionsModule } from "../predictions/predictions.module";
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule],
|
||||
imports: [PredictionsModule],
|
||||
controllers: [HealthController],
|
||||
providers: [PrismaHealthIndicator],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
||||
@@ -9,6 +9,13 @@ import {
|
||||
ActiveLeagueDto,
|
||||
} from "./dto";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import {
|
||||
FINISHED_STATE_VALUES_FOR_DB,
|
||||
FINISHED_STATUS_VALUES_FOR_DB,
|
||||
LIVE_STATE_VALUES_FOR_DB,
|
||||
LIVE_STATUS_VALUES_FOR_DB,
|
||||
getDisplayMatchStatus,
|
||||
} from "../../common/utils/match-status.util";
|
||||
|
||||
@Injectable()
|
||||
export class MatchesService {
|
||||
@@ -38,23 +45,12 @@ export class MatchesService {
|
||||
OR: [
|
||||
{
|
||||
status: {
|
||||
in: [
|
||||
"LIVE",
|
||||
"1H",
|
||||
"2H",
|
||||
"HT",
|
||||
"1Q",
|
||||
"2Q",
|
||||
"3Q",
|
||||
"4Q",
|
||||
"Playing",
|
||||
"Half Time",
|
||||
],
|
||||
in: LIVE_STATUS_VALUES_FOR_DB,
|
||||
},
|
||||
},
|
||||
{
|
||||
state: {
|
||||
in: ["live", "firsthalf", "secondhalf"],
|
||||
in: LIVE_STATE_VALUES_FOR_DB,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -66,14 +62,23 @@ export class MatchesService {
|
||||
OR: [
|
||||
{
|
||||
status: {
|
||||
in: ["Finished", "Played", "FT", "AET", "PEN", "Ended"],
|
||||
in: FINISHED_STATUS_VALUES_FOR_DB,
|
||||
},
|
||||
},
|
||||
{
|
||||
state: {
|
||||
in: ["Finished", "post", "FT", "postGame"],
|
||||
in: FINISHED_STATE_VALUES_FOR_DB,
|
||||
},
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{ scoreHome: { not: null } },
|
||||
{ scoreAway: { not: null } },
|
||||
{
|
||||
NOT: this.getLiveFilter(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -325,16 +330,13 @@ export class MatchesService {
|
||||
}
|
||||
|
||||
// Map status for frontend
|
||||
let displayStatus = match.status || "NS";
|
||||
if (match.state === "live") {
|
||||
displayStatus = "LIVE";
|
||||
} else if (
|
||||
match.state === "post" ||
|
||||
match.state === "FT" ||
|
||||
match.status === "Finished"
|
||||
) {
|
||||
displayStatus = "Finished";
|
||||
}
|
||||
const displayStatus = getDisplayMatchStatus({
|
||||
state: match.state,
|
||||
status: match.status,
|
||||
substate: match.substate,
|
||||
scoreHome: match.scoreHome,
|
||||
scoreAway: match.scoreAway,
|
||||
});
|
||||
|
||||
league.matches.push({
|
||||
id: match.id,
|
||||
@@ -562,16 +564,13 @@ export class MatchesService {
|
||||
|
||||
if (liveMatch) {
|
||||
// Map liveMatch status
|
||||
let displayStatus = liveMatch.status || "NS";
|
||||
if (liveMatch.state === "live") {
|
||||
displayStatus = "LIVE";
|
||||
} else if (
|
||||
liveMatch.state === "post" ||
|
||||
liveMatch.state === "FT" ||
|
||||
liveMatch.status === "Finished"
|
||||
) {
|
||||
displayStatus = "Finished";
|
||||
}
|
||||
const displayStatus = getDisplayMatchStatus({
|
||||
state: liveMatch.state,
|
||||
status: liveMatch.status,
|
||||
substate: liveMatch.substate,
|
||||
scoreHome: liveMatch.scoreHome,
|
||||
scoreAway: liveMatch.scoreAway,
|
||||
});
|
||||
|
||||
match = {
|
||||
...liveMatch,
|
||||
|
||||
@@ -461,6 +461,21 @@ export class AIHealthDto {
|
||||
|
||||
@ApiProperty()
|
||||
predictionServiceReady: boolean;
|
||||
|
||||
@ApiProperty({ required: false, default: true })
|
||||
aiEngineReachable?: boolean;
|
||||
|
||||
@ApiProperty({ required: false, enum: ["closed", "open"] })
|
||||
circuitState?: "closed" | "open";
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
consecutiveFailures?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
endpoint?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
export * from "./smart-coupon.dto";
|
||||
|
||||
@@ -19,11 +19,14 @@ import {
|
||||
ValueBetDto,
|
||||
AIHealthDto,
|
||||
} from "./dto";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { FeederService } from "../feeder/feeder.service";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
AiEngineClient,
|
||||
AiEngineRequestError,
|
||||
} from "../../common/utils/ai-engine-client";
|
||||
|
||||
type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW";
|
||||
|
||||
@@ -45,6 +48,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PredictionsService.name);
|
||||
private queueEvents: QueueEvents | null = null;
|
||||
private readonly aiEngineUrl: string;
|
||||
private readonly aiEngineClient: AiEngineClient;
|
||||
private readonly topLeagueIds = new Set<string>();
|
||||
private readonly reasonTranslations: Record<string, string> = {
|
||||
confidence_below_threshold: "Güven eşiğin altında",
|
||||
@@ -125,6 +129,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
"AI_ENGINE_URL",
|
||||
"http://localhost:8000",
|
||||
);
|
||||
this.aiEngineClient = new AiEngineClient({
|
||||
baseUrl: this.aiEngineUrl,
|
||||
logger: this.logger,
|
||||
serviceName: PredictionsService.name,
|
||||
timeoutMs: 60000,
|
||||
maxRetries: 2,
|
||||
retryDelayMs: 750,
|
||||
});
|
||||
this.topLeagueIds = this.loadTopLeagueIds();
|
||||
}
|
||||
|
||||
@@ -149,12 +161,50 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
checkHealth(): Promise<AIHealthDto> {
|
||||
return Promise.resolve({
|
||||
status: "healthy",
|
||||
modelLoaded: true,
|
||||
predictionServiceReady: true,
|
||||
});
|
||||
async checkHealth(): Promise<AIHealthDto> {
|
||||
const circuit = this.aiEngineClient.getSnapshot();
|
||||
|
||||
try {
|
||||
const response = await this.aiEngineClient.get<{
|
||||
status?: string;
|
||||
model_loaded?: boolean;
|
||||
prediction_service_ready?: boolean;
|
||||
}>("/health", {
|
||||
timeout: 5000,
|
||||
retryCount: 0,
|
||||
});
|
||||
|
||||
return {
|
||||
status: response.data?.status || "healthy",
|
||||
modelLoaded: response.data?.model_loaded ?? true,
|
||||
predictionServiceReady:
|
||||
response.data?.prediction_service_ready ?? true,
|
||||
aiEngineReachable: true,
|
||||
circuitState: circuit.state,
|
||||
consecutiveFailures: circuit.consecutiveFailures,
|
||||
endpoint: this.aiEngineUrl,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const requestError =
|
||||
error instanceof AiEngineRequestError
|
||||
? error
|
||||
: new AiEngineRequestError("AI health check failed");
|
||||
|
||||
return {
|
||||
status: requestError.isCircuitOpen ? "circuit_open" : "unhealthy",
|
||||
modelLoaded: false,
|
||||
predictionServiceReady: false,
|
||||
aiEngineReachable: false,
|
||||
circuitState: this.aiEngineClient.getSnapshot().state,
|
||||
consecutiveFailures:
|
||||
this.aiEngineClient.getSnapshot().consecutiveFailures,
|
||||
endpoint: this.aiEngineUrl,
|
||||
detail:
|
||||
typeof requestError.detail === "string"
|
||||
? requestError.detail
|
||||
: requestError.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getPredictionById(matchId: string): Promise<MatchPredictionDto | null> {
|
||||
@@ -182,22 +232,21 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// Direct HTTP mode (no Redis)
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
|
||||
const response = await this.aiEngineClient.post<MatchPredictionDto>(
|
||||
`/v20plus/analyze/${matchId}`,
|
||||
{},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
return this.enrichPredictionResponse(
|
||||
response.data as MatchPredictionDto,
|
||||
matchContext,
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
const error = e as AxiosError<Record<string, unknown>>;
|
||||
const status = error?.response?.status;
|
||||
const detail =
|
||||
error?.response?.data?.detail ||
|
||||
error?.response?.data ||
|
||||
error?.message;
|
||||
const requestError =
|
||||
e instanceof AiEngineRequestError
|
||||
? e
|
||||
: new AiEngineRequestError("AI Engine request failed");
|
||||
const status = requestError.status;
|
||||
const detail = requestError.detail || requestError.message;
|
||||
this.logger.error(
|
||||
`Direct AI Engine call failed for ${matchId}: status=${status}, detail=${JSON.stringify(detail)}`,
|
||||
);
|
||||
@@ -988,14 +1037,18 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// Direct HTTP mode
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.aiEngineUrl}/smart-coupon`,
|
||||
const response = await this.aiEngineClient.post(
|
||||
"/smart-coupon",
|
||||
{ match_ids: matchIds, strategy, ...options },
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof AiEngineRequestError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
this.logger.error(`Direct smart coupon call failed: ${message}`);
|
||||
this.throwAiError(message);
|
||||
}
|
||||
@@ -1018,6 +1071,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
if (message.includes("circuit breaker is open")) {
|
||||
throw new HttpException(
|
||||
"AI Engine is temporarily unavailable",
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
throw new HttpException(
|
||||
"Failed to get prediction from AI Engine",
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { RolesGuard } from "../auth/guards/auth.guards";
|
||||
@ApiTags("Social Poster")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("admin")
|
||||
@Roles("superadmin")
|
||||
@Controller("social-poster")
|
||||
export class SocialPosterController {
|
||||
constructor(private readonly socialPosterService: SocialPosterService) {}
|
||||
|
||||
@@ -43,7 +43,7 @@ export class SporTotoController {
|
||||
|
||||
@Post("sync")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Roles("admin")
|
||||
@Roles("superadmin")
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
@@ -114,7 +114,7 @@ export class SporTotoController {
|
||||
|
||||
@Post("bulletins")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Roles("admin")
|
||||
@Roles("superadmin")
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
@@ -135,7 +135,7 @@ export class SporTotoController {
|
||||
|
||||
@Patch("bulletins/:id/results")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Roles("admin")
|
||||
@Roles("superadmin")
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -84,7 +84,7 @@ export class UsersController extends BaseController<
|
||||
}
|
||||
|
||||
// Override create to require admin role
|
||||
@Roles("admin")
|
||||
@Roles("superadmin")
|
||||
async create(
|
||||
...args: Parameters<
|
||||
BaseController<User, CreateUserDto, UpdateUserDto>["create"]
|
||||
@@ -94,7 +94,7 @@ export class UsersController extends BaseController<
|
||||
}
|
||||
|
||||
// Override delete to require admin role
|
||||
@Roles("admin")
|
||||
@Roles("superadmin")
|
||||
async delete(
|
||||
...args: Parameters<
|
||||
BaseController<User, CreateUserDto, UpdateUserDto>["delete"]
|
||||
|
||||
Reference in New Issue
Block a user