This commit is contained in:
+987
@@ -0,0 +1,987 @@
|
||||
/* 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 { 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';
|
||||
|
||||
@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,
|
||||
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/areas/${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;
|
||||
} 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`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
let status = 'NS';
|
||||
if (headerData?.matchStatus) {
|
||||
if (
|
||||
headerData.matchStatus === 'postGame' ||
|
||||
headerData.matchStatus === 'post'
|
||||
) {
|
||||
status = 'FT';
|
||||
} else if (
|
||||
headerData.matchStatus === 'live' ||
|
||||
headerData.matchStatus === 'liveGame'
|
||||
) {
|
||||
status = 'LIVE';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Postponed Matches (ERT)
|
||||
if (matchSummary.statusBoxContent === 'ERT') {
|
||||
status = 'POSTPONED';
|
||||
}
|
||||
|
||||
if (
|
||||
status === 'NS' &&
|
||||
finalScoreHome !== null &&
|
||||
finalScoreAway !== null
|
||||
) {
|
||||
status = 'FT';
|
||||
}
|
||||
|
||||
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[]> {
|
||||
// Only consider matches "existing" if they have ALL key data points
|
||||
// This allows re-fetching matches that exist but have missing data
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
id: { in: matchIds },
|
||||
AND: [
|
||||
{ oddCategories: { some: {} } },
|
||||
{ playerEvents: { some: {} } },
|
||||
{ officials: { some: {} } },
|
||||
{
|
||||
OR: [
|
||||
{ footballTeamStats: { some: {} } },
|
||||
{ basketballTeamStats: { some: {} } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return matches.map((m) => m.id);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user