first (part 3: src directory)
Deploy Iddaai Backend / build-and-deploy (push) Successful in 33s

This commit is contained in:
2026-04-16 15:12:27 +03:00
parent 2f0b85a0c7
commit 182f4aae16
125 changed files with 22552 additions and 0 deletions
+703
View File
@@ -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;
}
}