1066 lines
32 KiB
TypeScript
Executable File
1066 lines
32 KiB
TypeScript
Executable File
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";
|
|
import {
|
|
FINISHED_STATE_VALUES_FOR_DB,
|
|
FINISHED_STATUS_VALUES_FOR_DB,
|
|
LIVE_STATE_VALUES_FOR_DB,
|
|
LIVE_STATUS_VALUES_FOR_DB,
|
|
getDisplayMatchStatus,
|
|
} from "../../common/utils/match-status.util";
|
|
|
|
@Injectable()
|
|
export class MatchesService {
|
|
private readonly logger = new Logger(MatchesService.name);
|
|
private qualifiedLeagueIds: string[] = [];
|
|
private topLeagueIds: string[] = [];
|
|
|
|
constructor(private readonly prisma: PrismaService) {
|
|
this.loadQualifiedLeagues();
|
|
this.loadTopLeagues();
|
|
}
|
|
|
|
private loadTopLeagues() {
|
|
try {
|
|
const filePath = path.join(process.cwd(), "top_leagues.json");
|
|
if (fs.existsSync(filePath)) {
|
|
this.topLeagueIds = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
}
|
|
} catch (e) {
|
|
this.logger.warn(`Failed to load top_leagues.json: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
private loadQualifiedLeagues() {
|
|
try {
|
|
const filePath = path.join(process.cwd(), "qualified_leagues.json");
|
|
if (fs.existsSync(filePath)) {
|
|
this.qualifiedLeagueIds = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
this.logger.log(
|
|
`Loaded ${this.qualifiedLeagueIds.length} qualified leagues for filtering.`,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
this.logger.warn(`Failed to load qualified_leagues.json: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate URL for the country flag served from Mackolik
|
|
*/
|
|
private getCountryFlagUrl(countryId?: string | null): string | undefined {
|
|
if (!countryId) return undefined;
|
|
return `https://file.mackolikfeeds.com/areas/${countryId}`;
|
|
}
|
|
|
|
/**
|
|
* Generate URL for the team logo served from local uploads
|
|
*/
|
|
private getTeamLogoUrl(teamId?: string | null): string | undefined {
|
|
if (!teamId) return undefined;
|
|
return `https://file.mackolikfeeds.com/teams/${teamId}`;
|
|
}
|
|
|
|
private getLiveFilter(): Prisma.LiveMatchWhereInput {
|
|
return {
|
|
OR: [
|
|
{
|
|
status: {
|
|
in: LIVE_STATUS_VALUES_FOR_DB,
|
|
},
|
|
},
|
|
{
|
|
state: {
|
|
in: LIVE_STATE_VALUES_FOR_DB,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
private getFinishedFilter(): Prisma.LiveMatchWhereInput {
|
|
return {
|
|
OR: [
|
|
{
|
|
status: {
|
|
in: FINISHED_STATUS_VALUES_FOR_DB,
|
|
},
|
|
},
|
|
{
|
|
state: {
|
|
in: FINISHED_STATE_VALUES_FOR_DB,
|
|
},
|
|
},
|
|
{
|
|
AND: [
|
|
{ scoreHome: { not: null } },
|
|
{ scoreAway: { not: null } },
|
|
{
|
|
NOT: this.getLiveFilter(),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
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 (this.qualifiedLeagueIds.length > 0) {
|
|
// Only show matches from qualified leagues (leagues with historical data for AI analysis)
|
|
where.leagueId = { in: this.qualifiedLeagueIds };
|
|
}
|
|
|
|
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 ||
|
|
this.getCountryFlagUrl(match.league?.country?.id),
|
|
},
|
|
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
|
|
const displayStatus = getDisplayMatchStatus({
|
|
state: match.state,
|
|
status: match.status,
|
|
substate: match.substate,
|
|
scoreHome: match.scoreHome,
|
|
scoreAway: match.scoreAway,
|
|
});
|
|
|
|
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
|
|
? this.getTeamLogoUrl(match.homeTeamId)
|
|
: undefined,
|
|
awayTeamName: match.awayTeam?.name || "Unknown",
|
|
awayTeamLogo: match.awayTeamId
|
|
? this.getTeamLogoUrl(match.awayTeamId)
|
|
: undefined,
|
|
leagueName: match.league?.name,
|
|
countryName: match.league?.country?.name,
|
|
odds: structuredOdds,
|
|
});
|
|
}
|
|
|
|
return Array.from(leaguesMap.values()).sort((a, b) => {
|
|
const aIdx = this.topLeagueIds.indexOf(a.id);
|
|
const bIdx = this.topLeagueIds.indexOf(b.id);
|
|
const aPriority = aIdx === -1 ? 999 : aIdx;
|
|
const bPriority = bIdx === -1 ? 999 : bIdx;
|
|
|
|
if (aPriority !== bPriority) return aPriority - bPriority;
|
|
return (a.name || "").localeCompare(b.name || "");
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get active leagues with match counts
|
|
*/
|
|
async getActiveLeagues(sport: Sport): Promise<ActiveLeagueDto[]> {
|
|
// Start of today in UTC — same reference point as findMatches browse filter
|
|
const today = new Date();
|
|
today.setUTCHours(0, 0, 0, 0);
|
|
const todayMs = BigInt(today.getTime());
|
|
|
|
// Build finished statuses/states for exclusion (mirroring getBrowseFilter logic)
|
|
const finishedStatuses = FINISHED_STATUS_VALUES_FOR_DB;
|
|
const finishedStates = FINISHED_STATE_VALUES_FOR_DB;
|
|
const liveStatuses = LIVE_STATUS_VALUES_FOR_DB;
|
|
const liveStates = LIVE_STATE_VALUES_FOR_DB;
|
|
|
|
// Use raw query for complex aggregation
|
|
// Filter: (mstUtc >= today AND NOT finished) OR is currently live
|
|
const leagues = await this.prisma.$queryRaw<any[]>`
|
|
SELECT
|
|
l.id, l.name, l.code,
|
|
c.id as country_id,
|
|
c.name as country_name,
|
|
c.flag_url as country_flag,
|
|
COUNT(lm.id)::int as match_count,
|
|
COUNT(CASE WHEN lm.status IN (${Prisma.join(liveStatuses)})
|
|
OR lm.state IN (${Prisma.join(liveStates)}) 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.qualifiedLeagueIds.length > 0 ? Prisma.sql`AND l.id IN (${Prisma.join(this.qualifiedLeagueIds)})` : Prisma.empty}
|
|
AND (
|
|
(lm.mst_utc >= ${todayMs} AND lm.status NOT IN (${Prisma.join(finishedStatuses)}) AND COALESCE(lm.state, '') NOT IN (${Prisma.join(finishedStates)}))
|
|
OR lm.status IN (${Prisma.join(liveStatuses)})
|
|
OR lm.state IN (${Prisma.join(liveStates)})
|
|
)
|
|
GROUP BY l.id, l.name, l.code, c.id, c.name, c.flag_url
|
|
ORDER BY l.name ASC
|
|
`;
|
|
|
|
return leagues
|
|
.filter((l) => l.match_count > 0)
|
|
.sort((a, b) => {
|
|
const aIdx = this.topLeagueIds.indexOf(a.id);
|
|
const bIdx = this.topLeagueIds.indexOf(b.id);
|
|
|
|
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 || this.getCountryFlagUrl(l.country_id),
|
|
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 ? this.getTeamLogoUrl(m.homeTeamId) : null,
|
|
awayTeamName: m.awayTeam?.name,
|
|
awayTeamLogo: m.awayTeamId ? this.getTeamLogoUrl(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
|
|
const displayStatus = getDisplayMatchStatus({
|
|
state: liveMatch.state,
|
|
status: liveMatch.status,
|
|
substate: liveMatch.substate,
|
|
scoreHome: liveMatch.scoreHome,
|
|
scoreAway: liveMatch.scoreAway,
|
|
});
|
|
|
|
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: (() => {
|
|
const parsed: Array<{
|
|
teamId: string;
|
|
isStarting: boolean;
|
|
shirtNumber: string | number | null;
|
|
position: string | null;
|
|
player: { id: string; name: string };
|
|
}> = [];
|
|
const canTrustFeedLineups =
|
|
displayStatus === "LIVE" || displayStatus === "Finished";
|
|
if (!canTrustFeedLineups) {
|
|
return parsed;
|
|
}
|
|
if (liveMatch.lineups && typeof liveMatch.lineups === "object") {
|
|
const lu = liveMatch.lineups as Record<string, any>;
|
|
const addPlayers = (teamLu: any, teamId: string | null) => {
|
|
if (!teamLu || !teamId) return;
|
|
if (teamLu.xi && Array.isArray(teamLu.xi)) {
|
|
teamLu.xi.forEach((p: any) => {
|
|
parsed.push({
|
|
teamId,
|
|
isStarting: true,
|
|
shirtNumber: p.shirtNumber || p.number,
|
|
position: p.position || p.pos,
|
|
player: {
|
|
id: p.personId || p.id || p.playerId || "unknown",
|
|
name:
|
|
p.matchName || p.name || p.playerName || "Bilinmiyor",
|
|
},
|
|
});
|
|
});
|
|
}
|
|
if (teamLu.subs && Array.isArray(teamLu.subs)) {
|
|
teamLu.subs.forEach((p: any) => {
|
|
parsed.push({
|
|
teamId,
|
|
isStarting: false,
|
|
shirtNumber: p.shirtNumber || p.number,
|
|
position: p.position || p.pos,
|
|
player: {
|
|
id: p.personId || p.id || p.playerId || "unknown",
|
|
name:
|
|
p.matchName || p.name || p.playerName || "Bilinmiyor",
|
|
},
|
|
});
|
|
});
|
|
}
|
|
};
|
|
addPlayers(lu.home, liveMatch.homeTeamId);
|
|
addPlayers(lu.away, liveMatch.awayTeamId);
|
|
}
|
|
return parsed;
|
|
})(),
|
|
playerEvents: [],
|
|
oddCategories: [], // Will handle odds parsing below
|
|
officials: [],
|
|
isLiveSource: true, // Flag to indicate source
|
|
};
|
|
}
|
|
}
|
|
|
|
if (!match) return null;
|
|
|
|
const detailDisplayStatus = getDisplayMatchStatus({
|
|
state: match.state,
|
|
status: match.status,
|
|
substate: match.substate,
|
|
scoreHome: match.scoreHome,
|
|
scoreAway: match.scoreAway,
|
|
});
|
|
const canTrustStoredLineups =
|
|
this.canTrustStoredLineups(detailDisplayStatus);
|
|
|
|
if (Array.isArray(match.playerParticipations)) {
|
|
if (!canTrustStoredLineups) {
|
|
match.playerParticipations = [];
|
|
}
|
|
|
|
const hasHomeLineup = match.playerParticipations.some(
|
|
(p: any) => p.teamId === match.homeTeamId && p.isStarting,
|
|
);
|
|
const hasAwayLineup = match.playerParticipations.some(
|
|
(p: any) => p.teamId === match.awayTeamId && p.isStarting,
|
|
);
|
|
|
|
if (!hasHomeLineup || !hasAwayLineup) {
|
|
const sidelined =
|
|
match.sidelined && typeof match.sidelined === "object"
|
|
? (match.sidelined as Record<string, any>)
|
|
: {};
|
|
const matchDateMs = Number(match.mstUtc || Date.now());
|
|
const probableLineups: any[] = [];
|
|
|
|
if (!hasHomeLineup && match.homeTeamId) {
|
|
probableLineups.push(
|
|
...(await this.buildProbableLineupForTeam({
|
|
teamId: match.homeTeamId,
|
|
beforeDateMs: matchDateMs,
|
|
sidelinedTeamData: sidelined.homeTeam,
|
|
})),
|
|
);
|
|
}
|
|
|
|
if (!hasAwayLineup && match.awayTeamId) {
|
|
probableLineups.push(
|
|
...(await this.buildProbableLineupForTeam({
|
|
teamId: match.awayTeamId,
|
|
beforeDateMs: matchDateMs,
|
|
sidelinedTeamData: sidelined.awayTeam,
|
|
})),
|
|
);
|
|
}
|
|
|
|
if (probableLineups.length > 0) {
|
|
match.playerParticipations = canTrustStoredLineups
|
|
? [...match.playerParticipations, ...probableLineups]
|
|
: probableLineups;
|
|
match.lineupSource = "probable_xi";
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)),
|
|
score: match.score || { home: match.scoreHome, away: match.scoreAway },
|
|
homeTeam: {
|
|
...match.homeTeam,
|
|
logo: match.homeTeamId
|
|
? this.getTeamLogoUrl(match.homeTeamId)
|
|
: match.homeTeam?.logoUrl || null,
|
|
},
|
|
awayTeam: {
|
|
...match.awayTeam,
|
|
logo: match.awayTeamId
|
|
? this.getTeamLogoUrl(match.awayTeamId)
|
|
: match.awayTeam?.logoUrl || null,
|
|
},
|
|
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;
|
|
}
|
|
|
|
private async buildProbableLineupForTeam(params: {
|
|
teamId: string;
|
|
beforeDateMs: number;
|
|
sidelinedTeamData?: any;
|
|
matchLimit?: number;
|
|
lookbackDays?: number;
|
|
maxStalenessDays?: number;
|
|
}) {
|
|
const matchLimit = params.matchLimit ?? 5;
|
|
const lookbackDays = params.lookbackDays ?? 370;
|
|
const maxStalenessDays = params.maxStalenessDays ?? 120;
|
|
const beforeDateMs = params.beforeDateMs || Date.now();
|
|
const minDateMs = Math.max(
|
|
0,
|
|
beforeDateMs - lookbackDays * 24 * 60 * 60 * 1000,
|
|
);
|
|
const excluded = this.extractSidelinedPlayerIds(params.sidelinedTeamData);
|
|
|
|
const rows = await this.prisma.$queryRaw<any[]>`
|
|
SELECT
|
|
mpp.player_id AS "playerId",
|
|
p.name AS "playerName",
|
|
mpp.position AS "position",
|
|
mpp.shirt_number AS "shirtNumber",
|
|
m.id AS "matchId",
|
|
m.mst_utc AS "mstUtc"
|
|
FROM match_player_participation mpp
|
|
JOIN matches m ON m.id = mpp.match_id
|
|
JOIN players p ON p.id = mpp.player_id
|
|
WHERE mpp.team_id = ${params.teamId}
|
|
AND mpp.is_starting = true
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM match_player_participation later_mpp
|
|
JOIN matches later_m ON later_m.id = later_mpp.match_id
|
|
WHERE later_mpp.player_id = mpp.player_id
|
|
AND later_mpp.team_id <> ${params.teamId}
|
|
AND later_m.mst_utc > m.mst_utc
|
|
AND later_m.mst_utc < ${BigInt(beforeDateMs)}
|
|
AND (
|
|
later_m.status = 'FT'
|
|
OR later_m.state = 'postGame'
|
|
OR (later_m.score_home IS NOT NULL AND later_m.score_away IS NOT NULL)
|
|
)
|
|
)
|
|
AND m.id IN (
|
|
SELECT m2.id
|
|
FROM matches m2
|
|
JOIN match_player_participation recent_mpp
|
|
ON recent_mpp.match_id = m2.id
|
|
AND recent_mpp.team_id = ${params.teamId}
|
|
AND recent_mpp.is_starting = true
|
|
WHERE (m2.home_team_id = ${params.teamId} OR m2.away_team_id = ${params.teamId})
|
|
AND (
|
|
m2.status = 'FT'
|
|
OR m2.state = 'postGame'
|
|
OR (m2.score_home IS NOT NULL AND m2.score_away IS NOT NULL)
|
|
)
|
|
AND m2.mst_utc < ${BigInt(beforeDateMs)}
|
|
AND m2.mst_utc >= ${BigInt(minDateMs)}
|
|
GROUP BY m2.id
|
|
HAVING COUNT(recent_mpp.*) >= 9
|
|
ORDER BY MAX(m2.mst_utc) DESC
|
|
LIMIT ${matchLimit}
|
|
)
|
|
ORDER BY m.mst_utc DESC
|
|
`;
|
|
|
|
if (!rows.length) return [];
|
|
|
|
const latestMst = Math.max(...rows.map((row) => Number(row.mstUtc || 0)));
|
|
const ageDays =
|
|
latestMst > 0
|
|
? (beforeDateMs - latestMst) / (24 * 60 * 60 * 1000)
|
|
: Number.POSITIVE_INFINITY;
|
|
const staleProjection = ageDays > maxStalenessDays;
|
|
|
|
const matchOrder = new Map<string, number>();
|
|
for (const row of rows) {
|
|
const matchId = String(row.matchId);
|
|
if (!matchOrder.has(matchId)) {
|
|
matchOrder.set(matchId, matchOrder.size);
|
|
}
|
|
}
|
|
|
|
const playerMap = new Map<
|
|
string,
|
|
{
|
|
playerId: string;
|
|
playerName: string;
|
|
position: string | null;
|
|
shirtNumber: number | null;
|
|
score: number;
|
|
starts: number;
|
|
lastSeenRank: number;
|
|
}
|
|
>();
|
|
|
|
for (const row of rows) {
|
|
const playerId = String(row.playerId);
|
|
if (excluded.has(playerId)) continue;
|
|
|
|
const rank = matchOrder.get(String(row.matchId)) ?? matchLimit;
|
|
const recencyWeight = Math.max(1, matchLimit - rank);
|
|
const score = recencyWeight + (rank === 0 ? 3 : rank === 1 ? 1.5 : 0);
|
|
const existing = playerMap.get(playerId);
|
|
|
|
if (!existing) {
|
|
playerMap.set(playerId, {
|
|
playerId,
|
|
playerName: row.playerName || "Bilinmiyor",
|
|
position: row.position ?? null,
|
|
shirtNumber:
|
|
row.shirtNumber === null || row.shirtNumber === undefined
|
|
? null
|
|
: Number(row.shirtNumber),
|
|
score,
|
|
starts: 1,
|
|
lastSeenRank: rank,
|
|
});
|
|
} else {
|
|
existing.score += score;
|
|
existing.starts += 1;
|
|
existing.lastSeenRank = Math.min(existing.lastSeenRank, rank);
|
|
existing.position = existing.position || row.position || null;
|
|
existing.shirtNumber =
|
|
existing.shirtNumber ??
|
|
(row.shirtNumber === null || row.shirtNumber === undefined
|
|
? null
|
|
: Number(row.shirtNumber));
|
|
}
|
|
}
|
|
|
|
const ranked = [...playerMap.values()]
|
|
.sort((a, b) => {
|
|
if (b.score !== a.score) return b.score - a.score;
|
|
if (b.starts !== a.starts) return b.starts - a.starts;
|
|
return a.lastSeenRank - b.lastSeenRank;
|
|
})
|
|
.slice(0, 11);
|
|
|
|
const coverage = Math.min(1, ranked.length / 11);
|
|
const historyScore = Math.min(1, matchOrder.size / matchLimit);
|
|
const stableCore = ranked.filter((p) => p.starts >= 2).length / 11;
|
|
const stalenessFactor = Math.max(
|
|
0.35,
|
|
Math.min(1, maxStalenessDays / Math.max(ageDays, 1)),
|
|
);
|
|
const confidence = Math.max(
|
|
0,
|
|
Math.min(
|
|
staleProjection ? 0.58 : 0.88,
|
|
(coverage * 0.45 + historyScore * 0.25 + stableCore * 0.3) *
|
|
stalenessFactor,
|
|
),
|
|
);
|
|
|
|
return ranked.map((p) => ({
|
|
teamId: params.teamId,
|
|
isStarting: true,
|
|
shirtNumber: p.shirtNumber,
|
|
position: p.position,
|
|
isProbable: true,
|
|
lineupSource: "probable_xi",
|
|
projectionConfidence: Number(confidence.toFixed(3)),
|
|
projectionAgeDays: Number(ageDays.toFixed(1)),
|
|
projectionStale: staleProjection,
|
|
projectionMatchLimit: matchLimit,
|
|
projectionLookbackDays: lookbackDays,
|
|
projectionMaxStalenessDays: maxStalenessDays,
|
|
player: {
|
|
id: p.playerId,
|
|
name: p.playerName,
|
|
},
|
|
}));
|
|
}
|
|
|
|
private extractSidelinedPlayerIds(teamData: any): Set<string> {
|
|
if (!teamData || typeof teamData !== "object") return new Set();
|
|
const players = Array.isArray(teamData.players) ? teamData.players : [];
|
|
return new Set(
|
|
players
|
|
.map((player: any) =>
|
|
String(
|
|
player?.playerId ??
|
|
player?.player_id ??
|
|
player?.id ??
|
|
player?.personId ??
|
|
"",
|
|
),
|
|
)
|
|
.filter(Boolean),
|
|
);
|
|
}
|
|
|
|
private canTrustStoredLineups(displayStatus?: string): boolean {
|
|
const normalized = String(displayStatus || "").toLowerCase();
|
|
return (
|
|
normalized === "live" || normalized === "finished" || normalized === "ft"
|
|
);
|
|
}
|
|
}
|