This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -22,6 +22,7 @@
|
||||
"ai:backtest": "python ai-engine/scripts/backtest_v2_runtime.py",
|
||||
"ai:train:vqwen": "python ai-engine/scripts/train_vqwen_v3.py",
|
||||
"feeder:historical": "ts-node -r tsconfig-paths/register src/scripts/run-feeder.ts",
|
||||
"feeder:repair": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-repair.ts",
|
||||
"feeder:previous-day": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-previous-day.ts",
|
||||
"feeder:fill-gaps": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-filtered.ts",
|
||||
"feeder:basketball": "ts-node -r tsconfig-paths/register src/scripts/run-feeder-basketball.ts",
|
||||
|
||||
@@ -50,6 +50,7 @@ import { LeaguesModule } from "./modules/leagues/leagues.module";
|
||||
import { AnalysisModule } from "./modules/analysis/analysis.module";
|
||||
import { CouponsModule } from "./modules/coupons/coupons.module";
|
||||
import { SporTotoModule } from "./modules/spor-toto/spor-toto.module";
|
||||
import { AiProxyModule } from "./modules/ai-proxy/ai-proxy.module";
|
||||
|
||||
// Services and Tasks
|
||||
import { ServicesModule } from "./services/services.module";
|
||||
@@ -201,6 +202,7 @@ const historicalFeederMode = process.env.FEEDER_MODE === "historical";
|
||||
AnalysisModule,
|
||||
CouponsModule,
|
||||
SporTotoModule,
|
||||
AiProxyModule,
|
||||
|
||||
// Services and Scheduled Tasks
|
||||
ServicesModule,
|
||||
|
||||
@@ -22,7 +22,7 @@ export const envSchema = z.object({
|
||||
// Database
|
||||
DATABASE_URL: z.string().url(),
|
||||
// AI Engine
|
||||
AI_ENGINE_URL: z.string().url().default("http://localhost:8000"),
|
||||
AI_ENGINE_URL: z.string().url(),
|
||||
AI_ENGINE_MODE: z.enum(["v28-pro-max", "dual"]).default("v28-pro-max"),
|
||||
|
||||
// JWT
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
CACHE_MANAGER,
|
||||
} from "@nestjs/cache-manager";
|
||||
import * as cacheManager from "cache-manager";
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from "@nestjs/swagger";
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse as SwaggerResponse } from "@nestjs/swagger";
|
||||
import { Roles } from "../../common/decorators";
|
||||
import { PrismaService } from "../../database/prisma.service";
|
||||
import { PaginationDto } from "../../common/dto/pagination.dto";
|
||||
@@ -46,6 +46,7 @@ export class AdminController {
|
||||
|
||||
@Get("users")
|
||||
@ApiOperation({ summary: "Get all users (admin)" })
|
||||
@SwaggerResponse({ status: 200, type: [UserResponseDto] })
|
||||
async getAllUsers(
|
||||
@Query() pagination: PaginationDto,
|
||||
): Promise<ApiResponse<PaginatedData<UserResponseDto>>> {
|
||||
@@ -75,6 +76,7 @@ export class AdminController {
|
||||
|
||||
@Get("users/:id")
|
||||
@ApiOperation({ summary: "Get user by ID" })
|
||||
@SwaggerResponse({ status: 200, type: UserResponseDto })
|
||||
async getUserById(
|
||||
@Param("id") id: string,
|
||||
): Promise<ApiResponse<UserResponseDto>> {
|
||||
@@ -98,6 +100,7 @@ export class AdminController {
|
||||
|
||||
@Put("users/:id/toggle-active")
|
||||
@ApiOperation({ summary: "Toggle user active status" })
|
||||
@SwaggerResponse({ status: 200, type: UserResponseDto })
|
||||
async toggleUserActive(
|
||||
@Param("id") id: string,
|
||||
): Promise<ApiResponse<UserResponseDto>> {
|
||||
@@ -120,6 +123,7 @@ export class AdminController {
|
||||
|
||||
@Put("users/:id/role")
|
||||
@ApiOperation({ summary: "Update user role" })
|
||||
@SwaggerResponse({ status: 200, type: UserResponseDto })
|
||||
async updateUserRole(
|
||||
@Param("id") id: string,
|
||||
@Body() data: { role: UserRole },
|
||||
@@ -137,6 +141,7 @@ export class AdminController {
|
||||
|
||||
@Put("users/:id/subscription")
|
||||
@ApiOperation({ summary: "Update user subscription" })
|
||||
@SwaggerResponse({ status: 200, type: UserResponseDto })
|
||||
async updateUserSubscription(
|
||||
@Param("id") id: string,
|
||||
@Body()
|
||||
@@ -160,6 +165,7 @@ export class AdminController {
|
||||
|
||||
@Delete("users/:id")
|
||||
@ApiOperation({ summary: "Soft delete a user" })
|
||||
@SwaggerResponse({ status: 200, description: "User deleted" })
|
||||
async deleteUser(@Param("id") id: string): Promise<ApiResponse<null>> {
|
||||
await this.prisma.user.update({
|
||||
where: { id },
|
||||
@@ -175,6 +181,7 @@ export class AdminController {
|
||||
@CacheKey("app_settings")
|
||||
@CacheTTL(60 * 1000)
|
||||
@ApiOperation({ summary: "Get all app settings" })
|
||||
@SwaggerResponse({ status: 200, schema: { type: "object", additionalProperties: { type: "string" } } })
|
||||
async getAllSettings(): Promise<ApiResponse<Record<string, string>>> {
|
||||
const settings = await this.prisma.appSetting.findMany();
|
||||
const settingsMap: Record<string, string> = {};
|
||||
@@ -186,6 +193,7 @@ export class AdminController {
|
||||
|
||||
@Put("settings/:key")
|
||||
@ApiOperation({ summary: "Update an app setting" })
|
||||
@SwaggerResponse({ status: 200, schema: { type: "object", properties: { key: { type: "string" }, value: { type: "string" } } } })
|
||||
async updateSetting(
|
||||
@Param("key") key: string,
|
||||
@Body() data: { value: string },
|
||||
@@ -206,6 +214,7 @@ export class AdminController {
|
||||
|
||||
@Get("usage-limits")
|
||||
@ApiOperation({ summary: "Get all usage limits" })
|
||||
@SwaggerResponse({ status: 200, schema: { type: "array", items: { type: "object" } } })
|
||||
async getAllUsageLimits(@Query() pagination: PaginationDto) {
|
||||
const { skip, take } = pagination;
|
||||
|
||||
@@ -233,6 +242,7 @@ export class AdminController {
|
||||
|
||||
@Post("usage-limits/reset-all")
|
||||
@ApiOperation({ summary: "Reset all usage limits" })
|
||||
@SwaggerResponse({ status: 200, schema: { type: "object", properties: { count: { type: "number" } } } })
|
||||
async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> {
|
||||
const result = await this.prisma.usageLimit.updateMany({
|
||||
data: {
|
||||
@@ -252,6 +262,7 @@ export class AdminController {
|
||||
|
||||
@Get("analytics/overview")
|
||||
@ApiOperation({ summary: "Get system analytics overview" })
|
||||
@SwaggerResponse({ status: 200, schema: { type: "object" } })
|
||||
async getAnalyticsOverview() {
|
||||
const [
|
||||
totalUsers,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { All, Body, Controller, Req } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
|
||||
import { AiProxyService } from "./ai-proxy.service";
|
||||
|
||||
@Controller("ai-engine")
|
||||
export class AiProxyController {
|
||||
constructor(private readonly aiProxyService: AiProxyService) {}
|
||||
|
||||
@All("*path")
|
||||
proxy(@Req() request: Request, @Body() body: unknown) {
|
||||
return this.aiProxyService.proxy({
|
||||
method: request.method,
|
||||
originalUrl: request.originalUrl,
|
||||
query: request.query as Record<string, unknown>,
|
||||
body,
|
||||
acceptLanguage: request.headers["accept-language"],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
|
||||
import { AiProxyController } from "./ai-proxy.controller";
|
||||
import { AiProxyService } from "./ai-proxy.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({
|
||||
timeout: 45000,
|
||||
maxRedirects: 0,
|
||||
}),
|
||||
],
|
||||
controllers: [AiProxyController],
|
||||
providers: [AiProxyService],
|
||||
})
|
||||
export class AiProxyModule {}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AxiosError, Method } from "axios";
|
||||
|
||||
interface ProxyRequest {
|
||||
method: string;
|
||||
originalUrl: string;
|
||||
query: Record<string, unknown>;
|
||||
body: unknown;
|
||||
acceptLanguage?: string | string[];
|
||||
}
|
||||
|
||||
interface AllowedRoute {
|
||||
method: Method;
|
||||
pattern: RegExp;
|
||||
}
|
||||
|
||||
const ALLOWED_AI_ROUTES: AllowedRoute[] = [
|
||||
{ method: "GET", pattern: /^\/$/ },
|
||||
{ method: "GET", pattern: /^\/health$/ },
|
||||
{ method: "POST", pattern: /^\/v20plus\/analyze\/[^/]+$/ },
|
||||
{ method: "GET", pattern: /^\/v20plus\/analyze-htms\/[^/]+$/ },
|
||||
{ method: "GET", pattern: /^\/v20plus\/analyze-htft\/[^/]+$/ },
|
||||
{ method: "POST", pattern: /^\/v20plus\/coupon$/ },
|
||||
{ method: "GET", pattern: /^\/v20plus\/daily-banker$/ },
|
||||
{ method: "GET", pattern: /^\/v20plus\/reversal-watchlist$/ },
|
||||
{ method: "GET", pattern: /^\/v2\/health$/ },
|
||||
{ method: "POST", pattern: /^\/v2\/analyze\/[^/]+$/ },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class AiProxyService {
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async proxy(request: ProxyRequest) {
|
||||
const path = this.extractProxyPath(request.originalUrl);
|
||||
const method = request.method.toUpperCase() as Method;
|
||||
|
||||
if (!this.isAllowed(method, path)) {
|
||||
throw new ForbiddenException("AI_PROXY_ROUTE_NOT_ALLOWED");
|
||||
}
|
||||
|
||||
const baseUrl = this.configService.getOrThrow<string>("AI_ENGINE_URL");
|
||||
const targetUrl = new URL(path, baseUrl);
|
||||
|
||||
try {
|
||||
const response = await this.httpService.axiosRef.request({
|
||||
url: targetUrl.toString(),
|
||||
method,
|
||||
params: request.query,
|
||||
data: request.body,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"accept-language": Array.isArray(request.acceptLanguage)
|
||||
? request.acceptLanguage[0]
|
||||
: request.acceptLanguage,
|
||||
},
|
||||
timeout: 45000,
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) => status >= 200 && status < 500,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
throw new BadGatewayException({
|
||||
message: "AI_PROXY_UPSTREAM_FAILED",
|
||||
status: axiosError.response?.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private extractProxyPath(originalUrl: string): string {
|
||||
const withoutQuery = originalUrl.split("?")[0] || "";
|
||||
const marker = "/ai-engine";
|
||||
const markerIndex = withoutQuery.indexOf(marker);
|
||||
if (markerIndex === -1) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
const path = withoutQuery.slice(markerIndex + marker.length);
|
||||
return path.length === 0 ? "/" : path;
|
||||
}
|
||||
|
||||
private isAllowed(method: Method, path: string): boolean {
|
||||
return ALLOWED_AI_ROUTES.some(
|
||||
(route) => route.method === method && route.pattern.test(path),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,18 @@ export class AnalysisController {
|
||||
@Post("analyze-matches")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Analyze multiple matches for coupon" })
|
||||
@ApiResponse({ status: 200, description: "Analysis successful" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Analysis successful",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "object" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Invalid input" })
|
||||
@ApiResponse({ status: 429, description: "Usage limit exceeded" })
|
||||
async analyzeMatches(
|
||||
@@ -92,7 +103,17 @@ export class AnalysisController {
|
||||
*/
|
||||
@Get("history")
|
||||
@ApiOperation({ summary: "Get analysis history" })
|
||||
@ApiResponse({ status: 200, description: "History retrieved" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "History retrieved",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "array", items: { type: "object" } },
|
||||
},
|
||||
},
|
||||
})
|
||||
async getHistory(@CurrentUser() user: any) {
|
||||
const history = await this.analysisService.getAnalysisHistory(user.id);
|
||||
return { success: true, data: history };
|
||||
|
||||
@@ -67,7 +67,17 @@ export class AuthController {
|
||||
@Post("logout")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: "Logout and invalidate refresh token" })
|
||||
@ApiOkResponse({ description: "Logout successful" })
|
||||
@ApiOkResponse({
|
||||
description: "Logout successful",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
message: { type: "string" },
|
||||
data: { type: "null" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async logout(
|
||||
@Body() dto: RefreshTokenDto,
|
||||
@I18n() i18n: I18nContext,
|
||||
|
||||
@@ -53,7 +53,18 @@ export class CouponsController {
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Analyze single match with V20 model" })
|
||||
@ApiResponse({ status: 200, description: "Match analysis" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Match analysis",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "object" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async analyzeMatch(@Body() dto: AnalyzeMatchDto) {
|
||||
const analysis = await this.smartCouponService.analyzeMatch(dto.matchId);
|
||||
if (!analysis) {
|
||||
@@ -99,6 +110,18 @@ export class CouponsController {
|
||||
@ApiOperation({
|
||||
summary: "Generate a high-confidence banko combo (2 matches)",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Daily banko coupon",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "object" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async getDailyBanko(@Body() dto: DailyBankoDto) {
|
||||
// If no match IDs provided, fetch from system (top 50 upcoming)
|
||||
let candidateMatches = dto.matchIds || [];
|
||||
@@ -146,7 +169,18 @@ export class CouponsController {
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Suggest Smart Coupon" })
|
||||
@ApiResponse({ status: 200, description: "Smart Coupon generated" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Smart Coupon generated",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "object" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async suggestCoupon(@Body() dto: SuggestCouponDto) {
|
||||
// If no match IDs provided, fetch from system (top 50 upcoming)
|
||||
let candidateMatches = dto.matchIds || [];
|
||||
@@ -237,6 +271,18 @@ export class CouponsController {
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: "Create and save a user coupon" })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: "Coupon created",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "object" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async createCoupon(@Body() dto: CreateCouponDto, @Req() req: any) {
|
||||
// req.user is populated by JwtAuthGuard
|
||||
const coupon = await this.userCouponService.createCoupon(req.user, dto);
|
||||
@@ -251,6 +297,18 @@ export class CouponsController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: "Get user betting statistics" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "User statistics",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "object" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async getUserStats(@Req() req: any) {
|
||||
const stats = await this.userCouponService.getUserStatistics(req.user.id);
|
||||
return { success: true, data: stats };
|
||||
@@ -263,7 +321,18 @@ export class CouponsController {
|
||||
@Get("history")
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: "Get coupon history" })
|
||||
@ApiResponse({ status: 200, description: "History retrieved" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "History retrieved",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
data: { type: "array", items: { type: "object" } },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async getHistory(@Query("limit") limit?: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
const results = await this.couponsService.getCouponHistory(
|
||||
|
||||
@@ -898,6 +898,58 @@ export class FeederPersistenceService {
|
||||
.map((m) => m.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* For a list of match IDs that ALREADY exist in DB,
|
||||
* returns which data scopes are missing per match.
|
||||
* Only checks completed (Ended) football/basketball matches.
|
||||
*/
|
||||
async getMissingScopes(
|
||||
matchIds: string[],
|
||||
): Promise<Map<string, string[]>> {
|
||||
const result = new Map<string, string[]>();
|
||||
if (matchIds.length === 0) return result;
|
||||
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
id: { in: matchIds },
|
||||
state: "Ended",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
sport: true,
|
||||
_count: {
|
||||
select: {
|
||||
playerParticipations: true,
|
||||
footballTeamStats: true,
|
||||
basketballTeamStats: true,
|
||||
basketballPlayerStats: true,
|
||||
oddCategories: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const m of matches) {
|
||||
const missing: string[] = [];
|
||||
|
||||
if (m.sport === "football") {
|
||||
if (m._count.footballTeamStats === 0) missing.push("stats");
|
||||
if (m._count.playerParticipations < 18) missing.push("lineups");
|
||||
} else if (m.sport === "basketball") {
|
||||
if (m._count.basketballTeamStats === 0) missing.push("stats");
|
||||
if (m._count.basketballPlayerStats === 0) missing.push("lineups");
|
||||
}
|
||||
|
||||
if (m._count.oddCategories === 0) missing.push("odds");
|
||||
|
||||
if (missing.length > 0) {
|
||||
result.set(m.id, missing);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async hasOdds(matchId: string): Promise<boolean> {
|
||||
const category = await this.prisma.oddCategory.findFirst({
|
||||
where: { matchId },
|
||||
|
||||
@@ -385,21 +385,71 @@ export class FeederService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Filter out already existing matches to skip processing
|
||||
// 2. Filter out already existing matches & patch incomplete ones
|
||||
const allIds = matchesToProcess.map((m) => m.id);
|
||||
const existingIds =
|
||||
await this.persistenceService.getExistingMatchIds(allIds);
|
||||
const totalCount = matchesToProcess.length;
|
||||
|
||||
// ── Patch incomplete existing matches ──────────────────────
|
||||
// Find matches that ARE in DB but have missing data scopes
|
||||
const allExistingInDb = await this.persistenceService.getMissingScopes(allIds);
|
||||
if (allExistingInDb.size > 0) {
|
||||
this.logger.log(
|
||||
`[${sport}] [${dateString}] 🔧 Found ${allExistingInDb.size} existing matches with missing data. Patching...`,
|
||||
);
|
||||
|
||||
for (const [matchId, missingScopes] of allExistingInDb) {
|
||||
const matchSummary = matchesToProcess.find((m) => m.id === matchId);
|
||||
if (!matchSummary) continue;
|
||||
|
||||
for (const scope of missingScopes) {
|
||||
await this.delay(500);
|
||||
try {
|
||||
const patchScope: "all" | "lineups" | "odds" =
|
||||
scope === "odds" ? "odds" : scope === "lineups" ? "lineups" : "all";
|
||||
|
||||
const result = await this.processSingleMatch(
|
||||
matchSummary,
|
||||
data.competitions,
|
||||
sport,
|
||||
true, // force
|
||||
patchScope,
|
||||
);
|
||||
|
||||
this.heartbeat();
|
||||
if (result.success) {
|
||||
this.logger.log(
|
||||
`[${sport}] ✅ Patched [${scope}] for ${matchId} ${matchSummary.homeTeam.name} vs ${matchSummary.awayTeam.name}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`[${sport}] ⚠️ Patch [${scope}] failed for ${matchId}`,
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.logger.warn(
|
||||
`[${sport}] ❌ Patch [${scope}] exception for ${matchId}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Now filter out COMPLETE existing matches (skip them)
|
||||
if (!refreshExistingMatches && existingIds.length > 0) {
|
||||
// Re-check after patching - which ones are now complete?
|
||||
const updatedExistingIds =
|
||||
await this.persistenceService.getExistingMatchIds(allIds);
|
||||
matchesToProcess = matchesToProcess.filter(
|
||||
(m) => !existingIds.includes(m.id),
|
||||
(m) => !updatedExistingIds.includes(m.id),
|
||||
);
|
||||
}
|
||||
|
||||
if (matchesToProcess.length === 0) {
|
||||
this.logger.log(
|
||||
`[${sport}] [${dateString}] All ${totalCount} matches already exist. Skipping...`,
|
||||
`[${sport}] [${dateString}] All ${totalCount} matches processed (${existingIds.length} existed, ${allExistingInDb.size} patched). Done.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -410,7 +460,7 @@ export class FeederService {
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`[${sport}] [${dateString}] Processing ${matchesToProcess.length}/${totalCount} matches (Skipped ${existingIds.length} existing)`,
|
||||
`[${sport}] [${dateString}] Processing ${matchesToProcess.length}/${totalCount} new matches (${existingIds.length} existing, ${allExistingInDb.size} patched)`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -474,7 +524,7 @@ export class FeederService {
|
||||
match,
|
||||
data.competitions,
|
||||
sport,
|
||||
refreshExistingMatches,
|
||||
true, // FORCE: re-fetch incomplete data
|
||||
);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
@@ -778,8 +828,9 @@ export class FeederService {
|
||||
if (scope === "all" || scope === "lineups") {
|
||||
// Starting Formation
|
||||
try {
|
||||
const formationData =
|
||||
await this.scraperService.fetchStartingFormation(matchId);
|
||||
const formationData = await fetchResilient("Formation", () =>
|
||||
this.scraperService.fetchStartingFormation(matchId),
|
||||
);
|
||||
if (formationData?.stats) {
|
||||
this.transformerService.processLineup(
|
||||
formationData.stats.home || [],
|
||||
@@ -805,8 +856,9 @@ export class FeederService {
|
||||
|
||||
// Substitutes
|
||||
try {
|
||||
const subsData =
|
||||
await this.scraperService.fetchSubstitutions(matchId);
|
||||
const subsData = await fetchResilient("Subs", () =>
|
||||
this.scraperService.fetchSubstitutions(matchId),
|
||||
);
|
||||
if (subsData?.stats) {
|
||||
this.transformerService.processLineup(
|
||||
subsData.stats.home || [],
|
||||
@@ -887,7 +939,37 @@ export class FeederService {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Persist to Database
|
||||
// ── Pre-save completeness gate ──────────────────────────────
|
||||
// If a 502 caused missing data, do NOT save. The data exists on
|
||||
// the API and will be available shortly. Skip and retry instead.
|
||||
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 === "football" && participationData.length < 18)
|
||||
missingParts.push("Lineups");
|
||||
if (sport === "basketball" && !basketballTeamStats)
|
||||
missingParts.push("BoxScore");
|
||||
if (oddsArray.length === 0) missingParts.push("Odds");
|
||||
}
|
||||
|
||||
// 502 caused missing data → do NOT save, retry later
|
||||
if (hasCriticalError && missingParts.length > 0) {
|
||||
this.logger.warn(
|
||||
`[${matchId}] ⛔ SKIPPED SAVE: 502 errors caused missing [${missingParts.join(", ")}]. Will retry for complete data.`,
|
||||
);
|
||||
return { success: false, retryable: true };
|
||||
}
|
||||
|
||||
// 4. SAVE
|
||||
let saved = false;
|
||||
if (scope === "lineups") {
|
||||
saved = await this.persistenceService.saveLineups(
|
||||
@@ -941,34 +1023,11 @@ export class FeederService {
|
||||
*/
|
||||
// ==========================================
|
||||
|
||||
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 === "football" && participationData.length < 18)
|
||||
missingParts.push("Lineups");
|
||||
if (sport === "basketball" && !basketballTeamStats)
|
||||
missingParts.push("BoxScore");
|
||||
if (oddsArray.length === 0) missingParts.push("Odds");
|
||||
}
|
||||
|
||||
if (saved && (hasCriticalError || missingParts.length > 0)) {
|
||||
const reason = hasCriticalError
|
||||
? "missing data after upstream errors"
|
||||
: "incomplete completed-match payload";
|
||||
|
||||
// No 502 but data genuinely missing → save anyway, log warning
|
||||
if (saved && missingParts.length > 0) {
|
||||
this.logger.warn(
|
||||
`[${matchId}] Saved with ${reason}. Missing: [${missingParts.join(", ")}]. Scheduled for retry.`,
|
||||
`[${matchId}] Saved but data genuinely missing (no 502): [${missingParts.join(", ")}]`,
|
||||
);
|
||||
return { success: false, retryable: true };
|
||||
}
|
||||
|
||||
return { success: saved, retryable: !saved };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Controller, Get, Res } from "@nestjs/common";
|
||||
import { ApiTags, ApiOperation } from "@nestjs/swagger";
|
||||
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import { Public } from "../../common/decorators";
|
||||
import { PrismaService } from "../../database/prisma.service";
|
||||
@@ -52,6 +52,17 @@ export class HealthController {
|
||||
@Get("live")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Liveness check" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "System liveness",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string" },
|
||||
timestamp: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
liveness(@Res() response: Response) {
|
||||
return response
|
||||
.status(200)
|
||||
|
||||
@@ -28,7 +28,11 @@ export class LeaguesController {
|
||||
@Get("countries")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Get all countries" })
|
||||
@ApiResponse({ status: 200, description: "List of countries" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of countries",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
async getCountries() {
|
||||
return this.leaguesService.findAllCountries();
|
||||
}
|
||||
@@ -40,6 +44,11 @@ export class LeaguesController {
|
||||
@Get("countries/:id")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Get country by ID with leagues" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Country details",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
@ApiParam({ name: "id", description: "Country ID" })
|
||||
async getCountryById(@Param("id") id: string) {
|
||||
const country = await this.leaguesService.findCountryById(id);
|
||||
@@ -54,6 +63,11 @@ export class LeaguesController {
|
||||
@Get()
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Get all leagues" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of leagues",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "sport",
|
||||
required: false,
|
||||
@@ -71,6 +85,11 @@ export class LeaguesController {
|
||||
@Get("teams/h2h")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Get head-to-head matches between two teams" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Head-to-head matches",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
@ApiQuery({ name: "team1", required: true })
|
||||
@ApiQuery({ name: "team2", required: true })
|
||||
@ApiQuery({ name: "limit", required: false, type: Number })
|
||||
@@ -93,6 +112,11 @@ export class LeaguesController {
|
||||
@Get("teams/search")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Search teams by name" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of teams matching search",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
@ApiQuery({ name: "q", required: true, description: "Search query" })
|
||||
@ApiQuery({
|
||||
name: "sport",
|
||||
@@ -110,6 +134,11 @@ export class LeaguesController {
|
||||
@Get("teams/:id")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Get team by ID" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Team details",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
@ApiParam({ name: "id", description: "Team ID" })
|
||||
async getTeamById(@Param("id") id: string) {
|
||||
const team = await this.leaguesService.findTeamById(id);
|
||||
@@ -124,6 +153,17 @@ export class LeaguesController {
|
||||
@Get("teams/:id/matches")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Get team's recent matches (paginated)" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Paginated list of matches",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { type: "object" } },
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiParam({ name: "id", description: "Team ID" })
|
||||
@ApiQuery({ name: "page", required: false, type: Number, description: "Page number (default: 1)" })
|
||||
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page (default: 20)" })
|
||||
@@ -149,6 +189,11 @@ export class LeaguesController {
|
||||
@Get(":id")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Get league by ID" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "League details",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
@ApiParam({ name: "id", description: "League ID" })
|
||||
async getLeagueById(@Param("id") id: string) {
|
||||
const league = await this.leaguesService.findLeagueById(id);
|
||||
|
||||
@@ -71,7 +71,17 @@ export class MatchesController {
|
||||
@ApiQuery({ name: "page", required: false, type: Number })
|
||||
@ApiQuery({ name: "limit", required: false, type: Number })
|
||||
@ApiQuery({ name: "sport", required: false, enum: Sport })
|
||||
@ApiResponse({ status: 200, description: "Paginated list of matches" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Paginated list of matches",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { type: "object" } },
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async listMatches(
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string,
|
||||
@@ -112,6 +122,7 @@ export class MatchesController {
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Match details with lineups, stats, odds, events",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Match not found" })
|
||||
async getMatchDetails(@Param("id") id: string) {
|
||||
|
||||
@@ -56,6 +56,11 @@ export class PredictionsController {
|
||||
*/
|
||||
@Get("test/:id")
|
||||
@ApiOperation({ summary: "Refetch match data and get prediction" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Prediction details",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
@ApiParam({ name: "id", description: "Match ID" })
|
||||
async getTestPrediction(@Param("id") id: string) {
|
||||
return this.predictionsService.testPrediction(id);
|
||||
@@ -91,7 +96,12 @@ export class PredictionsController {
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Get prediction for a specific match" })
|
||||
@ApiParam({ name: "matchId", description: "Match ID" })
|
||||
@ApiResponse({ status: 200, type: MatchPredictionDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Match prediction",
|
||||
schema: { type: "object" },
|
||||
type: MatchPredictionDto,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Match not found" })
|
||||
async getPrediction(
|
||||
@Param("matchId") matchId: string,
|
||||
@@ -145,6 +155,7 @@ export class PredictionsController {
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Smart coupon generated successfully",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> {
|
||||
const coupon = await this.predictionsService.getSmartCoupon(
|
||||
|
||||
@@ -54,6 +54,7 @@ export class SporTotoController {
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Sync result with action (created/updated/unchanged)",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
async syncFromApi() {
|
||||
const result = await this.sporTotoService.syncFromApi();
|
||||
@@ -82,6 +83,7 @@ export class SporTotoController {
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Array of bulletins with matches and results",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
async listBulletins(
|
||||
@Query("status") status?: TotoBulletinStatus,
|
||||
@@ -105,6 +107,7 @@ export class SporTotoController {
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Bulletin with matches and results",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Bulletin not found" })
|
||||
async getBulletin(@Param("id") id: string) {
|
||||
@@ -123,7 +126,11 @@ export class SporTotoController {
|
||||
"Creates a new bulletin with 15 matches. Fails if gameCycleNo already exists.",
|
||||
})
|
||||
@ApiBody({ type: CreateBulletinDto })
|
||||
@ApiResponse({ status: 201, description: "Created bulletin with matches" })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: "Created bulletin with matches",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 409,
|
||||
description: "Bulletin with this gameCycleNo already exists",
|
||||
@@ -145,7 +152,11 @@ export class SporTotoController {
|
||||
})
|
||||
@ApiParam({ name: "id", description: "Bulletin UUID" })
|
||||
@ApiBody({ type: UpdateResultsDto })
|
||||
@ApiResponse({ status: 200, description: "Updated bulletin with results" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Updated bulletin with results",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Bulletin not found" })
|
||||
async updateResults(@Param("id") id: string, @Body() dto: UpdateResultsDto) {
|
||||
const bulletin = await this.sporTotoService.updateResults(id, dto);
|
||||
@@ -162,7 +173,11 @@ export class SporTotoController {
|
||||
"Returns pool distribution (35/20/20/25), expected value calculations, and rollover analysis for a bulletin.",
|
||||
})
|
||||
@ApiParam({ name: "id", description: "Bulletin UUID" })
|
||||
@ApiResponse({ status: 200, description: "Pool distribution and EV stats" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Pool distribution and EV stats",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
async getBulletinStats(@Param("id") id: string) {
|
||||
const stats = await this.sporTotoService.getBulletinStats(id);
|
||||
return { success: true, data: stats };
|
||||
@@ -181,7 +196,11 @@ export class SporTotoController {
|
||||
type: Number,
|
||||
description: "Number of results (default: 20)",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Rollover history with trend data" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Rollover history with trend data",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
async getRolloverHistory(@Query("limit") limit?: string) {
|
||||
const history = await this.sporTotoService.getRolloverHistory(
|
||||
Number(limit) || 20,
|
||||
@@ -204,6 +223,7 @@ export class SporTotoController {
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Generated columns with strategy, cost, and column strings",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
async generateColumns(@Body() dto: GenerateColumnsDto) {
|
||||
const result = await this.sporTotoService.generateColumns(dto);
|
||||
@@ -223,6 +243,7 @@ export class SporTotoController {
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Evaluation results with correct counts per column",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
async evaluateColumns(@Body() dto: EvaluateColumnsDto) {
|
||||
const result = await this.sporTotoService.evaluateColumns(
|
||||
@@ -248,6 +269,7 @@ export class SporTotoController {
|
||||
status: 200,
|
||||
description:
|
||||
"Prediction result with per-match analysis, system coupon, and EV report with play recommendation",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
async generatePrediction(@Body() dto: GenerateSporTotoPredictionDto) {
|
||||
this.logger.log(
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Repair Feeder - Fix incomplete matches
|
||||
* Usage: npm run feeder:repair
|
||||
*
|
||||
* Finds matches in DB that are missing stats or lineups
|
||||
* and re-fetches them from the API.
|
||||
*/
|
||||
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { PrismaService } from "../database/prisma.service";
|
||||
import { FeederService } from "../modules/feeder/feeder.service";
|
||||
|
||||
async function bootstrap() {
|
||||
process.env.FEEDER_MODE = "historical";
|
||||
|
||||
const logger = new Logger("FeederRepair");
|
||||
|
||||
logger.log("🔧 Starting feeder repair scan...");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { AppModule } = require("../app.module");
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ["log", "error", "warn"],
|
||||
});
|
||||
|
||||
const prisma = app.get(PrismaService);
|
||||
const feederService = app.get(FeederService);
|
||||
|
||||
try {
|
||||
// Find football matches missing stats (no football_team_stats rows)
|
||||
const matchesMissingStats = await prisma.$queryRaw<
|
||||
Array<{ id: string; match_name: string }>
|
||||
>`
|
||||
SELECT m.id, m.match_name
|
||||
FROM matches m
|
||||
LEFT JOIN football_team_stats fts ON fts.match_id = m.id
|
||||
WHERE m.sport = 'football'
|
||||
AND m.state = 'Ended'
|
||||
AND fts.id IS NULL
|
||||
ORDER BY m.mst_utc DESC
|
||||
`;
|
||||
|
||||
// Find football matches missing lineups (< 18 participation rows)
|
||||
const matchesMissingLineups = await prisma.$queryRaw<
|
||||
Array<{ id: string; match_name: string; cnt: bigint }>
|
||||
>`
|
||||
SELECT m.id, m.match_name, COUNT(mpp.id) as cnt
|
||||
FROM matches m
|
||||
LEFT JOIN match_player_participation mpp ON mpp.match_id = m.id
|
||||
WHERE m.sport = 'football'
|
||||
AND m.state = 'Ended'
|
||||
GROUP BY m.id, m.match_name
|
||||
HAVING COUNT(mpp.id) < 18
|
||||
ORDER BY m.mst_utc DESC
|
||||
`;
|
||||
|
||||
// Combine unique match IDs
|
||||
const repairSet = new Set<string>();
|
||||
for (const m of matchesMissingStats) repairSet.add(m.id);
|
||||
for (const m of matchesMissingLineups) repairSet.add(m.id);
|
||||
|
||||
logger.log(
|
||||
`📊 Found ${repairSet.size} incomplete matches (${matchesMissingStats.length} missing stats, ${matchesMissingLineups.length} missing lineups)`,
|
||||
);
|
||||
|
||||
if (repairSet.size === 0) {
|
||||
logger.log("✅ No incomplete matches found. Everything is clean!");
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let repaired = 0;
|
||||
let failed = 0;
|
||||
const matchIds = Array.from(repairSet);
|
||||
|
||||
for (let i = 0; i < matchIds.length; i++) {
|
||||
const matchId = matchIds[i];
|
||||
|
||||
// Rate limiting
|
||||
if (i > 0 && i % 10 === 0) {
|
||||
logger.log(
|
||||
`⏸️ Cooldown after 10 repairs... (${repaired} repaired, ${failed} failed, ${matchIds.length - i} remaining)`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
try {
|
||||
const result = await feederService.refreshMatch(matchId, "all");
|
||||
if (result.success) {
|
||||
repaired++;
|
||||
if (repaired % 25 === 0) {
|
||||
logger.log(`🔧 Progress: ${repaired}/${matchIds.length} repaired`);
|
||||
}
|
||||
} else {
|
||||
failed++;
|
||||
logger.warn(
|
||||
`❌ [${matchId}] Repair failed: ${result.error || "unknown"}`,
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
failed++;
|
||||
logger.error(`❌ [${matchId}] Repair exception: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log(
|
||||
`🎉 REPAIR COMPLETE: ${repaired} repaired, ${failed} failed out of ${matchIds.length} total`,
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ Repair failed: ${error.message}`);
|
||||
logger.error(error.stack);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
Binary file not shown.
Reference in New Issue
Block a user