Files
iddaai-be/src/modules/feeder/feeder-persistence.service.ts
T
fahricansecer 9481ad7094
Deploy Iddaai Backend / build-and-deploy (push) Successful in 42s
changes
2026-05-20 10:10:28 +03:00

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 },
});
}
}