This commit is contained in:
Executable
+219
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsInt,
|
||||
IsEnum,
|
||||
IsDateString,
|
||||
Min,
|
||||
Max,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum Sport {
|
||||
FOOTBALL = 'football',
|
||||
BASKETBALL = 'basketball',
|
||||
}
|
||||
|
||||
export class OddFilterDto {
|
||||
@ApiProperty({ example: 'Maç Sonucu' })
|
||||
@IsString()
|
||||
categoryName: string;
|
||||
|
||||
@ApiProperty({ example: '1' })
|
||||
@IsString()
|
||||
selectionName: string;
|
||||
|
||||
@ApiProperty({ example: 1.5 })
|
||||
value: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 0.1 })
|
||||
@IsOptional()
|
||||
tolerance?: number = 0.1;
|
||||
}
|
||||
|
||||
export class TeamFilterDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['home', 'away', 'any'] })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
role?: 'home' | 'away' | 'any';
|
||||
}
|
||||
|
||||
export class DateRangeDto {
|
||||
@ApiProperty()
|
||||
@IsDateString()
|
||||
from: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsDateString()
|
||||
to: string;
|
||||
}
|
||||
|
||||
export class MatchQueryDto {
|
||||
@ApiProperty({ enum: Sport, default: Sport.FOOTBALL })
|
||||
@IsEnum(Sport)
|
||||
sport: Sport;
|
||||
|
||||
@ApiPropertyOptional({ default: 50 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 50;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
leagueId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by status: LIVE, Finished, etc.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Single date filter (YYYY-MM-DD)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: TeamFilterDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamFilterDto)
|
||||
team?: TeamFilterDto;
|
||||
|
||||
@ApiPropertyOptional({ type: [OddFilterDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OddFilterDto)
|
||||
odds?: OddFilterDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: DateRangeDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DateRangeDto)
|
||||
dateRange?: DateRangeDto;
|
||||
}
|
||||
|
||||
export class MatchResponseDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
matchName: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
matchSlug?: string;
|
||||
|
||||
@ApiProperty()
|
||||
mstUtc: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
state?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
scoreHome?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
scoreAway?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
htScoreHome?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
htScoreAway?: number;
|
||||
|
||||
@ApiProperty()
|
||||
homeTeamName: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
homeTeamLogo?: string;
|
||||
|
||||
@ApiProperty()
|
||||
awayTeamName: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
awayTeamLogo?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
leagueName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
countryName?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: 'array' })
|
||||
odds?: any[];
|
||||
}
|
||||
|
||||
export class PaginatedMatchesDto {
|
||||
@ApiProperty({ type: [MatchResponseDto] })
|
||||
matches: MatchResponseDto[];
|
||||
|
||||
@ApiProperty()
|
||||
total: number;
|
||||
|
||||
@ApiProperty()
|
||||
page: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export class LeagueWithMatchesDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
code?: string;
|
||||
|
||||
@ApiProperty()
|
||||
country: {
|
||||
id: string;
|
||||
name: string;
|
||||
flagUrl?: string;
|
||||
};
|
||||
|
||||
@ApiProperty()
|
||||
sport: Sport;
|
||||
|
||||
@ApiProperty({ type: [MatchResponseDto] })
|
||||
matches: MatchResponseDto[];
|
||||
}
|
||||
|
||||
export class ActiveLeagueDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
countryName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
countryFlag?: string;
|
||||
|
||||
@ApiProperty()
|
||||
matchCount: number;
|
||||
|
||||
@ApiProperty()
|
||||
liveCount: number;
|
||||
}
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { Public } from '../../common/decorators';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiQuery,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager';
|
||||
import { MatchesService } from './matches.service';
|
||||
import {
|
||||
MatchQueryDto,
|
||||
Sport,
|
||||
LeagueWithMatchesDto,
|
||||
ActiveLeagueDto,
|
||||
} from './dto';
|
||||
|
||||
@ApiTags('Matches')
|
||||
@Controller('matches')
|
||||
export class MatchesController {
|
||||
constructor(private readonly matchesService: MatchesService) {}
|
||||
|
||||
/**
|
||||
* POST /matches/query
|
||||
* Advanced match query with filters
|
||||
*/
|
||||
@Public()
|
||||
@Post('query')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Advanced match query with filters' })
|
||||
@ApiResponse({ status: 200, type: [LeagueWithMatchesDto] })
|
||||
async queryMatches(
|
||||
@Body() queryDto: MatchQueryDto,
|
||||
): Promise<LeagueWithMatchesDto[]> {
|
||||
if (!queryDto.sport) {
|
||||
throw new BadRequestException("'sport' field is required");
|
||||
}
|
||||
|
||||
const matchIds = await this.matchesService.findMatches(queryDto);
|
||||
|
||||
if (matchIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.matchesService.getMatchesAndStructureByIds(
|
||||
matchIds,
|
||||
queryDto.sport,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /matches
|
||||
* List matches with pagination
|
||||
*/
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List matches with pagination' })
|
||||
@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' })
|
||||
async listMatches(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('sport') sport?: Sport,
|
||||
) {
|
||||
const pageNum = parseInt(page || '1', 10);
|
||||
const limitNum = parseInt(limit || '20', 10);
|
||||
const sportType = sport || Sport.FOOTBALL;
|
||||
|
||||
return this.matchesService.listMatches(sportType, pageNum, limitNum);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /matches/leagues/active
|
||||
* Get active leagues with match counts
|
||||
*/
|
||||
@Public()
|
||||
@Get('leagues/active')
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheTTL(60000) // 1 minute cache
|
||||
@ApiOperation({ summary: 'Get active leagues with upcoming/live matches' })
|
||||
@ApiQuery({ name: 'sport', required: false, enum: Sport })
|
||||
@ApiResponse({ status: 200, type: [ActiveLeagueDto] })
|
||||
async getActiveLeagues(
|
||||
@Query('sport') sport?: Sport,
|
||||
): Promise<ActiveLeagueDto[]> {
|
||||
return this.matchesService.getActiveLeagues(sport || Sport.FOOTBALL);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /matches/:id
|
||||
* Get full match details
|
||||
*/
|
||||
@Public()
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get full match details by ID' })
|
||||
@ApiParam({ name: 'id', description: 'Match ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Match details with lineups, stats, odds, events',
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Match not found' })
|
||||
async getMatchDetails(@Param('id') id: string) {
|
||||
if (!id) {
|
||||
throw new BadRequestException('Match ID is required');
|
||||
}
|
||||
|
||||
const match = await this.matchesService.getMatchDetailsById(id);
|
||||
|
||||
if (!match) {
|
||||
throw new NotFoundException('Match not found');
|
||||
}
|
||||
|
||||
return match;
|
||||
}
|
||||
}
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MatchesController } from './matches.controller';
|
||||
import { MatchesService } from './matches.service';
|
||||
import { DatabaseModule } from '../../database/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [MatchesController],
|
||||
providers: [MatchesService],
|
||||
exports: [MatchesService],
|
||||
})
|
||||
export class MatchesModule {}
|
||||
Executable
+703
@@ -0,0 +1,703 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import {
|
||||
Sport,
|
||||
MatchQueryDto,
|
||||
LeagueWithMatchesDto,
|
||||
ActiveLeagueDto,
|
||||
} from './dto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class MatchesService {
|
||||
private readonly logger = new Logger(MatchesService.name);
|
||||
private topLeagueIds: string[] = [];
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
this.loadTopLeagues();
|
||||
}
|
||||
|
||||
private loadTopLeagues() {
|
||||
try {
|
||||
const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json');
|
||||
if (fs.existsSync(topLeaguesPath)) {
|
||||
this.topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8'));
|
||||
this.logger.log(
|
||||
`Loaded ${this.topLeagueIds.length} top leagues for filtering.`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(`Failed to load top_leagues.json: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getLiveFilter(): Prisma.LiveMatchWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
status: {
|
||||
in: [
|
||||
'LIVE',
|
||||
'1H',
|
||||
'2H',
|
||||
'HT',
|
||||
'1Q',
|
||||
'2Q',
|
||||
'3Q',
|
||||
'4Q',
|
||||
'Playing',
|
||||
'Half Time',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
state: {
|
||||
in: ['live', 'firsthalf', 'secondhalf'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private getFinishedFilter(): Prisma.LiveMatchWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
status: {
|
||||
in: ['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'],
|
||||
},
|
||||
},
|
||||
{
|
||||
state: {
|
||||
in: ['Finished', 'post', 'FT', 'postGame'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private getUpcomingFilter(
|
||||
fromTimestampMs: number,
|
||||
): Prisma.LiveMatchWhereInput {
|
||||
return {
|
||||
AND: [
|
||||
{
|
||||
mstUtc: {
|
||||
gte: BigInt(fromTimestampMs),
|
||||
},
|
||||
},
|
||||
{
|
||||
NOT: {
|
||||
OR: [this.getLiveFilter(), this.getFinishedFilter()],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private getBrowseFilter(fromTimestampMs: number): Prisma.LiveMatchWhereInput {
|
||||
return {
|
||||
AND: [
|
||||
{
|
||||
mstUtc: {
|
||||
gte: BigInt(fromTimestampMs),
|
||||
},
|
||||
},
|
||||
{
|
||||
NOT: this.getFinishedFilter(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find matches by query criteria
|
||||
*/
|
||||
async findMatches(options: MatchQueryDto): Promise<string[]> {
|
||||
const {
|
||||
sport,
|
||||
limit = 50,
|
||||
leagueId,
|
||||
status,
|
||||
date,
|
||||
team,
|
||||
dateRange,
|
||||
} = options;
|
||||
|
||||
// Build where conditions
|
||||
const where: Prisma.LiveMatchWhereInput = {
|
||||
sport: sport as any,
|
||||
};
|
||||
const andConditions: Prisma.LiveMatchWhereInput[] = [];
|
||||
|
||||
if (leagueId) {
|
||||
where.leagueId = leagueId;
|
||||
} else if (status === 'LIVE' && this.topLeagueIds.length > 0) {
|
||||
// Filter live matches by top leagues by default if no leagueId is provided
|
||||
where.leagueId = { in: this.topLeagueIds };
|
||||
}
|
||||
|
||||
if (status === 'LIVE') {
|
||||
andConditions.push(this.getLiveFilter());
|
||||
} else if (status === 'UPCOMING' || status === 'NOT_STARTED') {
|
||||
andConditions.push(this.getUpcomingFilter(Date.now()));
|
||||
} else if (status === 'FINISHED') {
|
||||
andConditions.push(this.getFinishedFilter());
|
||||
} else if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
// Date filter
|
||||
if (date) {
|
||||
const d = new Date(date);
|
||||
const startOfDay = new Date(d);
|
||||
startOfDay.setUTCHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(d);
|
||||
endOfDay.setUTCHours(23, 59, 59, 999);
|
||||
|
||||
where.mstUtc = {
|
||||
gte: BigInt(startOfDay.getTime()),
|
||||
lte: BigInt(endOfDay.getTime()),
|
||||
};
|
||||
} else if (dateRange) {
|
||||
where.mstUtc = {
|
||||
gte: BigInt(new Date(dateRange.from).getTime()),
|
||||
lte: BigInt(new Date(dateRange.to).getTime()),
|
||||
};
|
||||
}
|
||||
|
||||
// Team filter
|
||||
if (team) {
|
||||
if (team.role === 'home') {
|
||||
where.homeTeamId = team.id;
|
||||
} else if (team.role === 'away') {
|
||||
where.awayTeamId = team.id;
|
||||
} else {
|
||||
andConditions.push({
|
||||
OR: [{ homeTeamId: team.id }, { awayTeamId: team.id }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Default date filter: From today onwards if no specific filter
|
||||
if (!date && !dateRange && !status) {
|
||||
const today = new Date();
|
||||
today.setUTCHours(0, 0, 0, 0); // Start of today in UTC
|
||||
|
||||
andConditions.push(this.getBrowseFilter(today.getTime()));
|
||||
}
|
||||
|
||||
if (andConditions.length > 0) {
|
||||
where.AND = andConditions;
|
||||
}
|
||||
|
||||
// Switch to live_matches table
|
||||
const matches = await this.prisma.liveMatch.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
orderBy: { mstUtc: 'asc' }, // Sort by nearest match first
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return matches.map((m) => m.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find upcoming matches from the live matches table
|
||||
* Used for Coupon Generator when no specific matches are selected
|
||||
*/
|
||||
async findUpcomingMatches(
|
||||
sport: Sport,
|
||||
limit: number = 50,
|
||||
): Promise<string[]> {
|
||||
console.log(`[MatchesService] Finding upcoming matches for ${sport}`);
|
||||
|
||||
const matches = await this.prisma.liveMatch.findMany({
|
||||
where: {
|
||||
sport: sport as any,
|
||||
AND: [this.getUpcomingFilter(Date.now())],
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { mstUtc: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
console.log(
|
||||
`[MatchesService] Found ${matches.length} upcoming matches from live_matches`,
|
||||
);
|
||||
|
||||
return matches.map((m) => m.id);
|
||||
}
|
||||
|
||||
async filterUpcomingMatchIds(
|
||||
matchIds: string[],
|
||||
sport: Sport,
|
||||
): Promise<string[]> {
|
||||
const uniqueIds = [...new Set(matchIds.filter((id) => !!id))];
|
||||
|
||||
if (uniqueIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matches = await this.prisma.liveMatch.findMany({
|
||||
where: {
|
||||
id: { in: uniqueIds },
|
||||
sport: sport as any,
|
||||
AND: [this.getUpcomingFilter(Date.now())],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return matches.map((match) => match.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get matches structured by league (from live_matches table)
|
||||
*/
|
||||
async getMatchesAndStructureByIds(
|
||||
matchIds: string[],
|
||||
sport: Sport,
|
||||
): Promise<LeagueWithMatchesDto[]> {
|
||||
if (!matchIds.length) return [];
|
||||
|
||||
const matches = await this.prisma.liveMatch.findMany({
|
||||
where: { id: { in: matchIds } },
|
||||
include: {
|
||||
league: {
|
||||
include: {
|
||||
country: true,
|
||||
},
|
||||
},
|
||||
homeTeam: true,
|
||||
awayTeam: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Sort matches by time (ASC) before grouping to ensure correct order
|
||||
matches.sort((a, b) =>
|
||||
Number(BigInt(a.mstUtc || 0) - BigInt(b.mstUtc || 0)),
|
||||
);
|
||||
|
||||
// Group by league
|
||||
const leaguesMap = new Map<string, LeagueWithMatchesDto>();
|
||||
|
||||
for (const match of matches) {
|
||||
const leagueId = match.leagueId || 'unknown';
|
||||
|
||||
if (!leaguesMap.has(leagueId)) {
|
||||
leaguesMap.set(leagueId, {
|
||||
id: leagueId,
|
||||
name: match.league?.name || 'Unknown League',
|
||||
code: match.league?.code || undefined,
|
||||
country: {
|
||||
id: match.league?.country?.id || '',
|
||||
name: match.league?.country?.name || '',
|
||||
flagUrl: match.league?.country?.flagUrl || undefined,
|
||||
},
|
||||
sport: sport,
|
||||
matches: [],
|
||||
});
|
||||
}
|
||||
|
||||
const league = leaguesMap.get(leagueId)!;
|
||||
|
||||
// Structure odds from JSON
|
||||
const structuredOdds: any[] = [];
|
||||
if (
|
||||
match.odds &&
|
||||
typeof match.odds === 'object' &&
|
||||
!Array.isArray(match.odds)
|
||||
) {
|
||||
const oddsObj = match.odds as Record<string, Record<string, number>>;
|
||||
for (const [marketName, selections] of Object.entries(oddsObj)) {
|
||||
const structuredSelections: Record<string, { odd: string }> = {};
|
||||
if (selections && typeof selections === 'object') {
|
||||
for (const [selName, selOdd] of Object.entries(selections)) {
|
||||
structuredSelections[selName] = { odd: String(selOdd) };
|
||||
}
|
||||
structuredOdds.push({
|
||||
category_name: marketName,
|
||||
selections: structuredSelections,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map status for frontend
|
||||
let displayStatus = match.status || 'NS';
|
||||
if (match.state === 'live') {
|
||||
displayStatus = 'LIVE';
|
||||
} else if (
|
||||
match.state === 'post' ||
|
||||
match.state === 'FT' ||
|
||||
match.status === 'Finished'
|
||||
) {
|
||||
displayStatus = 'Finished';
|
||||
}
|
||||
|
||||
league.matches.push({
|
||||
id: match.id,
|
||||
matchName:
|
||||
match.matchName ||
|
||||
`${match.homeTeam?.name} vs ${match.awayTeam?.name}`,
|
||||
matchSlug: match.matchSlug || undefined,
|
||||
mstUtc: Number(match.mstUtc),
|
||||
status: displayStatus,
|
||||
state: match.state || undefined,
|
||||
scoreHome: match.scoreHome ?? undefined,
|
||||
scoreAway: match.scoreAway ?? undefined,
|
||||
htScoreHome: undefined, // LiveMatch table doesn't have HT scores separately usually
|
||||
htScoreAway: undefined,
|
||||
homeTeamName: match.homeTeam?.name || 'Unknown',
|
||||
homeTeamLogo: match.homeTeamId
|
||||
? `https://file.mackolikfeeds.com/teams/${match.homeTeamId}`
|
||||
: undefined,
|
||||
awayTeamName: match.awayTeam?.name || 'Unknown',
|
||||
awayTeamLogo: match.awayTeamId
|
||||
? `https://file.mackolikfeeds.com/teams/${match.awayTeamId}`
|
||||
: undefined,
|
||||
leagueName: match.league?.name,
|
||||
countryName: match.league?.country?.name,
|
||||
odds: structuredOdds,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(leaguesMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active leagues with match counts
|
||||
*/
|
||||
async getActiveLeagues(sport: Sport): Promise<ActiveLeagueDto[]> {
|
||||
// Use raw query for complex aggregation
|
||||
const leagues = await this.prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
l.id, l.name, l.code,
|
||||
c.name as country_name,
|
||||
c.flag_url as country_flag,
|
||||
COUNT(lm.id)::int as match_count,
|
||||
COUNT(CASE WHEN lm.status IN ('LIVE', '1H', '2H', 'HT', '1Q', '2Q', '3Q', '4Q', 'Playing', 'Half Time')
|
||||
OR lm.state IN ('live', 'firsthalf', 'secondhalf') THEN 1 END)::int as live_count
|
||||
FROM live_matches lm
|
||||
JOIN leagues l ON lm.league_id = l.id
|
||||
LEFT JOIN countries c ON l.country_id = c.id
|
||||
WHERE lm.sport = ${sport}
|
||||
${this.topLeagueIds.length > 0 ? Prisma.sql`AND l.id IN (${Prisma.join(this.topLeagueIds)})` : Prisma.empty}
|
||||
GROUP BY l.id, l.name, l.code, c.name, c.flag_url
|
||||
ORDER BY l.name ASC
|
||||
`;
|
||||
|
||||
// Priority sorting (Mackolik style)
|
||||
const PRIORITY = [
|
||||
'Trendyol Süper Lig',
|
||||
'Süper Lig',
|
||||
'Trendyol 1. Lig',
|
||||
'1. Lig',
|
||||
'Premier Lig',
|
||||
'LaLiga',
|
||||
'Serie A',
|
||||
'Bundesliga',
|
||||
'Ligue 1',
|
||||
];
|
||||
|
||||
return leagues
|
||||
.sort((a, b) => {
|
||||
const aIdx = PRIORITY.findIndex((p) => a.name?.includes(p));
|
||||
const bIdx = PRIORITY.findIndex((p) => b.name?.includes(p));
|
||||
|
||||
const aPriority = aIdx === -1 ? 999 : aIdx;
|
||||
const bPriority = bIdx === -1 ? 999 : bIdx;
|
||||
|
||||
if (aPriority !== bPriority) return aPriority - bPriority;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
})
|
||||
.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
code: l.code,
|
||||
countryName: l.country_name,
|
||||
countryFlag: l.country_flag,
|
||||
matchCount: l.match_count,
|
||||
liveCount: l.live_count,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* List matches with pagination
|
||||
*/
|
||||
async listMatches(sport: Sport, page: number = 1, limit: number = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [matches, total] = await Promise.all([
|
||||
this.prisma.match.findMany({
|
||||
where: { sport: sport as any },
|
||||
include: {
|
||||
homeTeam: true,
|
||||
awayTeam: true,
|
||||
league: {
|
||||
include: { country: true },
|
||||
},
|
||||
},
|
||||
orderBy: { mstUtc: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.match.count({ where: { sport: sport as any } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
matches: matches.map((m) => ({
|
||||
id: m.id,
|
||||
matchName: m.matchName,
|
||||
matchSlug: m.matchSlug,
|
||||
mstUtc: Number(m.mstUtc),
|
||||
scoreHome: m.scoreHome,
|
||||
scoreAway: m.scoreAway,
|
||||
status: m.status,
|
||||
homeTeamName: m.homeTeam?.name,
|
||||
homeTeamLogo: m.homeTeamId
|
||||
? `https://file.mackolikfeeds.com/teams/${m.homeTeamId}`
|
||||
: null,
|
||||
awayTeamName: m.awayTeam?.name,
|
||||
awayTeamLogo: m.awayTeamId
|
||||
? `https://file.mackolikfeeds.com/teams/${m.awayTeamId}`
|
||||
: null,
|
||||
leagueName: m.league?.name,
|
||||
countryName: m.league?.country?.name,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeTeamStat(stat: any, sport?: string) {
|
||||
if (!stat) return null;
|
||||
|
||||
const base = {
|
||||
id: stat.id,
|
||||
matchId: stat.matchId,
|
||||
teamId: stat.teamId,
|
||||
createdAt: stat.createdAt,
|
||||
};
|
||||
|
||||
if ((sport || '').toLowerCase() === 'basketball') {
|
||||
return {
|
||||
...base,
|
||||
points: stat.points,
|
||||
rebounds: stat.rebounds,
|
||||
assists: stat.assists,
|
||||
fgMade: stat.fgMade,
|
||||
fgAttempted: stat.fgAttempted,
|
||||
threePtMade: stat.threePtMade,
|
||||
threePtAttempted: stat.threePtAttempted,
|
||||
ftMade: stat.ftMade,
|
||||
ftAttempted: stat.ftAttempted,
|
||||
steals: stat.steals,
|
||||
blocks: stat.blocks,
|
||||
turnovers: stat.turnovers,
|
||||
q1Score: stat.q1Score,
|
||||
q2Score: stat.q2Score,
|
||||
q3Score: stat.q3Score,
|
||||
q4Score: stat.q4Score,
|
||||
otScore: stat.otScore,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
possessionPercentage: stat.possessionPercentage,
|
||||
shotsOnTarget: stat.shotsOnTarget,
|
||||
shotsOffTarget: stat.shotsOffTarget,
|
||||
totalShots: stat.totalShots,
|
||||
totalPasses: stat.totalPasses,
|
||||
corners: stat.corners,
|
||||
fouls: stat.fouls,
|
||||
offsides: stat.offsides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full match details by ID
|
||||
*/
|
||||
async getMatchDetailsById(matchId: string) {
|
||||
let match: any = await this.prisma.match.findUnique({
|
||||
where: { id: matchId },
|
||||
include: {
|
||||
league: { include: { country: true } },
|
||||
homeTeam: true,
|
||||
awayTeam: true,
|
||||
footballTeamStats: true,
|
||||
basketballTeamStats: true,
|
||||
playerParticipations: {
|
||||
include: { player: true },
|
||||
orderBy: [{ isStarting: 'desc' }, { position: 'asc' }],
|
||||
},
|
||||
playerEvents: {
|
||||
include: {
|
||||
player: true,
|
||||
assistPlayer: true,
|
||||
substitutedOut: true,
|
||||
},
|
||||
orderBy: [{ periodId: 'asc' }, { timeMinute: 'asc' }],
|
||||
},
|
||||
oddCategories: {
|
||||
include: { selections: true },
|
||||
},
|
||||
officials: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!match) {
|
||||
// Try to find in LiveMatch table
|
||||
const liveMatch = await this.prisma.liveMatch.findUnique({
|
||||
where: { id: matchId },
|
||||
include: {
|
||||
league: { include: { country: true } },
|
||||
homeTeam: true,
|
||||
awayTeam: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (liveMatch) {
|
||||
// Map liveMatch status
|
||||
let displayStatus = liveMatch.status || 'NS';
|
||||
if (liveMatch.state === 'live') {
|
||||
displayStatus = 'LIVE';
|
||||
} else if (
|
||||
liveMatch.state === 'post' ||
|
||||
liveMatch.state === 'FT' ||
|
||||
liveMatch.status === 'Finished'
|
||||
) {
|
||||
displayStatus = 'Finished';
|
||||
}
|
||||
|
||||
match = {
|
||||
...liveMatch,
|
||||
matchName:
|
||||
liveMatch.matchName ||
|
||||
`${liveMatch.homeTeam?.name} vs ${liveMatch.awayTeam?.name}`,
|
||||
status: displayStatus,
|
||||
mstUtc: liveMatch.mstUtc,
|
||||
score: {
|
||||
home: liveMatch.scoreHome,
|
||||
away: liveMatch.scoreAway,
|
||||
},
|
||||
date: new Date(Number(liveMatch.mstUtc)),
|
||||
// Fill missing relations with empty arrays
|
||||
teamStats: [],
|
||||
playerParticipations: [],
|
||||
playerEvents: [],
|
||||
oddCategories: [], // Will handle odds parsing below
|
||||
officials: [],
|
||||
isLiveSource: true, // Flag to indicate source
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
// Structure odds
|
||||
const odds: Record<
|
||||
string,
|
||||
Record<string, { odd: string; sov?: number }>
|
||||
> = {};
|
||||
|
||||
if (
|
||||
match.isLiveSource &&
|
||||
match.odds &&
|
||||
typeof match.odds === 'object' &&
|
||||
!Array.isArray(match.odds)
|
||||
) {
|
||||
// Parse JSON odds from LiveMatch
|
||||
const oddsObj = match.odds as Record<string, Record<string, number>>;
|
||||
for (const [marketName, selections] of Object.entries(oddsObj)) {
|
||||
odds[marketName] = {};
|
||||
if (selections && typeof selections === 'object') {
|
||||
for (const [selName, selOdd] of Object.entries(selections)) {
|
||||
odds[marketName][selName] = { odd: String(selOdd) };
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (match.oddCategories) {
|
||||
// Parse relation odds from Match
|
||||
for (const cat of match.oddCategories) {
|
||||
if (!cat.name) continue;
|
||||
odds[cat.name] = {};
|
||||
for (const sel of cat.selections) {
|
||||
if (sel.name) {
|
||||
odds[cat.name][sel.name] = {
|
||||
odd: sel.oddValue || '',
|
||||
sov: sel.sov ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sportStats =
|
||||
match.sport === 'basketball'
|
||||
? match.basketballTeamStats || []
|
||||
: match.footballTeamStats || [];
|
||||
const normalizedTeamStats = sportStats.map((s: any) =>
|
||||
this.normalizeTeamStat(s, match.sport),
|
||||
);
|
||||
const homeStat = sportStats.find((s: any) => s.teamId === match.homeTeamId);
|
||||
const awayStat = sportStats.find((s: any) => s.teamId === match.awayTeamId);
|
||||
|
||||
return {
|
||||
...match,
|
||||
teamStats: normalizedTeamStats,
|
||||
mstUtc: Number(match.mstUtc),
|
||||
date: match.date || new Date(Number(match.mstUtc)),
|
||||
// Ensure score is in expected format (nested object for frontend if needed, but frontend seems to use match.score.home in some places and match.scoreHome in others.
|
||||
// The match-detail-content uses match.score.home. Match entity has scoreHome/scoreAway fields.
|
||||
// Let's ensure compatibility.
|
||||
score: match.score || { home: match.scoreHome, away: match.scoreAway },
|
||||
stats: {
|
||||
home: this.normalizeTeamStat(homeStat, match.sport),
|
||||
away: this.normalizeTeamStat(awayStat, match.sport),
|
||||
},
|
||||
lineups: {
|
||||
home: match.playerParticipations.filter(
|
||||
(p: any) => p.teamId === match.homeTeamId,
|
||||
),
|
||||
away: match.playerParticipations.filter(
|
||||
(p: any) => p.teamId === match.awayTeamId,
|
||||
),
|
||||
},
|
||||
events: match.playerEvents,
|
||||
odds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team ID by name (for legacy compatibility)
|
||||
*/
|
||||
async getTeamIdByName(
|
||||
teamName: string,
|
||||
sport: Sport,
|
||||
): Promise<string | null> {
|
||||
const trimmedName = teamName.trim();
|
||||
|
||||
// Exact match first
|
||||
let team = await this.prisma.team.findFirst({
|
||||
where: { name: trimmedName, sport: sport as any },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (team) return team.id;
|
||||
|
||||
// Fuzzy search
|
||||
team = await this.prisma.team.findFirst({
|
||||
where: {
|
||||
name: { contains: trimmedName, mode: 'insensitive' },
|
||||
sport: sport as any,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return team?.id || null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user