/* 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"; 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 { 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, participationData: MatchParticipation[], eventData: DbEventPayload[], stats: TransformedMatchStats | null, basketballTeamStats: BasketballTeamStats | null, basketballPlayerStats: Partial[], oddsArray: DbMarketPayload[], officialsData: MatchOfficial[], ): Promise { // START IMAGE DOWNLOADS (NON-BLOCKING) const imageDownloads: Promise[] = []; 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); 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(); 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, participationData: MatchParticipation[], homeTeamId: string, awayTeamId: string, ): Promise { 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 { 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> = {}; 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 { const match = await this.prisma.match.findUnique({ where: { id: matchId }, select: { id: true }, }); return !!match; } async getExistingMatchIds(matchIds: string[]): Promise { const matches = await this.prisma.match.findMany({ where: { id: { in: matchIds }, oddCategories: { some: {} }, OR: [ { sport: "football", footballTeamStats: { some: {} }, playerParticipations: { some: { isStarting: true } }, }, { sport: "basketball", basketballTeamStats: { some: {} }, basketballPlayerStats: { some: {} }, }, ], }, select: { id: true, sport: true }, }); const footballIds = matches .filter((m) => m.sport === "football") .map((m) => m.id); const completeFootballIds = new Set(); if (footballIds.length > 0) { const starterCounts = await this.prisma.matchPlayerParticipation.groupBy({ by: ["matchId"], where: { matchId: { in: footballIds }, isStarting: true, }, _count: { _all: true }, }); for (const row of starterCounts) { if (row._count._all >= 18) completeFootballIds.add(row.matchId); } } return matches .filter((m) => m.sport !== "football" || completeFootballIds.has(m.id)) .map((m) => m.id); } async hasOdds(matchId: string): Promise { 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 { 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 { 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 { const setting = await this.prisma.appSetting.findUnique({ where: { key }, }); return setting?.value || null; } async setState(key: string, value: string): Promise { await this.prisma.appSetting.upsert({ where: { key }, update: { value, updatedAt: new Date() }, create: { key, value }, }); } }