1042 lines
33 KiB
TypeScript
Executable File
1042 lines
33 KiB
TypeScript
Executable File
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
|
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
/**
|
|
* Feeder Persistence Service - Senior Level Implementation
|
|
* Database operations using Prisma
|
|
*/
|
|
|
|
import { Injectable, Logger } from "@nestjs/common";
|
|
import { Prisma } from "@prisma/client";
|
|
import { PrismaService } from "../../database/prisma.service";
|
|
import {
|
|
Sport,
|
|
MatchSummary,
|
|
Competition,
|
|
TransformedPlayer,
|
|
MatchParticipation,
|
|
TransformedMatchStats,
|
|
MatchOfficial,
|
|
ParsedMatchHeader,
|
|
BasketballPlayerStats,
|
|
DbEventPayload,
|
|
DbMarketPayload,
|
|
BasketballTeamStats,
|
|
} from "./feeder.types";
|
|
import { ImageUtils } from "../../common/utils/image.util";
|
|
import { deriveStoredMatchStatus } from "../../common/utils/match-status.util";
|
|
|
|
@Injectable()
|
|
export class FeederPersistenceService {
|
|
private readonly logger = new Logger(FeederPersistenceService.name);
|
|
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
// ============================================
|
|
// HELPER FUNCTIONS
|
|
// ============================================
|
|
private safeString(value: any): string | null {
|
|
return value === null || value === undefined || value === ""
|
|
? null
|
|
: String(value);
|
|
}
|
|
|
|
private safeInt(value: any): number | null {
|
|
const num = parseInt(String(value), 10);
|
|
return isNaN(num) ? null : num;
|
|
}
|
|
|
|
private safeFloat(value: any): number | null {
|
|
const num = parseFloat(String(value));
|
|
return isNaN(num) ? null : num;
|
|
}
|
|
|
|
private mapPositionToEnum(position: string | null): any {
|
|
if (!position) return null;
|
|
const pos = position.toLowerCase();
|
|
if (pos.includes("kaleci") || pos.includes("goalkeeper"))
|
|
return "goalkeeper";
|
|
if (pos.includes("defans") || pos.includes("defender")) return "defender";
|
|
if (pos.includes("orta saha") || pos.includes("midfielder"))
|
|
return "midfielder";
|
|
if (pos.includes("forvet") || pos.includes("striker")) return "striker";
|
|
return null;
|
|
}
|
|
|
|
// ============================================
|
|
// ODDS HELPER (TRANSACTION SAFE)
|
|
// ============================================
|
|
private async saveOddsInTransaction(
|
|
tx: any,
|
|
matchId: string,
|
|
oddsArray: DbMarketPayload[],
|
|
): Promise<void> {
|
|
if (oddsArray.length === 0) return;
|
|
|
|
const existingCategories = await tx.oddCategory.findMany({
|
|
where: { matchId },
|
|
include: { selections: true },
|
|
});
|
|
|
|
for (const market of oddsArray) {
|
|
if (!market || !market.name || !market.selectionCollection) continue;
|
|
|
|
let category = existingCategories.find((c) => c.name === market.name);
|
|
|
|
if (!category) {
|
|
category = await tx.oddCategory.create({
|
|
data: {
|
|
matchId,
|
|
categoryJsonId: this.safeInt(market.id),
|
|
name: market.name,
|
|
},
|
|
include: { selections: true },
|
|
});
|
|
existingCategories.push(category);
|
|
}
|
|
|
|
for (const s of market.selectionCollection) {
|
|
if (!s || s.odd === "-" || s.odd === "") continue;
|
|
|
|
const sName = this.safeString(s.name);
|
|
const sValue = this.safeString(s.odd);
|
|
const sPos = this.safeString(s.position);
|
|
|
|
if (!sName || !sValue) continue;
|
|
|
|
const existingSel = category.selections.find(
|
|
(sel) => sel.name === sName,
|
|
);
|
|
|
|
if (existingSel) {
|
|
if (existingSel.oddValue !== sValue) {
|
|
const oldVal = parseFloat(existingSel.oddValue || "0");
|
|
const newVal = parseFloat(sValue);
|
|
|
|
if (!isNaN(oldVal) && !isNaN(newVal)) {
|
|
await tx.oddsHistory.create({
|
|
data: {
|
|
selectionId: existingSel.dbId,
|
|
matchId: matchId,
|
|
previousValue: oldVal,
|
|
newValue: newVal,
|
|
},
|
|
});
|
|
}
|
|
|
|
await tx.oddSelection.update({
|
|
where: { dbId: existingSel.dbId },
|
|
data: { oddValue: sValue, position: sPos },
|
|
});
|
|
}
|
|
} else {
|
|
const newSel = await tx.oddSelection.create({
|
|
data: {
|
|
categoryId: category.dbId,
|
|
name: sName,
|
|
oddValue: sValue,
|
|
openingValue: sValue,
|
|
position: sPos,
|
|
},
|
|
});
|
|
category.selections.push(newSel);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// MAIN SAVE FUNCTION
|
|
// ============================================
|
|
async saveMatch(
|
|
sport: Sport,
|
|
matchId: string,
|
|
matchSummary: MatchSummary,
|
|
league: Competition,
|
|
homeTeamId: string,
|
|
awayTeamId: string,
|
|
headerData: ParsedMatchHeader | null,
|
|
playersMap: Map<string, TransformedPlayer>,
|
|
participationData: MatchParticipation[],
|
|
eventData: DbEventPayload[],
|
|
stats: TransformedMatchStats | null,
|
|
basketballTeamStats: BasketballTeamStats | null,
|
|
basketballPlayerStats: Partial<BasketballPlayerStats>[],
|
|
oddsArray: DbMarketPayload[],
|
|
officialsData: MatchOfficial[],
|
|
): Promise<boolean> {
|
|
// START IMAGE DOWNLOADS (NON-BLOCKING)
|
|
const imageDownloads: Promise<void>[] = [];
|
|
|
|
const leagueId = this.safeString(league.id);
|
|
if (leagueId) {
|
|
const logoUrl = `https://file.mackolikfeeds.com/competitions/${leagueId}`;
|
|
const localPath = `public/uploads/competitions/${leagueId}.png`;
|
|
imageDownloads.push(
|
|
ImageUtils.downloadImage(logoUrl, localPath)
|
|
.then(() => void 0)
|
|
.catch((err) => {
|
|
this.logger.error(
|
|
`Failed to download league logo ${leagueId}: ${err}`,
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
const teamsToUpsert = [
|
|
{
|
|
id: homeTeamId,
|
|
name: matchSummary.homeTeam?.name || "Unknown",
|
|
slug: matchSummary.homeTeam?.slug || homeTeamId,
|
|
sport: sport,
|
|
},
|
|
{
|
|
id: awayTeamId,
|
|
name: matchSummary.awayTeam?.name || "Unknown",
|
|
slug: matchSummary.awayTeam?.slug || awayTeamId,
|
|
sport: sport,
|
|
},
|
|
];
|
|
|
|
for (const team of teamsToUpsert) {
|
|
const teamLogoUrl = `https://file.mackolikfeeds.com/teams/${team.id}`;
|
|
const teamLocalPath = `public/uploads/teams/${team.id}.png`;
|
|
imageDownloads.push(
|
|
ImageUtils.downloadImage(teamLogoUrl, teamLocalPath)
|
|
.then(() => void 0)
|
|
.catch((err) => {
|
|
this.logger.error(
|
|
`Failed to download team logo ${team.id}: ${err}`,
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
// DATABASE TRANSACTION
|
|
try {
|
|
await this.prisma.$transaction(
|
|
async (tx) => {
|
|
// 1. Save Country
|
|
const countryId = this.safeString(league.country?.id);
|
|
if (countryId) {
|
|
try {
|
|
await tx.country.upsert({
|
|
where: { id: countryId },
|
|
update: {},
|
|
create: {
|
|
id: countryId,
|
|
name: league.country.name || "Unknown",
|
|
},
|
|
});
|
|
} catch (error: any) {
|
|
if (error.code !== "P2002") throw error;
|
|
}
|
|
}
|
|
|
|
// 2. Save League (Handle ID changes by checking unique constraint)
|
|
let finalLeagueId = this.safeString(league.id);
|
|
if (finalLeagueId && countryId) {
|
|
const leagueName = league.name || "Unknown";
|
|
|
|
// Check if league exists by unique constraint (name + country + sport)
|
|
const existingLeague = await tx.league.findUnique({
|
|
where: {
|
|
name_countryId_sport: {
|
|
name: leagueName,
|
|
countryId: countryId,
|
|
sport: sport,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (existingLeague) {
|
|
// If exists with different ID, use existing ID to prevent constraint errors
|
|
finalLeagueId = existingLeague.id;
|
|
// Update sortOrder if changed
|
|
if (league.sortOrder !== undefined) {
|
|
await tx.$executeRaw`UPDATE leagues SET sort_order = ${league.sortOrder} WHERE id = ${finalLeagueId}`;
|
|
}
|
|
} else {
|
|
// Create new league
|
|
await tx.league.create({
|
|
data: {
|
|
id: finalLeagueId,
|
|
name: leagueName,
|
|
countryId: countryId,
|
|
sport: sport,
|
|
competitionSlug: league.competitionSlug,
|
|
logoUrl: `/uploads/competitions/${finalLeagueId}.png`,
|
|
} as any,
|
|
});
|
|
if (league.sortOrder !== undefined) {
|
|
await tx.$executeRaw`UPDATE leagues SET sort_order = ${league.sortOrder} WHERE id = ${finalLeagueId}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Save Teams (BULK OPTIMIZED)
|
|
const existingTeams = await tx.team.findMany({
|
|
where: {
|
|
id: { in: [homeTeamId, awayTeamId] },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
const existingTeamIds = new Set(existingTeams.map((t) => t.id));
|
|
const teamsToCreate = teamsToUpsert.filter(
|
|
(t) => !existingTeamIds.has(t.id),
|
|
);
|
|
const teamsToUpdate = teamsToUpsert.filter((t) =>
|
|
existingTeamIds.has(t.id),
|
|
);
|
|
|
|
if (teamsToCreate.length > 0) {
|
|
await tx.team.createMany({
|
|
data: teamsToCreate.map((t) => ({
|
|
...t,
|
|
logoUrl: `/uploads/teams/${t.id}.png`,
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
|
|
for (const team of teamsToUpdate) {
|
|
await tx.team.update({
|
|
where: { id: team.id },
|
|
data: {
|
|
name: team.name,
|
|
logoUrl: `/uploads/teams/${team.id}.png`,
|
|
},
|
|
});
|
|
}
|
|
|
|
// 4. Save Match
|
|
const finalScoreHome =
|
|
headerData?.scoreHome ?? this.safeInt(matchSummary.score?.home);
|
|
const finalScoreAway =
|
|
headerData?.scoreAway ?? this.safeInt(matchSummary.score?.away);
|
|
const htScoreHome =
|
|
headerData?.htScoreHome ??
|
|
this.safeInt(matchSummary.score?.ht?.home);
|
|
const htScoreAway =
|
|
headerData?.htScoreAway ??
|
|
this.safeInt(matchSummary.score?.ht?.away);
|
|
|
|
const status = deriveStoredMatchStatus({
|
|
state: headerData?.matchStatus ?? matchSummary.state,
|
|
status: matchSummary.status,
|
|
substate: matchSummary.substate,
|
|
statusBoxContent: matchSummary.statusBoxContent,
|
|
scoreHome: finalScoreHome,
|
|
scoreAway: finalScoreAway,
|
|
score: matchSummary.score,
|
|
});
|
|
|
|
await tx.match.upsert({
|
|
where: { id: matchId },
|
|
update: {
|
|
scoreHome: finalScoreHome,
|
|
scoreAway: finalScoreAway,
|
|
htScoreHome: htScoreHome,
|
|
htScoreAway: htScoreAway,
|
|
status: status,
|
|
state: headerData?.matchStatus || null,
|
|
},
|
|
create: {
|
|
id: matchId,
|
|
leagueId: finalLeagueId || undefined,
|
|
homeTeamId: homeTeamId,
|
|
awayTeamId: awayTeamId,
|
|
sport: sport,
|
|
matchName: matchSummary.matchName,
|
|
matchSlug: matchSummary.matchSlug,
|
|
mstUtc: BigInt(matchSummary.mstUtc || 0),
|
|
status: status,
|
|
state: headerData?.matchStatus || null,
|
|
scoreHome: finalScoreHome,
|
|
scoreAway: finalScoreAway,
|
|
htScoreHome: htScoreHome,
|
|
htScoreAway: htScoreAway,
|
|
winner: matchSummary.winner || null,
|
|
iddaaCode: this.safeString(matchSummary.iddaaCode),
|
|
},
|
|
});
|
|
|
|
// 5. Save Players (BULK OPTIMIZED)
|
|
const playersArray = Array.from(playersMap.values());
|
|
if (playersArray.length > 0) {
|
|
const existingPlayers = await tx.player.findMany({
|
|
where: {
|
|
id: { in: playersArray.map((p) => p.id) },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
const existingPlayerIds = new Set(existingPlayers.map((p) => p.id));
|
|
const playersToCreate = playersArray.filter(
|
|
(p) => !existingPlayerIds.has(p.id),
|
|
);
|
|
const playersToUpdate = playersArray.filter((p) =>
|
|
existingPlayerIds.has(p.id),
|
|
);
|
|
|
|
if (playersToCreate.length > 0) {
|
|
await tx.player.createMany({
|
|
data: playersToCreate.map((p) => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
slug: p.slug,
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
|
|
if (playersToUpdate.length > 0) {
|
|
await Promise.all(
|
|
playersToUpdate.map((p) =>
|
|
tx.player.update({
|
|
where: { id: p.id },
|
|
data: { name: p.name },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// 6. Save Participation
|
|
if (participationData.length > 0) {
|
|
await tx.matchPlayerParticipation.deleteMany({
|
|
where: { matchId: matchId },
|
|
});
|
|
|
|
await tx.matchPlayerParticipation.createMany({
|
|
data: participationData.map((p) => ({
|
|
matchId: p.matchId,
|
|
playerId: p.playerId,
|
|
teamId: p.teamId,
|
|
position: this.mapPositionToEnum(p.position),
|
|
shirtNumber: p.shirtNumber,
|
|
isStarting: p.isStarting,
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
|
|
// 7. Save Events
|
|
if (eventData.length > 0) {
|
|
await tx.matchPlayerEvents.deleteMany({
|
|
where: { matchId: matchId },
|
|
});
|
|
|
|
await tx.matchPlayerEvents.createMany({
|
|
data: eventData.map((e) => ({
|
|
matchId: e.match_id,
|
|
playerId: e.player_id,
|
|
teamId: e.team_id,
|
|
eventType: e.event_type,
|
|
eventSubtype: e.event_subtype,
|
|
timeMinute: e.time_minute,
|
|
timeSeconds: e.time_seconds,
|
|
periodId: e.period_id,
|
|
assistPlayerId: e.assist_player_id,
|
|
scoreAfter: e.score_after,
|
|
playerOutId: e.player_out_id,
|
|
position: e.position,
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
|
|
// 8. Save Team Stats (Football)
|
|
if (stats && sport === "football") {
|
|
const statsRows = [
|
|
{
|
|
matchId,
|
|
teamId: homeTeamId,
|
|
possessionPercentage: stats.home.possesionPercentage,
|
|
shotsOnTarget: stats.home.shotsOnTarget,
|
|
shotsOffTarget: stats.home.shotsOffTarget,
|
|
totalShots:
|
|
(stats.home.shotsOnTarget || 0) +
|
|
(stats.home.shotsOffTarget || 0) || null,
|
|
totalPasses: stats.home.totalPasses,
|
|
corners: stats.home.corners,
|
|
fouls: stats.home.fouls,
|
|
offsides: stats.home.offsides,
|
|
},
|
|
{
|
|
matchId,
|
|
teamId: awayTeamId,
|
|
possessionPercentage: stats.away.possesionPercentage,
|
|
shotsOnTarget: stats.away.shotsOnTarget,
|
|
shotsOffTarget: stats.away.shotsOffTarget,
|
|
totalShots:
|
|
(stats.away.shotsOnTarget || 0) +
|
|
(stats.away.shotsOffTarget || 0) || null,
|
|
totalPasses: stats.away.totalPasses,
|
|
corners: stats.away.corners,
|
|
fouls: stats.away.fouls,
|
|
offsides: stats.away.offsides,
|
|
},
|
|
];
|
|
|
|
for (const row of statsRows) {
|
|
await tx.footballTeamStats.upsert({
|
|
where: {
|
|
matchId_teamId: { matchId: row.matchId, teamId: row.teamId },
|
|
},
|
|
update: row,
|
|
create: row,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 8b. Save Team Stats (Basketball)
|
|
if (basketballTeamStats && sport === "basketball") {
|
|
const teams = [
|
|
{ id: homeTeamId, data: basketballTeamStats.home },
|
|
{ id: awayTeamId, data: basketballTeamStats.away },
|
|
];
|
|
|
|
for (const t of teams) {
|
|
if (!t.data) continue;
|
|
await tx.basketballTeamStats.upsert({
|
|
where: {
|
|
matchId_teamId: { matchId, teamId: t.id },
|
|
},
|
|
update: {
|
|
points: t.data.points,
|
|
rebounds: t.data.rebounds,
|
|
assists: t.data.assists,
|
|
fgMade: t.data.fgMade,
|
|
fgAttempted: t.data.fgAttempted,
|
|
threePtMade: t.data.threePtMade,
|
|
threePtAttempted: t.data.threePtAttempted,
|
|
ftMade: t.data.ftMade,
|
|
ftAttempted: t.data.ftAttempted,
|
|
steals: t.data.steals,
|
|
blocks: t.data.blocks,
|
|
turnovers: t.data.turnovers,
|
|
fouls: t.data.fouls,
|
|
q1Score: t.data.q1,
|
|
q2Score: t.data.q2,
|
|
q3Score: t.data.q3,
|
|
q4Score: t.data.q4,
|
|
otScore: t.data.ot,
|
|
},
|
|
create: {
|
|
matchId,
|
|
teamId: t.id,
|
|
points: t.data.points,
|
|
rebounds: t.data.rebounds,
|
|
assists: t.data.assists,
|
|
fgMade: t.data.fgMade,
|
|
fgAttempted: t.data.fgAttempted,
|
|
threePtMade: t.data.threePtMade,
|
|
threePtAttempted: t.data.threePtAttempted,
|
|
ftMade: t.data.ftMade,
|
|
ftAttempted: t.data.ftAttempted,
|
|
steals: t.data.steals,
|
|
blocks: t.data.blocks,
|
|
turnovers: t.data.turnovers,
|
|
fouls: t.data.fouls,
|
|
q1Score: t.data.q1,
|
|
q2Score: t.data.q2,
|
|
q3Score: t.data.q3,
|
|
q4Score: t.data.q4,
|
|
otScore: t.data.ot,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// 8c. Save Player Stats (Basketball)
|
|
if (basketballPlayerStats.length > 0 && sport === "basketball") {
|
|
await tx.basketballPlayerStats.deleteMany({ where: { matchId } });
|
|
|
|
for (const p of basketballPlayerStats) {
|
|
if (!p.id || !p.teamId) continue;
|
|
|
|
await tx.basketballPlayerStats.create({
|
|
data: {
|
|
matchId,
|
|
playerId: p.id,
|
|
teamId: p.teamId,
|
|
minutes: p.minutes,
|
|
points: p.points,
|
|
rebounds: p.rebounds,
|
|
assists: p.assists,
|
|
fgMade: p.fgMade,
|
|
fgAttempted: p.fgAttempted,
|
|
threePtMade: p.threePtMade,
|
|
threePtAttempted: p.threePtAttempted,
|
|
ftMade: p.ftMade,
|
|
ftAttempted: p.ftAttempted,
|
|
steals: p.steals,
|
|
blocks: p.blocks,
|
|
turnovers: p.turnovers,
|
|
fouls: p.fouls,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// 9. Save Odds (USING HELPER)
|
|
await this.saveOddsInTransaction(tx, matchId, oddsArray);
|
|
|
|
// 10. Save Officials
|
|
if (sport === "football" && officialsData.length > 0) {
|
|
await tx.matchOfficial.deleteMany({ where: { matchId } });
|
|
const processedOfficials = new Set<string>();
|
|
|
|
for (const o of officialsData) {
|
|
const roleName = o.role || "Referee";
|
|
const uniqueKey = `${o.name}_${roleName}`;
|
|
|
|
if (processedOfficials.has(uniqueKey)) continue;
|
|
processedOfficials.add(uniqueKey);
|
|
|
|
const role = await tx.officialRole.upsert({
|
|
where: { name: roleName },
|
|
update: {},
|
|
create: { name: roleName },
|
|
});
|
|
|
|
await tx.matchOfficial.create({
|
|
data: {
|
|
matchId,
|
|
name: o.name,
|
|
roleId: role.id,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
},
|
|
{ maxWait: 40000, timeout: 40000 },
|
|
);
|
|
|
|
// WAIT FOR IMAGES AFTER TRANSACTION
|
|
await Promise.allSettled(imageDownloads);
|
|
|
|
this.logger.log(`✅ SAVED: [${matchId}] ${matchSummary.matchName}`);
|
|
return true;
|
|
} catch (error: any) {
|
|
this.logger.error(`❌ SAVE FAILED [${matchId}]: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// SELECTIVE UPDATE: LINEUPS ONLY
|
|
// ============================================
|
|
async saveLineups(
|
|
matchId: string,
|
|
playersMap: Map<string, TransformedPlayer>,
|
|
participationData: MatchParticipation[],
|
|
homeTeamId: string,
|
|
awayTeamId: string,
|
|
): Promise<boolean> {
|
|
try {
|
|
await this.prisma.$transaction(
|
|
async (tx) => {
|
|
const matchInMainDb = await tx.match.findUnique({
|
|
where: { id: matchId },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (matchInMainDb) {
|
|
const playersArray = Array.from(playersMap.values());
|
|
if (playersArray.length > 0) {
|
|
const existingPlayers = await tx.player.findMany({
|
|
where: {
|
|
id: { in: playersArray.map((p) => p.id) },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
const existingPlayerIds = new Set(
|
|
existingPlayers.map((p) => p.id),
|
|
);
|
|
const playersToCreate = playersArray.filter(
|
|
(p) => !existingPlayerIds.has(p.id),
|
|
);
|
|
|
|
if (playersToCreate.length > 0) {
|
|
await tx.player.createMany({
|
|
data: playersToCreate.map((p) => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
slug: p.slug,
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (participationData.length > 0) {
|
|
await tx.matchPlayerParticipation.deleteMany({
|
|
where: { matchId: matchId },
|
|
});
|
|
|
|
await tx.matchPlayerParticipation.createMany({
|
|
data: participationData.map((p) => ({
|
|
matchId: p.matchId,
|
|
playerId: p.playerId,
|
|
teamId: p.teamId,
|
|
position: this.mapPositionToEnum(p.position),
|
|
shirtNumber: p.shirtNumber,
|
|
isStarting: p.isStarting,
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
{ maxWait: 15000, timeout: 15000 },
|
|
);
|
|
|
|
this.logger.log(`✅ LINEUPS REFRESHED & SYNCED: [${matchId}]`);
|
|
return true;
|
|
} catch (error: any) {
|
|
this.logger.error(`❌ LINEUP SAVE FAILED [${matchId}]: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// SELECTIVE UPDATE: ODDS ONLY (HISTORY-AWARE)
|
|
// ============================================
|
|
async saveOdds(
|
|
matchId: string,
|
|
oddsArray: DbMarketPayload[],
|
|
): Promise<boolean> {
|
|
try {
|
|
await this.prisma.$transaction(
|
|
async (tx) => {
|
|
// 1. MAIN DB LOGIC
|
|
const matchInMainDb = await tx.match.findUnique({
|
|
where: { id: matchId },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (matchInMainDb && oddsArray.length > 0) {
|
|
await this.saveOddsInTransaction(tx, matchId, oddsArray);
|
|
}
|
|
|
|
// 2. LIVE MATCH DB LOGIC
|
|
const liveMatch = await tx.liveMatch.findUnique({
|
|
where: { id: matchId },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (liveMatch && oddsArray.length > 0) {
|
|
const oddsJson: Record<string, Record<string, number>> = {};
|
|
for (const m of oddsArray) {
|
|
oddsJson[m.name] = {};
|
|
for (const s of m.selectionCollection) {
|
|
const val = parseFloat(s.odd);
|
|
if (!isNaN(val)) oddsJson[m.name][s.name] = val;
|
|
}
|
|
}
|
|
|
|
await tx.liveMatch.update({
|
|
where: { id: matchId },
|
|
data: {
|
|
odds: oddsJson as any,
|
|
oddsUpdatedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
},
|
|
{ maxWait: 15000, timeout: 15000 },
|
|
);
|
|
|
|
this.logger.log(`✅ ODDS REFRESHED: [${matchId}]`);
|
|
return true;
|
|
} catch (error: any) {
|
|
this.logger.error(`❌ ODDS SAVE FAILED [${matchId}]: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// FULL DATA FETCH FOR AI
|
|
// ============================================
|
|
async getMatchFullDetails(matchId: string) {
|
|
const match = await this.prisma.match.findUnique({
|
|
where: { id: matchId },
|
|
include: {
|
|
homeTeam: true,
|
|
awayTeam: true,
|
|
league: true,
|
|
oddCategories: {
|
|
include: { selections: true },
|
|
},
|
|
playerParticipations: {
|
|
select: { playerId: true, teamId: true, isStarting: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!match) return null;
|
|
|
|
const homeLineup = match.playerParticipations
|
|
.filter((p) => p.teamId === match.homeTeamId)
|
|
.map((p) => p.playerId);
|
|
const awayLineup = match.playerParticipations
|
|
.filter((p) => p.teamId === match.awayTeamId)
|
|
.map((p) => p.playerId);
|
|
|
|
const getForm = async (teamId: string) => {
|
|
const history = await this.prisma.match.findMany({
|
|
where: {
|
|
OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }],
|
|
status: "FT",
|
|
mstUtc: { lt: match.mstUtc },
|
|
},
|
|
orderBy: { mstUtc: "desc" },
|
|
take: 5,
|
|
});
|
|
|
|
if (history.length === 0) return { avg_gf: 1.2, avg_ga: 1.2 };
|
|
|
|
let totalGF = 0;
|
|
let totalGA = 0;
|
|
for (const m of history) {
|
|
if (m.homeTeamId === teamId) {
|
|
totalGF += m.scoreHome ?? 0;
|
|
totalGA += m.scoreAway ?? 0;
|
|
} else {
|
|
totalGF += m.scoreAway ?? 0;
|
|
totalGA += m.scoreHome ?? 0;
|
|
}
|
|
}
|
|
return {
|
|
avg_gf: totalGF / history.length,
|
|
avg_ga: totalGA / history.length,
|
|
};
|
|
};
|
|
|
|
const homeForm = await getForm(match.homeTeamId!);
|
|
const awayForm = await getForm(match.awayTeamId!);
|
|
|
|
const odds: any[] = [];
|
|
for (const cat of match.oddCategories) {
|
|
for (const sel of cat.selections) {
|
|
odds.push({
|
|
category: cat.name,
|
|
selection: sel.name,
|
|
odd_value: this.safeFloat(sel.oddValue),
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
match_id: match.id,
|
|
home_team: match.homeTeam?.name || "Unknown",
|
|
away_team: match.awayTeam?.name || "Unknown",
|
|
home_team_id: match.homeTeamId,
|
|
away_team_id: match.awayTeamId,
|
|
league_id: match.leagueId,
|
|
league_name: match.league?.name,
|
|
date: match.mstUtc.toString(),
|
|
score_home: match.scoreHome,
|
|
score_away: match.scoreAway,
|
|
status: match.status,
|
|
odds: odds,
|
|
home_form: homeForm,
|
|
away_form: awayForm,
|
|
home_lineup: homeLineup,
|
|
away_lineup: awayLineup,
|
|
};
|
|
}
|
|
|
|
// ============================================
|
|
// CHECKERS
|
|
// ============================================
|
|
async matchExists(matchId: string): Promise<boolean> {
|
|
const match = await this.prisma.match.findUnique({
|
|
where: { id: matchId },
|
|
select: { id: true },
|
|
});
|
|
return !!match;
|
|
}
|
|
|
|
async getExistingMatchIds(matchIds: string[]): Promise<string[]> {
|
|
if (matchIds.length === 0) return [];
|
|
|
|
// Use raw SQL for performance — Prisma's { some: {} } relation filters
|
|
// generate heavy correlated subqueries that hang on Raspberry Pi with
|
|
// large tables (15M+ odd_selections, 3M+ participations).
|
|
const result = await this.prisma.$queryRaw<Array<{ id: string }>>(
|
|
Prisma.sql`
|
|
SELECT m.id
|
|
FROM matches m
|
|
WHERE m.id = ANY(${matchIds}::text[])
|
|
AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id)
|
|
AND (
|
|
(m.sport = 'football'
|
|
AND EXISTS (SELECT 1 FROM football_team_stats fts WHERE fts.match_id = m.id)
|
|
AND (SELECT count(*) FROM match_player_participation mpp
|
|
WHERE mpp.match_id = m.id AND mpp.is_starting = true) >= 18)
|
|
OR
|
|
(m.sport = 'basketball'
|
|
AND EXISTS (SELECT 1 FROM basketball_team_stats bts WHERE bts.match_id = m.id)
|
|
AND EXISTS (SELECT 1 FROM basketball_player_stats bps WHERE bps.match_id = m.id))
|
|
)
|
|
`,
|
|
);
|
|
|
|
return result.map((r) => r.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;
|
|
|
|
// Use raw SQL for performance on Raspberry Pi.
|
|
// Note: state is 'postGame' in DB, not 'Ended'.
|
|
const rows = await this.prisma.$queryRawUnsafe<
|
|
Array<{
|
|
id: string;
|
|
sport: string;
|
|
fts_count: bigint;
|
|
pp_count: bigint;
|
|
bts_count: bigint;
|
|
bps_count: bigint;
|
|
oc_count: bigint;
|
|
}>
|
|
>(
|
|
`
|
|
SELECT m.id, m.sport::text,
|
|
(SELECT count(*) FROM football_team_stats fts WHERE fts.match_id = m.id) as fts_count,
|
|
(SELECT count(*) FROM match_player_participation mpp WHERE mpp.match_id = m.id) as pp_count,
|
|
(SELECT count(*) FROM basketball_team_stats bts WHERE bts.match_id = m.id) as bts_count,
|
|
(SELECT count(*) FROM basketball_player_stats bps WHERE bps.match_id = m.id) as bps_count,
|
|
(SELECT count(*) FROM odd_categories oc WHERE oc.match_id = m.id) as oc_count
|
|
FROM matches m
|
|
WHERE m.id = ANY($1::text[])
|
|
AND m.state = 'postGame'
|
|
`,
|
|
matchIds,
|
|
);
|
|
|
|
for (const m of rows) {
|
|
const missing: string[] = [];
|
|
|
|
if (m.sport === "football") {
|
|
if (Number(m.fts_count) === 0) missing.push("stats");
|
|
if (Number(m.pp_count) < 18) missing.push("lineups");
|
|
} else if (m.sport === "basketball") {
|
|
if (Number(m.bts_count) === 0) missing.push("stats");
|
|
if (Number(m.bps_count) === 0) missing.push("lineups");
|
|
}
|
|
|
|
if (Number(m.oc_count) === 0) missing.push("odds");
|
|
|
|
if (missing.length > 0) {
|
|
result.set(m.id, missing);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async hasOdds(matchId: string): Promise<boolean> {
|
|
const category = await this.prisma.oddCategory.findFirst({
|
|
where: { matchId },
|
|
});
|
|
if (category) return true;
|
|
|
|
const live = await this.prisma.liveMatch.findUnique({
|
|
where: { id: matchId },
|
|
select: { odds: true },
|
|
});
|
|
return !!(live?.odds && Object.keys(live.odds as any).length > 0);
|
|
}
|
|
|
|
async getMatch(matchId: string): Promise<any | null> {
|
|
const match = await this.prisma.match.findUnique({
|
|
where: { id: matchId },
|
|
include: {
|
|
homeTeam: true,
|
|
awayTeam: true,
|
|
},
|
|
});
|
|
|
|
if (match) return match;
|
|
|
|
const liveMatch = await this.prisma.liveMatch.findUnique({
|
|
where: { id: matchId },
|
|
include: {
|
|
homeTeam: true,
|
|
awayTeam: true,
|
|
league: true,
|
|
},
|
|
});
|
|
|
|
if (liveMatch) {
|
|
return {
|
|
...liveMatch,
|
|
leagueId: liveMatch.leagueId,
|
|
homeTeamId: liveMatch.homeTeamId,
|
|
awayTeamId: liveMatch.awayTeamId,
|
|
scoreHome: liveMatch.scoreHome,
|
|
scoreAway: liveMatch.scoreAway,
|
|
mstUtc: liveMatch.mstUtc,
|
|
sport: liveMatch.sport || "football",
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async getPlayerCount(matchId: string): Promise<number> {
|
|
const relationalCount = await this.prisma.matchPlayerParticipation.count({
|
|
where: { matchId },
|
|
});
|
|
|
|
if (relationalCount > 0) return relationalCount;
|
|
|
|
const liveMatch = await this.prisma.liveMatch.findUnique({
|
|
where: { id: matchId },
|
|
select: { lineups: true },
|
|
});
|
|
|
|
if (liveMatch?.lineups) {
|
|
try {
|
|
const lineups = liveMatch.lineups as any;
|
|
const homeXi = lineups.home?.xi?.length || 0;
|
|
const awayXi = lineups.away?.xi?.length || 0;
|
|
return homeXi + awayXi;
|
|
} catch (e) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
// ============================================
|
|
// STATE MANAGEMENT
|
|
// ============================================
|
|
async getState(key: string): Promise<string | null> {
|
|
const setting = await this.prisma.appSetting.findUnique({
|
|
where: { key },
|
|
});
|
|
return setting?.value || null;
|
|
}
|
|
|
|
async setState(key: string, value: string): Promise<void> {
|
|
await this.prisma.appSetting.upsert({
|
|
where: { key },
|
|
update: { value, updatedAt: new Date() },
|
|
create: { key, value },
|
|
});
|
|
}
|
|
}
|