import { Injectable, } from '@nestjs/common'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { MarketsService } from '../odds/markets.service'; import { WC2026_LEAGUE_CODE, WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams'; import { syncWc2026OutrightMarket } from './wc2026-outright.sync'; import { appBadRequest, appNotFound } from '../../shared/common/app-error'; const PLACEHOLDER_TEAM_CODE = 'OUT'; const OUTRIGHT_MARKET_TYPE = 'OUTRIGHT_WINNER'; @Injectable() export class OutrightService { constructor( private prisma: PrismaService, private markets: MarketsService, ) {} async listForAdmin() { const matches = await this.prisma.match.findMany({ where: { isOutright: true, sportType: 'FOOTBALL', deletedAt: null }, include: { league: true, markets: { where: { marketType: OUTRIGHT_MARKET_TYPE }, select: { id: true, status: true, _count: { select: { selections: { where: { status: { not: 'CLOSED' }, selectionCode: { not: PLACEHOLDER_TEAM_CODE }, }, }, }, }, selections: { where: { status: 'OPEN', selectionCode: { not: PLACEHOLDER_TEAM_CODE }, }, select: { id: true }, take: 1, }, }, }, }, orderBy: [{ displayOrder: 'asc' }, { startTime: 'asc' }], }); return Promise.all( matches.map(async (m) => { const league = m.league; const leagueCode = league.code; const [leagueZh, leagueEn] = await Promise.all([ this.getTranslation('LEAGUE', league.id, 'zh-CN'), this.getTranslation('LEAGUE', league.id, 'en-US'), ]); const market = m.markets[0]; const openCount = market?.selections.length ?? 0; const selectionCount = market?._count.selections ?? 0; const visibility = this.playerVisibilityByCounts( m.status, market, openCount, league.isActive, ); const [titleZh, titleEn, titleMs] = await Promise.all([ this.getOutrightTitle(m.id, 'zh-CN'), this.getOutrightTitle(m.id, 'en-US'), this.getOutrightTitle(m.id, 'ms-MY'), ]); return { id: m.id.toString(), leagueId: league.id.toString(), leagueCode, leagueZh, leagueEn, matchName: m.matchName ?? '', titleZh: titleZh || m.matchName || '', titleEn: titleEn || m.matchName || '', titleMs, status: m.status, selectionCount, canImportCanonical: leagueCode === WC2026_LEAGUE_CODE, playerVisible: visibility.playerVisible, playerHiddenReason: visibility.playerHiddenReason, }; }), ); } async listLeagueOptions() { const leagues = await this.prisma.league.findMany({ where: { isActive: true, deletedAt: null, sportType: 'FOOTBALL' }, orderBy: { displayOrder: 'asc' }, }); return Promise.all( leagues.map(async (l) => ({ id: l.id.toString(), code: l.code, nameZh: await this.getTranslation('LEAGUE', l.id, 'zh-CN'), nameEn: await this.getTranslation('LEAGUE', l.id, 'en-US'), })), ); } async getForAdmin(matchId: bigint) { const match = await this.getOutrightMatchOrThrow(matchId); const market = await this.ensureOutrightMarket(match.id); const league = await this.prisma.league.findUniqueOrThrow({ where: { id: match.leagueId }, }); const [leagueZh, leagueEn] = await Promise.all([ this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'), this.getTranslation('LEAGUE', match.leagueId, 'en-US'), ]); const fullMarket = await this.prisma.market.findUniqueOrThrow({ where: { id: market.id }, include: { selections: { orderBy: { sortOrder: 'asc' } }, }, }); const selections = await Promise.all( fullMarket.selections .filter((s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE) .map(async (sel, index) => { const team = await this.prisma.team.findUnique({ where: { code: sel.selectionCode }, }); const teamZh = team ? await this.getTranslation('TEAM', team.id, 'zh-CN') : sel.selectionName; const teamEn = team ? await this.getTranslation('TEAM', team.id, 'en-US') : sel.selectionName; return { id: sel.id.toString(), teamId: team?.id.toString() ?? null, teamCode: sel.selectionCode, rank: sel.sortOrder + 1 || index + 1, teamZh: teamZh || sel.selectionName, teamEn: teamEn || sel.selectionName, logoUrl: team?.logoUrl ?? null, odds: sel.odds.toString(), oddsVersion: sel.oddsVersion.toString(), status: sel.status, }; }), ); const visibility = this.playerVisibility( match.status, fullMarket, fullMarket.selections.filter( (s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE, ), league.isActive, ); const unsettledFixtureCount = await this.countUnsettledLeagueFixtures( match.leagueId, ); const [titleZh, titleEn, titleMs] = await Promise.all([ this.getOutrightTitle(match.id, 'zh-CN'), this.getOutrightTitle(match.id, 'en-US'), this.getOutrightTitle(match.id, 'ms-MY'), ]); return { id: match.id.toString(), leagueId: match.leagueId.toString(), leagueCode: league.code, leagueZh, leagueEn, matchName: match.matchName ?? '', titleZh: titleZh || match.matchName || '', titleEn: titleEn || match.matchName || '', titleMs, status: match.status, leagueIsPublished: league.isActive, unsettledFixtureCount, marketId: fullMarket.id.toString(), marketStatus: fullMarket.status, canImportCanonical: league.code === WC2026_LEAGUE_CODE, expectedCanonicalCount: league.code === WC2026_LEAGUE_CODE ? WC2026_OUTRIGHT_TEAMS.length : null, playerVisible: visibility.playerVisible, playerHiddenReason: visibility.playerHiddenReason, selections, }; } /** 按联赛获取或创建冠军盘,并从单场赛程同步参赛队伍 */ async getOrCreateAndSyncForLeague(leagueId: bigint) { const league = await this.prisma.league.findUnique({ where: { id: leagueId }, }); if (!league) throw appNotFound('LEAGUE_NOT_FOUND'); let match = await this.prisma.match.findFirst({ where: { leagueId, isOutright: true, deletedAt: null }, orderBy: { id: 'asc' }, }); if (!match) { const [leagueZh, leagueEn, leagueMs] = await Promise.all([ this.getTranslation('LEAGUE', leagueId, 'zh-CN'), this.getTranslation('LEAGUE', leagueId, 'en-US'), this.getTranslation('LEAGUE', leagueId, 'ms-MY'), ]); await this.createForAdmin({ leagueId, titleZh: leagueZh || league.code, titleEn: leagueEn || league.code, titleMs: leagueMs || undefined, status: league.isActive ? 'PUBLISHED' : 'DRAFT', }); match = await this.prisma.match.findFirstOrThrow({ where: { leagueId, isOutright: true, deletedAt: null }, orderBy: { id: 'asc' }, }); } else { await this.syncOutrightStatusWithLeague(match, league); match = await this.prisma.match.findFirstOrThrow({ where: { id: match.id }, }); } const sync = await this.syncSelectionsFromLeagueFixtures(match.id); const data = await this.getForAdmin(match.id); return { ...data, fixtureSyncAdded: sync.addedCount, fixtureSyncReopened: sync.reopenedCount, }; } /** 联赛发布后同步冠军盘状态(随联赛发布,无需单独发布) */ async syncWithLeaguePublished(leagueId: bigint) { const league = await this.prisma.league.findUnique({ where: { id: leagueId }, }); if (!league?.isActive) return; const existing = await this.prisma.match.findFirst({ where: { leagueId, isOutright: true, deletedAt: null }, orderBy: { id: 'asc' }, }); if (!existing) { await this.getOrCreateAndSyncForLeague(leagueId); return; } await this.syncOutrightStatusWithLeague(existing, league); } /** 联赛下尚未结算/取消的单场数量(不含冠军盘) */ async countUnsettledLeagueFixtures(leagueId: bigint): Promise { return this.prisma.match.count({ where: { leagueId, isOutright: false, deletedAt: null, status: { notIn: ['SETTLED', 'CANCELLED'] }, }, }); } private async syncOutrightStatusWithLeague( match: { id: bigint; status: string }, league: { isActive: boolean }, ) { if (!league.isActive || match.status !== 'DRAFT') return; await this.prisma.match.update({ where: { id: match.id }, data: { status: 'PUBLISHED', publishTime: new Date() }, }); } /** 若联赛已有冠军盘,则从单场同步球队(不自动创建冠军盘) */ async syncOutrightTeamsForLeagueIfExists(leagueId: bigint) { const match = await this.prisma.match.findFirst({ where: { leagueId, isOutright: true, deletedAt: null }, orderBy: { id: 'asc' }, }); if (!match) return { addedCount: 0, reopenedCount: 0 }; return this.syncSelectionsFromLeagueFixtures(match.id); } async syncSelectionsFromLeagueFixtures(matchId: bigint) { const match = await this.getOutrightMatchOrThrow(matchId); const market = await this.ensureOutrightMarket(match.id); const teams = await this.collectFixtureTeamsForLeague(match.leagueId); const fixtureCodes = new Set(teams.map((t) => t.code)); const existing = await this.prisma.marketSelection.findMany({ where: { marketId: market.id, selectionCode: { not: PLACEHOLDER_TEAM_CODE }, }, }); const existingCodes = new Set(existing.map((s) => s.selectionCode)); let sortOrder = existing.reduce((max, s) => Math.max(max, s.sortOrder), -1); let addedCount = 0; let reopenedCount = 0; for (const sel of existing) { if (fixtureCodes.has(sel.selectionCode) && sel.status === 'CLOSED') { await this.prisma.marketSelection.update({ where: { id: sel.id }, data: { status: 'OPEN' }, }); reopenedCount += 1; } } for (const team of teams) { if (existingCodes.has(team.code)) continue; const [teamZh, teamEn] = await Promise.all([ this.getTranslation('TEAM', team.id, 'zh-CN'), this.getTranslation('TEAM', team.id, 'en-US'), ]); sortOrder += 1; await this.prisma.marketSelection.create({ data: { marketId: market.id, selectionCode: team.code, selectionName: teamZh || teamEn || team.code, odds: 10, sortOrder, status: 'OPEN', }, }); addedCount += 1; } return { addedCount, reopenedCount }; } private async collectFixtureTeamsForLeague(leagueId: bigint) { const matches = await this.prisma.match.findMany({ where: { leagueId, isOutright: false, deletedAt: null }, select: { homeTeamId: true, awayTeamId: true }, }); const teamIds = [ ...new Set(matches.flatMap((m) => [m.homeTeamId, m.awayTeamId])), ]; if (teamIds.length === 0) return []; return this.prisma.team.findMany({ where: { id: { in: teamIds }, code: { not: PLACEHOLDER_TEAM_CODE }, }, orderBy: { code: 'asc' }, }); } async createForAdmin(data: { leagueId: bigint; titleZh: string; titleEn: string; titleMs?: string; status?: string; isHot?: boolean; displayOrder?: number; startTime?: Date; }) { const league = await this.prisma.league.findUnique({ where: { id: data.leagueId }, }); if (!league) throw appNotFound('LEAGUE_NOT_FOUND'); const placeholder = await this.ensurePlaceholderTeam(); const status = data.status ?? 'PUBLISHED'; const matchName = this.resolveOutrightMatchName(data); const match = await this.prisma.match.create({ data: { leagueId: data.leagueId, homeTeamId: placeholder.id, awayTeamId: placeholder.id, isOutright: true, matchName, startTime: data.startTime ?? new Date('2030-01-01T00:00:00Z'), status, publishTime: status === 'PUBLISHED' ? new Date() : undefined, isHot: data.isHot ?? false, displayOrder: data.displayOrder ?? 0, }, }); await this.upsertOutrightTitles(match.id, { zh: data.titleZh, en: data.titleEn, ms: data.titleMs, }); await this.ensureOutrightMarket(match.id); return this.getForAdmin(match.id); } async updateForAdmin( matchId: bigint, data: { status?: string; matchName?: string; isHot?: boolean; displayOrder?: number; titleZh?: string; titleEn?: string; titleMs?: string; }, ) { const match = await this.getOutrightMatchOrThrow(matchId); const status = data.status ?? match.status; let matchName = data.matchName?.trim(); const titlesTouched = data.titleZh !== undefined || data.titleEn !== undefined || data.titleMs !== undefined; if (titlesTouched) { const [curZh, curEn, curMs] = await Promise.all([ this.getOutrightTitle(matchId, 'zh-CN'), this.getOutrightTitle(matchId, 'en-US'), this.getOutrightTitle(matchId, 'ms-MY'), ]); const zh = data.titleZh !== undefined ? data.titleZh : curZh; const en = data.titleEn !== undefined ? data.titleEn : curEn; const ms = data.titleMs !== undefined ? data.titleMs : curMs; await this.upsertOutrightTitles(matchId, { zh, en, ms }); matchName = this.resolveOutrightMatchName({ titleZh: zh, titleEn: en, titleMs: ms }); } await this.prisma.match.update({ where: { id: matchId }, data: { status, matchName: matchName !== undefined ? matchName : undefined, isHot: data.isHot, displayOrder: data.displayOrder, publishTime: status === 'PUBLISHED' && !match.publishTime ? new Date() : match.publishTime, }, }); return this.getForAdmin(matchId); } async addSelection( matchId: bigint, data: { teamCode: string; teamZh: string; teamEn: string; odds: number; logoUrl?: string; }, ) { if (!data.teamCode?.trim()) { throw appBadRequest('TEAM_CODE_REQUIRED'); } if (data.odds <= 1) { throw appBadRequest('ODDS_MIN'); } const match = await this.getOutrightMatchOrThrow(matchId); const market = await this.ensureOutrightMarket(match.id); const code = data.teamCode.trim().toUpperCase(); const logoUrl = data.logoUrl === undefined ? undefined : data.logoUrl.trim() ? data.logoUrl.trim() : null; const team = await this.prisma.team.upsert({ where: { code }, create: { code, ...(logoUrl !== undefined ? { logoUrl } : {}), }, update: logoUrl !== undefined ? { logoUrl } : {}, }); await this.upsertTeamTranslations(team.id, { 'zh-CN': data.teamZh.trim() || data.teamEn, 'en-US': data.teamEn.trim() || data.teamZh, }); const existing = await this.prisma.marketSelection.findFirst({ where: { marketId: market.id, selectionCode: code }, }); if (existing) { if (existing.status === 'CLOSED') { await this.prisma.marketSelection.update({ where: { id: existing.id }, data: { status: 'OPEN', odds: data.odds, selectionName: data.teamZh.trim() || data.teamEn, }, }); return this.getForAdmin(matchId); } throw appBadRequest('OUTRIGHT_SELECTION_EXISTS'); } const maxSort = await this.prisma.marketSelection.aggregate({ where: { marketId: market.id }, _max: { sortOrder: true }, }); await this.prisma.marketSelection.create({ data: { marketId: market.id, selectionCode: code, selectionName: data.teamZh.trim() || data.teamEn, odds: data.odds, sortOrder: (maxSort._max.sortOrder ?? -1) + 1, status: 'OPEN', }, }); return this.getForAdmin(matchId); } async addSelectionsBatch( matchId: bigint, items: Array<{ teamCode: string; teamZh: string; teamEn: string; odds: number; logoUrl?: string; }>, ) { if (!items.length) { throw appBadRequest('OUTRIGHT_TEAMS_REQUIRED'); } let added = 0; let skipped = 0; for (const item of items) { try { await this.addSelection(matchId, item); added += 1; } catch (e) { const msg = e instanceof Error ? e.message : ''; if (msg.includes('already exists')) { skipped += 1; continue; } throw e; } } const data = await this.getForAdmin(matchId); return { ...data, batchResult: { added, skipped } }; } async updateSelectionTeam( matchId: bigint, selectionId: bigint, data: { teamCode?: string; teamZh?: string; teamEn?: string; logoUrl?: string | null; }, ) { const match = await this.getOutrightMatchOrThrow(matchId); const market = await this.ensureOutrightMarket(match.id); const sel = await this.prisma.marketSelection.findFirst({ where: { id: selectionId, marketId: market.id }, }); if (!sel) throw appNotFound('SELECTION_NOT_FOUND'); const nextCode = data.teamCode?.trim().toUpperCase() || sel.selectionCode; if (nextCode !== sel.selectionCode) { const dup = await this.prisma.marketSelection.findFirst({ where: { marketId: market.id, selectionCode: nextCode, id: { not: selectionId }, }, }); if (dup) { throw appBadRequest('OUTRIGHT_SELECTION_EXISTS'); } await this.prisma.marketSelection.update({ where: { id: selectionId }, data: { selectionCode: nextCode, selectionName: data.teamZh?.trim() || data.teamEn?.trim() || sel.selectionName, }, }); } else if (data.teamZh?.trim() || data.teamEn?.trim()) { await this.prisma.marketSelection.update({ where: { id: selectionId }, data: { selectionName: data.teamZh?.trim() || data.teamEn?.trim() || sel.selectionName, }, }); } const team = await this.prisma.team.upsert({ where: { code: nextCode }, create: { code: nextCode }, update: {}, }); if (data.teamZh !== undefined || data.teamEn !== undefined) { await this.upsertTeamTranslations(team.id, { 'zh-CN': data.teamZh?.trim() || data.teamEn?.trim() || nextCode, 'en-US': data.teamEn?.trim() || data.teamZh?.trim() || nextCode, }); } if (data.logoUrl !== undefined) { const logoUrl = data.logoUrl?.trim() ? data.logoUrl.trim() : null; await this.prisma.team.update({ where: { id: team.id }, data: { logoUrl }, }); } return this.getForAdmin(matchId); } async closeSelection(matchId: bigint, selectionId: bigint) { const match = await this.getOutrightMatchOrThrow(matchId); const market = await this.ensureOutrightMarket(match.id); const sel = await this.prisma.marketSelection.findFirst({ where: { id: selectionId, marketId: market.id }, }); if (!sel) throw appNotFound('SELECTION_NOT_FOUND'); await this.prisma.marketSelection.update({ where: { id: selectionId }, data: { status: 'CLOSED' }, }); return this.getForAdmin(matchId); } async batchUpdateOdds( matchId: bigint, updates: Array<{ selectionId: string; odds: number }>, operatorId: bigint, ) { await this.getOutrightMatchOrThrow(matchId); const market = await this.ensureOutrightMarket(matchId); const allowed = new Set( ( await this.prisma.marketSelection.findMany({ where: { marketId: market.id }, select: { id: true }, }) ).map((s) => s.id.toString()), ); for (const u of updates) { if (!allowed.has(u.selectionId)) { throw appBadRequest('OUTRIGHT_SELECTION_INVALID'); } } await this.markets.batchUpdateOdds( updates.map((u) => ({ selectionId: BigInt(u.selectionId), odds: u.odds })), operatorId, ); return this.getForAdmin(matchId); } async importWc2026Canonical() { const { matchId } = await syncWc2026OutrightMarket(this.prisma, { forceCanonical: true, }); return this.getForAdmin(matchId); } async listForPlayer(locale: string) { await this.trySyncWc2026(); const matches = await this.prisma.match.findMany({ where: { status: 'PUBLISHED', isOutright: true, sportType: 'FOOTBALL', deletedAt: null, league: { isActive: true, deletedAt: null }, }, include: { markets: { where: { marketType: OUTRIGHT_MARKET_TYPE, status: 'OPEN' }, include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' }, }, }, }, }, orderBy: [{ displayOrder: 'asc' }, { startTime: 'asc' }], }); const results = []; for (const match of matches) { const league = await this.prisma.league.findUniqueOrThrow({ where: { id: match.leagueId }, }); const leagueName = await this.getTranslation('LEAGUE', match.leagueId, locale); const market = match.markets[0]; if (!market) continue; const selections = await Promise.all( market.selections .filter((sel) => sel.selectionCode !== PLACEHOLDER_TEAM_CODE) .map(async (sel) => { const team = await this.prisma.team.findUnique({ where: { code: sel.selectionCode }, }); const translated = team ? await this.getTranslation('TEAM', team.id, locale) : ''; const teamZh = team ? await this.getTranslation('TEAM', team.id, 'zh-CN') : sel.selectionName; const teamEn = team ? await this.getTranslation('TEAM', team.id, 'en-US') : sel.selectionName; const teamName = translated || (locale.startsWith('zh') ? teamZh : teamEn) || sel.selectionName; return { id: sel.id.toString(), teamCode: sel.selectionCode, teamName, logoUrl: team?.logoUrl ?? null, odds: sel.odds.toString(), oddsVersion: sel.oddsVersion.toString(), }; }), ); if (!selections.length) continue; const [titleZh, titleEn, titleMs] = await Promise.all([ this.getOutrightTitle(match.id, 'zh-CN'), this.getOutrightTitle(match.id, 'en-US'), this.getOutrightTitle(match.id, 'ms-MY'), ]); const localizedTitle = this.pickOutrightTitleForLocale(locale, { zh: titleZh, en: titleEn, ms: titleMs, }); const title = localizedTitle || match.matchName?.trim() || `*${leagueName || 'Outright'} ${locale.startsWith('zh') ? '冠军' : 'Winner'}`; results.push({ id: match.id.toString(), leagueId: match.leagueId.toString(), leagueCode: league.code, leagueName: leagueName || '', title: title.startsWith('*') ? title : `*${title}`, marketId: market.id.toString(), selectionCount: selections.length, selections, }); } return results; } /** @deprecated 使用 listForPlayer */ async getWc2026ForPlayer(locale: string) { return this.listForPlayer(locale); } private async trySyncWc2026() { try { await syncWc2026OutrightMarket(this.prisma, { forceCanonical: false }); } catch { /* 联赛未 seed 时忽略 */ } } private async getOutrightMatchOrThrow(matchId: bigint) { const match = await this.prisma.match.findFirst({ where: { id: matchId, isOutright: true, deletedAt: null }, }); if (!match) throw appNotFound('OUTRIGHT_EVENT_NOT_FOUND'); return match; } private async ensureOutrightMarket(matchId: bigint) { let market = await this.prisma.market.findFirst({ where: { matchId, marketType: OUTRIGHT_MARKET_TYPE }, }); if (!market) { market = await this.prisma.market.create({ data: { matchId, marketType: OUTRIGHT_MARKET_TYPE, period: 'OUTRIGHT', allowSingle: true, allowParlay: false, sortOrder: 1, status: 'OPEN', }, }); } return market; } private async ensurePlaceholderTeam() { const existing = await this.prisma.team.findUnique({ where: { code: PLACEHOLDER_TEAM_CODE }, }); if (existing) return existing; return this.prisma.team.create({ data: { code: PLACEHOLDER_TEAM_CODE }, }); } private async upsertTeamTranslations( teamId: bigint, names: Record, ) { for (const [locale, value] of Object.entries(names)) { if (!value) continue; await this.prisma.entityTranslation.upsert({ where: { entityType_entityId_locale_fieldName: { entityType: 'TEAM', entityId: teamId, locale, fieldName: 'name', }, }, create: { entityType: 'TEAM', entityId: teamId, locale, fieldName: 'name', value, }, update: { value }, }); } } private playerVisibility( matchStatus: string, market: { status: string } | null | undefined, selections: Array<{ selectionCode: string; status: string }>, leagueIsActive = true, ): { playerVisible: boolean; playerHiddenReason: string | null } { const openCount = selections.filter( (s) => s.status === 'OPEN' && s.selectionCode !== PLACEHOLDER_TEAM_CODE, ).length; return this.playerVisibilityByCounts( matchStatus, market, openCount, leagueIsActive, ); } private playerVisibilityByCounts( matchStatus: string, market: { status: string } | null | undefined, openSelectionCount: number, leagueIsActive = true, ): { playerVisible: boolean; playerHiddenReason: string | null } { if (!leagueIsActive) { return { playerVisible: false, playerHiddenReason: 'LEAGUE_INACTIVE' }; } if (matchStatus !== 'PUBLISHED') { return { playerVisible: false, playerHiddenReason: 'NOT_PUBLISHED' }; } if (!market || market.status !== 'OPEN') { return { playerVisible: false, playerHiddenReason: 'MARKET_CLOSED' }; } if (openSelectionCount === 0) { return { playerVisible: false, playerHiddenReason: 'NO_SELECTIONS' }; } return { playerVisible: true, playerHiddenReason: null }; } private async getTranslation( entityType: string, entityId: bigint, locale: string, ): Promise { const row = await this.prisma.entityTranslation.findFirst({ where: { entityType, entityId, locale, fieldName: 'name' }, }); return row?.value ?? ''; } private async getOutrightTitle(matchId: bigint, locale: string): Promise { const row = await this.prisma.entityTranslation.findFirst({ where: { entityType: 'MATCH', entityId: matchId, locale, fieldName: 'title', }, }); return row?.value?.trim() ?? ''; } private resolveOutrightMatchName(data: { titleZh?: string; titleEn?: string; titleMs?: string; }): string { return ( data.titleEn?.trim() || data.titleZh?.trim() || data.titleMs?.trim() || 'Outright' ); } private pickOutrightTitleForLocale( locale: string, titles: { zh: string; en: string; ms: string }, ): string { if (locale === 'ms-MY' || locale.startsWith('ms')) { return titles.ms || titles.en || titles.zh; } if (locale.startsWith('zh')) { return titles.zh || titles.en || titles.ms; } return titles.en || titles.zh || titles.ms; } private async upsertOutrightTitles( matchId: bigint, titles: { zh?: string; en?: string; ms?: string }, ) { const entries: Array<[string, string | undefined]> = [ ['zh-CN', titles.zh], ['en-US', titles.en], ['ms-MY', titles.ms], ]; for (const [locale, raw] of entries) { if (raw === undefined) continue; const value = raw.trim(); if (!value) continue; await this.prisma.entityTranslation.upsert({ where: { entityType_entityId_locale_fieldName: { entityType: 'MATCH', entityId: matchId, locale, fieldName: 'title', }, }, create: { entityType: 'MATCH', entityId: matchId, locale, fieldName: 'title', value, }, update: { value }, }); } } }