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
+6
View File
@@ -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,
+21 -2
View File
@@ -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");
}
+6 -1
View File
@@ -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: {} } },
+31 -50
View File
@@ -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 };
}
+64 -18
View File
@@ -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",
};
}
}
}
+2 -4
View File
@@ -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 {}
+34 -35
View File
@@ -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,
+15
View File
@@ -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";
+80 -21
View File
@@ -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({
+2 -2
View File
@@ -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"]