first (part 3: src directory)
Deploy Iddaai Backend / build-and-deploy (push) Successful in 33s

This commit is contained in:
2026-04-16 15:12:27 +03:00
parent 2f0b85a0c7
commit 182f4aae16
125 changed files with 22552 additions and 0 deletions
+987
View File
@@ -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 },
});
}
}