Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a338d02244 | |||
| 1623432039 |
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:backtest": "python ai-engine/scripts/backtest_v2_runtime.py",
|
||||||
"ai:train:vqwen": "python ai-engine/scripts/train_vqwen_v3.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: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: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: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",
|
"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 { AnalysisModule } from "./modules/analysis/analysis.module";
|
||||||
import { CouponsModule } from "./modules/coupons/coupons.module";
|
import { CouponsModule } from "./modules/coupons/coupons.module";
|
||||||
import { SporTotoModule } from "./modules/spor-toto/spor-toto.module";
|
import { SporTotoModule } from "./modules/spor-toto/spor-toto.module";
|
||||||
|
import { AiProxyModule } from "./modules/ai-proxy/ai-proxy.module";
|
||||||
|
|
||||||
// Services and Tasks
|
// Services and Tasks
|
||||||
import { ServicesModule } from "./services/services.module";
|
import { ServicesModule } from "./services/services.module";
|
||||||
@@ -201,6 +202,7 @@ const historicalFeederMode = process.env.FEEDER_MODE === "historical";
|
|||||||
AnalysisModule,
|
AnalysisModule,
|
||||||
CouponsModule,
|
CouponsModule,
|
||||||
SporTotoModule,
|
SporTotoModule,
|
||||||
|
AiProxyModule,
|
||||||
|
|
||||||
// Services and Scheduled Tasks
|
// Services and Scheduled Tasks
|
||||||
ServicesModule,
|
ServicesModule,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const envSchema = z.object({
|
|||||||
// Database
|
// Database
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
// AI Engine
|
// 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"),
|
AI_ENGINE_MODE: z.enum(["v28-pro-max", "dual"]).default("v28-pro-max"),
|
||||||
|
|
||||||
// JWT
|
// JWT
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
CACHE_MANAGER,
|
CACHE_MANAGER,
|
||||||
} from "@nestjs/cache-manager";
|
} from "@nestjs/cache-manager";
|
||||||
import * as cacheManager from "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 { Roles } from "../../common/decorators";
|
||||||
import { PrismaService } from "../../database/prisma.service";
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import { PaginationDto } from "../../common/dto/pagination.dto";
|
import { PaginationDto } from "../../common/dto/pagination.dto";
|
||||||
@@ -46,6 +46,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Get("users")
|
@Get("users")
|
||||||
@ApiOperation({ summary: "Get all users (admin)" })
|
@ApiOperation({ summary: "Get all users (admin)" })
|
||||||
|
@SwaggerResponse({ status: 200, type: [UserResponseDto] })
|
||||||
async getAllUsers(
|
async getAllUsers(
|
||||||
@Query() pagination: PaginationDto,
|
@Query() pagination: PaginationDto,
|
||||||
): Promise<ApiResponse<PaginatedData<UserResponseDto>>> {
|
): Promise<ApiResponse<PaginatedData<UserResponseDto>>> {
|
||||||
@@ -75,6 +76,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Get("users/:id")
|
@Get("users/:id")
|
||||||
@ApiOperation({ summary: "Get user by ID" })
|
@ApiOperation({ summary: "Get user by ID" })
|
||||||
|
@SwaggerResponse({ status: 200, type: UserResponseDto })
|
||||||
async getUserById(
|
async getUserById(
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
): Promise<ApiResponse<UserResponseDto>> {
|
): Promise<ApiResponse<UserResponseDto>> {
|
||||||
@@ -98,6 +100,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Put("users/:id/toggle-active")
|
@Put("users/:id/toggle-active")
|
||||||
@ApiOperation({ summary: "Toggle user active status" })
|
@ApiOperation({ summary: "Toggle user active status" })
|
||||||
|
@SwaggerResponse({ status: 200, type: UserResponseDto })
|
||||||
async toggleUserActive(
|
async toggleUserActive(
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
): Promise<ApiResponse<UserResponseDto>> {
|
): Promise<ApiResponse<UserResponseDto>> {
|
||||||
@@ -120,6 +123,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Put("users/:id/role")
|
@Put("users/:id/role")
|
||||||
@ApiOperation({ summary: "Update user role" })
|
@ApiOperation({ summary: "Update user role" })
|
||||||
|
@SwaggerResponse({ status: 200, type: UserResponseDto })
|
||||||
async updateUserRole(
|
async updateUserRole(
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
@Body() data: { role: UserRole },
|
@Body() data: { role: UserRole },
|
||||||
@@ -137,6 +141,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Put("users/:id/subscription")
|
@Put("users/:id/subscription")
|
||||||
@ApiOperation({ summary: "Update user subscription" })
|
@ApiOperation({ summary: "Update user subscription" })
|
||||||
|
@SwaggerResponse({ status: 200, type: UserResponseDto })
|
||||||
async updateUserSubscription(
|
async updateUserSubscription(
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
@Body()
|
@Body()
|
||||||
@@ -160,6 +165,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Delete("users/:id")
|
@Delete("users/:id")
|
||||||
@ApiOperation({ summary: "Soft delete a user" })
|
@ApiOperation({ summary: "Soft delete a user" })
|
||||||
|
@SwaggerResponse({ status: 200, description: "User deleted" })
|
||||||
async deleteUser(@Param("id") id: string): Promise<ApiResponse<null>> {
|
async deleteUser(@Param("id") id: string): Promise<ApiResponse<null>> {
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -175,6 +181,7 @@ export class AdminController {
|
|||||||
@CacheKey("app_settings")
|
@CacheKey("app_settings")
|
||||||
@CacheTTL(60 * 1000)
|
@CacheTTL(60 * 1000)
|
||||||
@ApiOperation({ summary: "Get all app settings" })
|
@ApiOperation({ summary: "Get all app settings" })
|
||||||
|
@SwaggerResponse({ status: 200, schema: { type: "object", additionalProperties: { type: "string" } } })
|
||||||
async getAllSettings(): Promise<ApiResponse<Record<string, string>>> {
|
async getAllSettings(): Promise<ApiResponse<Record<string, string>>> {
|
||||||
const settings = await this.prisma.appSetting.findMany();
|
const settings = await this.prisma.appSetting.findMany();
|
||||||
const settingsMap: Record<string, string> = {};
|
const settingsMap: Record<string, string> = {};
|
||||||
@@ -186,6 +193,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Put("settings/:key")
|
@Put("settings/:key")
|
||||||
@ApiOperation({ summary: "Update an app setting" })
|
@ApiOperation({ summary: "Update an app setting" })
|
||||||
|
@SwaggerResponse({ status: 200, schema: { type: "object", properties: { key: { type: "string" }, value: { type: "string" } } } })
|
||||||
async updateSetting(
|
async updateSetting(
|
||||||
@Param("key") key: string,
|
@Param("key") key: string,
|
||||||
@Body() data: { value: string },
|
@Body() data: { value: string },
|
||||||
@@ -206,6 +214,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Get("usage-limits")
|
@Get("usage-limits")
|
||||||
@ApiOperation({ summary: "Get all usage limits" })
|
@ApiOperation({ summary: "Get all usage limits" })
|
||||||
|
@SwaggerResponse({ status: 200, schema: { type: "array", items: { type: "object" } } })
|
||||||
async getAllUsageLimits(@Query() pagination: PaginationDto) {
|
async getAllUsageLimits(@Query() pagination: PaginationDto) {
|
||||||
const { skip, take } = pagination;
|
const { skip, take } = pagination;
|
||||||
|
|
||||||
@@ -233,6 +242,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Post("usage-limits/reset-all")
|
@Post("usage-limits/reset-all")
|
||||||
@ApiOperation({ summary: "Reset all usage limits" })
|
@ApiOperation({ summary: "Reset all usage limits" })
|
||||||
|
@SwaggerResponse({ status: 200, schema: { type: "object", properties: { count: { type: "number" } } } })
|
||||||
async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> {
|
async resetAllUsageLimits(): Promise<ApiResponse<{ count: number }>> {
|
||||||
const result = await this.prisma.usageLimit.updateMany({
|
const result = await this.prisma.usageLimit.updateMany({
|
||||||
data: {
|
data: {
|
||||||
@@ -252,6 +262,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Get("analytics/overview")
|
@Get("analytics/overview")
|
||||||
@ApiOperation({ summary: "Get system analytics overview" })
|
@ApiOperation({ summary: "Get system analytics overview" })
|
||||||
|
@SwaggerResponse({ status: 200, schema: { type: "object" } })
|
||||||
async getAnalyticsOverview() {
|
async getAnalyticsOverview() {
|
||||||
const [
|
const [
|
||||||
totalUsers,
|
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")
|
@Post("analyze-matches")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: "Analyze multiple matches for coupon" })
|
@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: 400, description: "Invalid input" })
|
||||||
@ApiResponse({ status: 429, description: "Usage limit exceeded" })
|
@ApiResponse({ status: 429, description: "Usage limit exceeded" })
|
||||||
async analyzeMatches(
|
async analyzeMatches(
|
||||||
@@ -92,7 +103,17 @@ export class AnalysisController {
|
|||||||
*/
|
*/
|
||||||
@Get("history")
|
@Get("history")
|
||||||
@ApiOperation({ summary: "Get analysis 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) {
|
async getHistory(@CurrentUser() user: any) {
|
||||||
const history = await this.analysisService.getAnalysisHistory(user.id);
|
const history = await this.analysisService.getAnalysisHistory(user.id);
|
||||||
return { success: true, data: history };
|
return { success: true, data: history };
|
||||||
|
|||||||
@@ -67,7 +67,17 @@ export class AuthController {
|
|||||||
@Post("logout")
|
@Post("logout")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@ApiOperation({ summary: "Logout and invalidate refresh token" })
|
@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(
|
async logout(
|
||||||
@Body() dto: RefreshTokenDto,
|
@Body() dto: RefreshTokenDto,
|
||||||
@I18n() i18n: I18nContext,
|
@I18n() i18n: I18nContext,
|
||||||
|
|||||||
@@ -53,7 +53,18 @@ export class CouponsController {
|
|||||||
@Public()
|
@Public()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: "Analyze single match with V20 model" })
|
@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) {
|
async analyzeMatch(@Body() dto: AnalyzeMatchDto) {
|
||||||
const analysis = await this.smartCouponService.analyzeMatch(dto.matchId);
|
const analysis = await this.smartCouponService.analyzeMatch(dto.matchId);
|
||||||
if (!analysis) {
|
if (!analysis) {
|
||||||
@@ -99,6 +110,18 @@ export class CouponsController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Generate a high-confidence banko combo (2 matches)",
|
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) {
|
async getDailyBanko(@Body() dto: DailyBankoDto) {
|
||||||
// If no match IDs provided, fetch from system (top 50 upcoming)
|
// If no match IDs provided, fetch from system (top 50 upcoming)
|
||||||
let candidateMatches = dto.matchIds || [];
|
let candidateMatches = dto.matchIds || [];
|
||||||
@@ -146,7 +169,18 @@ export class CouponsController {
|
|||||||
@Public()
|
@Public()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: "Suggest Smart Coupon" })
|
@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) {
|
async suggestCoupon(@Body() dto: SuggestCouponDto) {
|
||||||
// If no match IDs provided, fetch from system (top 50 upcoming)
|
// If no match IDs provided, fetch from system (top 50 upcoming)
|
||||||
let candidateMatches = dto.matchIds || [];
|
let candidateMatches = dto.matchIds || [];
|
||||||
@@ -237,6 +271,18 @@ export class CouponsController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: "Create and save a user coupon" })
|
@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) {
|
async createCoupon(@Body() dto: CreateCouponDto, @Req() req: any) {
|
||||||
// req.user is populated by JwtAuthGuard
|
// req.user is populated by JwtAuthGuard
|
||||||
const coupon = await this.userCouponService.createCoupon(req.user, dto);
|
const coupon = await this.userCouponService.createCoupon(req.user, dto);
|
||||||
@@ -251,6 +297,18 @@ export class CouponsController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: "Get user betting statistics" })
|
@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) {
|
async getUserStats(@Req() req: any) {
|
||||||
const stats = await this.userCouponService.getUserStatistics(req.user.id);
|
const stats = await this.userCouponService.getUserStatistics(req.user.id);
|
||||||
return { success: true, data: stats };
|
return { success: true, data: stats };
|
||||||
@@ -263,7 +321,18 @@ export class CouponsController {
|
|||||||
@Get("history")
|
@Get("history")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: "Get coupon history" })
|
@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) {
|
async getHistory(@Query("limit") limit?: string) {
|
||||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||||
const results = await this.couponsService.getCouponHistory(
|
const results = await this.couponsService.getCouponHistory(
|
||||||
|
|||||||
@@ -898,6 +898,58 @@ export class FeederPersistenceService {
|
|||||||
.map((m) => m.id);
|
.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> {
|
async hasOdds(matchId: string): Promise<boolean> {
|
||||||
const category = await this.prisma.oddCategory.findFirst({
|
const category = await this.prisma.oddCategory.findFirst({
|
||||||
where: { matchId },
|
where: { matchId },
|
||||||
|
|||||||
@@ -385,21 +385,71 @@ export class FeederService {
|
|||||||
return;
|
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 allIds = matchesToProcess.map((m) => m.id);
|
||||||
const existingIds =
|
const existingIds =
|
||||||
await this.persistenceService.getExistingMatchIds(allIds);
|
await this.persistenceService.getExistingMatchIds(allIds);
|
||||||
const totalCount = matchesToProcess.length;
|
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) {
|
if (!refreshExistingMatches && existingIds.length > 0) {
|
||||||
|
// Re-check after patching - which ones are now complete?
|
||||||
|
const updatedExistingIds =
|
||||||
|
await this.persistenceService.getExistingMatchIds(allIds);
|
||||||
matchesToProcess = matchesToProcess.filter(
|
matchesToProcess = matchesToProcess.filter(
|
||||||
(m) => !existingIds.includes(m.id),
|
(m) => !updatedExistingIds.includes(m.id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchesToProcess.length === 0) {
|
if (matchesToProcess.length === 0) {
|
||||||
this.logger.log(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -410,7 +460,7 @@ export class FeederService {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(
|
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,
|
match,
|
||||||
data.competitions,
|
data.competitions,
|
||||||
sport,
|
sport,
|
||||||
refreshExistingMatches,
|
true, // FORCE: re-fetch incomplete data
|
||||||
);
|
);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
successCount++;
|
successCount++;
|
||||||
@@ -778,8 +828,9 @@ export class FeederService {
|
|||||||
if (scope === "all" || scope === "lineups") {
|
if (scope === "all" || scope === "lineups") {
|
||||||
// Starting Formation
|
// Starting Formation
|
||||||
try {
|
try {
|
||||||
const formationData =
|
const formationData = await fetchResilient("Formation", () =>
|
||||||
await this.scraperService.fetchStartingFormation(matchId);
|
this.scraperService.fetchStartingFormation(matchId),
|
||||||
|
);
|
||||||
if (formationData?.stats) {
|
if (formationData?.stats) {
|
||||||
this.transformerService.processLineup(
|
this.transformerService.processLineup(
|
||||||
formationData.stats.home || [],
|
formationData.stats.home || [],
|
||||||
@@ -805,8 +856,9 @@ export class FeederService {
|
|||||||
|
|
||||||
// Substitutes
|
// Substitutes
|
||||||
try {
|
try {
|
||||||
const subsData =
|
const subsData = await fetchResilient("Subs", () =>
|
||||||
await this.scraperService.fetchSubstitutions(matchId);
|
this.scraperService.fetchSubstitutions(matchId),
|
||||||
|
);
|
||||||
if (subsData?.stats) {
|
if (subsData?.stats) {
|
||||||
this.transformerService.processLineup(
|
this.transformerService.processLineup(
|
||||||
subsData.stats.home || [],
|
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;
|
let saved = false;
|
||||||
if (scope === "lineups") {
|
if (scope === "lineups") {
|
||||||
saved = await this.persistenceService.saveLineups(
|
saved = await this.persistenceService.saveLineups(
|
||||||
@@ -941,34 +1023,11 @@ export class FeederService {
|
|||||||
*/
|
*/
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
const completedMatch = isMatchCompleted({
|
// No 502 but data genuinely missing → save anyway, log warning
|
||||||
state: headerData?.matchStatus ?? matchSummary.state,
|
if (saved && missingParts.length > 0) {
|
||||||
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";
|
|
||||||
|
|
||||||
this.logger.warn(
|
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 };
|
return { success: saved, retryable: !saved };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Controller, Get, Res } from "@nestjs/common";
|
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 type { Response } from "express";
|
||||||
import { Public } from "../../common/decorators";
|
import { Public } from "../../common/decorators";
|
||||||
import { PrismaService } from "../../database/prisma.service";
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
@@ -52,6 +52,17 @@ export class HealthController {
|
|||||||
@Get("live")
|
@Get("live")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Liveness check" })
|
@ApiOperation({ summary: "Liveness check" })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "System liveness",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: { type: "string" },
|
||||||
|
timestamp: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
liveness(@Res() response: Response) {
|
liveness(@Res() response: Response) {
|
||||||
return response
|
return response
|
||||||
.status(200)
|
.status(200)
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ export class LeaguesController {
|
|||||||
@Get("countries")
|
@Get("countries")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Get all countries" })
|
@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() {
|
async getCountries() {
|
||||||
return this.leaguesService.findAllCountries();
|
return this.leaguesService.findAllCountries();
|
||||||
}
|
}
|
||||||
@@ -40,6 +44,11 @@ export class LeaguesController {
|
|||||||
@Get("countries/:id")
|
@Get("countries/:id")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Get country by ID with leagues" })
|
@ApiOperation({ summary: "Get country by ID with leagues" })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Country details",
|
||||||
|
schema: { type: "object" },
|
||||||
|
})
|
||||||
@ApiParam({ name: "id", description: "Country ID" })
|
@ApiParam({ name: "id", description: "Country ID" })
|
||||||
async getCountryById(@Param("id") id: string) {
|
async getCountryById(@Param("id") id: string) {
|
||||||
const country = await this.leaguesService.findCountryById(id);
|
const country = await this.leaguesService.findCountryById(id);
|
||||||
@@ -54,6 +63,11 @@ export class LeaguesController {
|
|||||||
@Get()
|
@Get()
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Get all leagues" })
|
@ApiOperation({ summary: "Get all leagues" })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "List of leagues",
|
||||||
|
schema: { type: "array", items: { type: "object" } },
|
||||||
|
})
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: "sport",
|
name: "sport",
|
||||||
required: false,
|
required: false,
|
||||||
@@ -71,6 +85,11 @@ export class LeaguesController {
|
|||||||
@Get("teams/h2h")
|
@Get("teams/h2h")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Get head-to-head matches between two teams" })
|
@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: "team1", required: true })
|
||||||
@ApiQuery({ name: "team2", required: true })
|
@ApiQuery({ name: "team2", required: true })
|
||||||
@ApiQuery({ name: "limit", required: false, type: Number })
|
@ApiQuery({ name: "limit", required: false, type: Number })
|
||||||
@@ -93,6 +112,11 @@ export class LeaguesController {
|
|||||||
@Get("teams/search")
|
@Get("teams/search")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Search teams by name" })
|
@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: "q", required: true, description: "Search query" })
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: "sport",
|
name: "sport",
|
||||||
@@ -110,6 +134,11 @@ export class LeaguesController {
|
|||||||
@Get("teams/:id")
|
@Get("teams/:id")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Get team by ID" })
|
@ApiOperation({ summary: "Get team by ID" })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Team details",
|
||||||
|
schema: { type: "object" },
|
||||||
|
})
|
||||||
@ApiParam({ name: "id", description: "Team ID" })
|
@ApiParam({ name: "id", description: "Team ID" })
|
||||||
async getTeamById(@Param("id") id: string) {
|
async getTeamById(@Param("id") id: string) {
|
||||||
const team = await this.leaguesService.findTeamById(id);
|
const team = await this.leaguesService.findTeamById(id);
|
||||||
@@ -124,6 +153,17 @@ export class LeaguesController {
|
|||||||
@Get("teams/:id/matches")
|
@Get("teams/:id/matches")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Get team's recent matches (paginated)" })
|
@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" })
|
@ApiParam({ name: "id", description: "Team ID" })
|
||||||
@ApiQuery({ name: "page", required: false, type: Number, description: "Page number (default: 1)" })
|
@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)" })
|
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page (default: 20)" })
|
||||||
@@ -149,6 +189,11 @@ export class LeaguesController {
|
|||||||
@Get(":id")
|
@Get(":id")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Get league by ID" })
|
@ApiOperation({ summary: "Get league by ID" })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "League details",
|
||||||
|
schema: { type: "object" },
|
||||||
|
})
|
||||||
@ApiParam({ name: "id", description: "League ID" })
|
@ApiParam({ name: "id", description: "League ID" })
|
||||||
async getLeagueById(@Param("id") id: string) {
|
async getLeagueById(@Param("id") id: string) {
|
||||||
const league = await this.leaguesService.findLeagueById(id);
|
const league = await this.leaguesService.findLeagueById(id);
|
||||||
|
|||||||
@@ -71,7 +71,17 @@ export class MatchesController {
|
|||||||
@ApiQuery({ name: "page", required: false, type: Number })
|
@ApiQuery({ name: "page", required: false, type: Number })
|
||||||
@ApiQuery({ name: "limit", required: false, type: Number })
|
@ApiQuery({ name: "limit", required: false, type: Number })
|
||||||
@ApiQuery({ name: "sport", required: false, enum: Sport })
|
@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(
|
async listMatches(
|
||||||
@Query("page") page?: string,
|
@Query("page") page?: string,
|
||||||
@Query("limit") limit?: string,
|
@Query("limit") limit?: string,
|
||||||
@@ -112,6 +122,7 @@ export class MatchesController {
|
|||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: "Match details with lineups, stats, odds, events",
|
description: "Match details with lineups, stats, odds, events",
|
||||||
|
schema: { type: "object" },
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 404, description: "Match not found" })
|
@ApiResponse({ status: 404, description: "Match not found" })
|
||||||
async getMatchDetails(@Param("id") id: string) {
|
async getMatchDetails(@Param("id") id: string) {
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ export class PredictionsController {
|
|||||||
*/
|
*/
|
||||||
@Get("test/:id")
|
@Get("test/:id")
|
||||||
@ApiOperation({ summary: "Refetch match data and get prediction" })
|
@ApiOperation({ summary: "Refetch match data and get prediction" })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Prediction details",
|
||||||
|
schema: { type: "object" },
|
||||||
|
})
|
||||||
@ApiParam({ name: "id", description: "Match ID" })
|
@ApiParam({ name: "id", description: "Match ID" })
|
||||||
async getTestPrediction(@Param("id") id: string) {
|
async getTestPrediction(@Param("id") id: string) {
|
||||||
return this.predictionsService.testPrediction(id);
|
return this.predictionsService.testPrediction(id);
|
||||||
@@ -91,7 +96,12 @@ export class PredictionsController {
|
|||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Get prediction for a specific match" })
|
@ApiOperation({ summary: "Get prediction for a specific match" })
|
||||||
@ApiParam({ name: "matchId", description: "Match ID" })
|
@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" })
|
@ApiResponse({ status: 404, description: "Match not found" })
|
||||||
async getPrediction(
|
async getPrediction(
|
||||||
@Param("matchId") matchId: string,
|
@Param("matchId") matchId: string,
|
||||||
@@ -145,6 +155,7 @@ export class PredictionsController {
|
|||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: "Smart coupon generated successfully",
|
description: "Smart coupon generated successfully",
|
||||||
|
schema: { type: "object" },
|
||||||
})
|
})
|
||||||
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> {
|
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> {
|
||||||
const coupon = await this.predictionsService.getSmartCoupon(
|
const coupon = await this.predictionsService.getSmartCoupon(
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export class SporTotoController {
|
|||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: "Sync result with action (created/updated/unchanged)",
|
description: "Sync result with action (created/updated/unchanged)",
|
||||||
|
schema: { type: "object" },
|
||||||
})
|
})
|
||||||
async syncFromApi() {
|
async syncFromApi() {
|
||||||
const result = await this.sporTotoService.syncFromApi();
|
const result = await this.sporTotoService.syncFromApi();
|
||||||
@@ -82,6 +83,7 @@ export class SporTotoController {
|
|||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: "Array of bulletins with matches and results",
|
description: "Array of bulletins with matches and results",
|
||||||
|
schema: { type: "object" },
|
||||||
})
|
})
|
||||||
async listBulletins(
|
async listBulletins(
|
||||||
@Query("status") status?: TotoBulletinStatus,
|
@Query("status") status?: TotoBulletinStatus,
|
||||||
@@ -105,6 +107,7 @@ export class SporTotoController {
|
|||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: "Bulletin with matches and results",
|
description: "Bulletin with matches and results",
|
||||||
|
schema: { type: "object" },
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 404, description: "Bulletin not found" })
|
@ApiResponse({ status: 404, description: "Bulletin not found" })
|
||||||
async getBulletin(@Param("id") id: string) {
|
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.",
|
"Creates a new bulletin with 15 matches. Fails if gameCycleNo already exists.",
|
||||||
})
|
})
|
||||||
@ApiBody({ type: CreateBulletinDto })
|
@ApiBody({ type: CreateBulletinDto })
|
||||||
@ApiResponse({ status: 201, description: "Created bulletin with matches" })
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: "Created bulletin with matches",
|
||||||
|
schema: { type: "object" },
|
||||||
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 409,
|
status: 409,
|
||||||
description: "Bulletin with this gameCycleNo already exists",
|
description: "Bulletin with this gameCycleNo already exists",
|
||||||
@@ -145,7 +152,11 @@ export class SporTotoController {
|
|||||||
})
|
})
|
||||||
@ApiParam({ name: "id", description: "Bulletin UUID" })
|
@ApiParam({ name: "id", description: "Bulletin UUID" })
|
||||||
@ApiBody({ type: UpdateResultsDto })
|
@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" })
|
@ApiResponse({ status: 404, description: "Bulletin not found" })
|
||||||
async updateResults(@Param("id") id: string, @Body() dto: UpdateResultsDto) {
|
async updateResults(@Param("id") id: string, @Body() dto: UpdateResultsDto) {
|
||||||
const bulletin = await this.sporTotoService.updateResults(id, dto);
|
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.",
|
"Returns pool distribution (35/20/20/25), expected value calculations, and rollover analysis for a bulletin.",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "id", description: "Bulletin UUID" })
|
@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) {
|
async getBulletinStats(@Param("id") id: string) {
|
||||||
const stats = await this.sporTotoService.getBulletinStats(id);
|
const stats = await this.sporTotoService.getBulletinStats(id);
|
||||||
return { success: true, data: stats };
|
return { success: true, data: stats };
|
||||||
@@ -181,7 +196,11 @@ export class SporTotoController {
|
|||||||
type: Number,
|
type: Number,
|
||||||
description: "Number of results (default: 20)",
|
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) {
|
async getRolloverHistory(@Query("limit") limit?: string) {
|
||||||
const history = await this.sporTotoService.getRolloverHistory(
|
const history = await this.sporTotoService.getRolloverHistory(
|
||||||
Number(limit) || 20,
|
Number(limit) || 20,
|
||||||
@@ -204,6 +223,7 @@ export class SporTotoController {
|
|||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: "Generated columns with strategy, cost, and column strings",
|
description: "Generated columns with strategy, cost, and column strings",
|
||||||
|
schema: { type: "object" },
|
||||||
})
|
})
|
||||||
async generateColumns(@Body() dto: GenerateColumnsDto) {
|
async generateColumns(@Body() dto: GenerateColumnsDto) {
|
||||||
const result = await this.sporTotoService.generateColumns(dto);
|
const result = await this.sporTotoService.generateColumns(dto);
|
||||||
@@ -223,6 +243,7 @@ export class SporTotoController {
|
|||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: "Evaluation results with correct counts per column",
|
description: "Evaluation results with correct counts per column",
|
||||||
|
schema: { type: "object" },
|
||||||
})
|
})
|
||||||
async evaluateColumns(@Body() dto: EvaluateColumnsDto) {
|
async evaluateColumns(@Body() dto: EvaluateColumnsDto) {
|
||||||
const result = await this.sporTotoService.evaluateColumns(
|
const result = await this.sporTotoService.evaluateColumns(
|
||||||
@@ -248,6 +269,7 @@ export class SporTotoController {
|
|||||||
status: 200,
|
status: 200,
|
||||||
description:
|
description:
|
||||||
"Prediction result with per-match analysis, system coupon, and EV report with play recommendation",
|
"Prediction result with per-match analysis, system coupon, and EV report with play recommendation",
|
||||||
|
schema: { type: "object" },
|
||||||
})
|
})
|
||||||
async generatePrediction(@Body() dto: GenerateSporTotoPredictionDto) {
|
async generatePrediction(@Body() dto: GenerateSporTotoPredictionDto) {
|
||||||
this.logger.log(
|
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();
|
||||||
@@ -12,7 +12,7 @@ import { FeederService } from "../modules/feeder/feeder.service";
|
|||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
|
|
||||||
const WATCHDOG_INTERVAL_MS = 60_000; // Check every 1 minute
|
const WATCHDOG_INTERVAL_MS = 60_000; // Check every 1 minute
|
||||||
const WATCHDOG_TIMEOUT_MS = 5 * 60_000; // Kill if no activity for 5 minutes
|
const WATCHDOG_TIMEOUT_MS = 3 * 60_000; // Kill if no activity for 3 minutes
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
process.env.FEEDER_MODE = "historical";
|
process.env.FEEDER_MODE = "historical";
|
||||||
@@ -31,15 +31,31 @@ async function bootstrap() {
|
|||||||
const feederService = app.get(FeederService);
|
const feederService = app.get(FeederService);
|
||||||
|
|
||||||
// ── Watchdog Timer ──────────────────────────────────────────
|
// ── Watchdog Timer ──────────────────────────────────────────
|
||||||
// If the feeder hangs on an API call for 5+ minutes, force-exit
|
// If the feeder hangs on an API call for 3+ minutes, force-kill
|
||||||
// so PM2 can restart and resume from where it left off in DB.
|
// so PM2 can restart and resume from where it left off in DB.
|
||||||
|
// NOTE: process.exit(1) alone can be blocked by open handles
|
||||||
|
// (DB connections, HTTP sockets). We use process.kill(SIGKILL)
|
||||||
|
// as an unconditional fallback.
|
||||||
const watchdog = setInterval(() => {
|
const watchdog = setInterval(() => {
|
||||||
const idleMs = Date.now() - feederService.lastActivityAt;
|
const idleMs = Date.now() - feederService.lastActivityAt;
|
||||||
if (idleMs > WATCHDOG_TIMEOUT_MS) {
|
if (idleMs > WATCHDOG_TIMEOUT_MS) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`🐕 WATCHDOG: No activity for ${Math.round(idleMs / 1000)}s. Force-exiting for PM2 restart...`,
|
`🐕 WATCHDOG: No activity for ${Math.round(idleMs / 1000)}s. Force-killing for PM2 restart...`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Try graceful exit first
|
||||||
|
try {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
} catch {
|
||||||
|
// Ignored – fallback below
|
||||||
|
}
|
||||||
|
|
||||||
|
// If process.exit didn't work (blocked by open handles),
|
||||||
|
// schedule an unconditional SIGKILL after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.error("🐕 WATCHDOG: process.exit blocked. Sending SIGKILL...");
|
||||||
|
process.kill(process.pid, "SIGKILL");
|
||||||
|
}, 2_000).unref();
|
||||||
}
|
}
|
||||||
}, WATCHDOG_INTERVAL_MS);
|
}, WATCHDOG_INTERVAL_MS);
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user