gg
Deploy Iddaai Backend / build-and-deploy (push) Successful in 1m0s

This commit is contained in:
2026-06-10 14:05:20 +03:00
parent b62a4f2161
commit e0fbde2fde
9 changed files with 89 additions and 144 deletions
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Warms the R2 image bucket by requesting every known team / competition /
# country image once through the image-proxy Worker, which mirrors each one
# into R2 (see workers/image-proxy).
#
# Run on the production server (needs docker access to iddaai-postgres):
# ./warm-image-cache.sh https://files.example.com
set -euo pipefail
BASE_URL="${1:?Usage: $0 <image-base-url>}"
BASE_URL="${BASE_URL%/}"
PSQL=(docker exec iddaai-postgres psql -U iddaai_user -d iddaai_db -At -c)
{
"${PSQL[@]}" "SELECT 'teams/' || id FROM teams"
"${PSQL[@]}" "SELECT 'competitions/' || id FROM leagues"
"${PSQL[@]}" "SELECT 'areas/' || id FROM countries"
} | xargs -P 8 -I{} curl -sS -o /dev/null -w "%{http_code}\n" "$BASE_URL/{}" \
| sort | uniq -c \
| awk '{printf "HTTP %s: %s istek\n", $2, $1}'
+35
View File
@@ -0,0 +1,35 @@
/**
* Central builder for team / competition / country image URLs.
*
* Images are served from a Cloudflare R2 bucket fronted by the
* `workers/image-proxy` Worker, which lazily mirrors each image from the
* upstream provider (file.mackolikfeeds.com) into R2 on first request.
*
* Set IMAGE_BASE_URL (no trailing slash, e.g. https://files.example.com)
* to serve from the Worker. When unset, URLs point directly at the
* upstream provider so behaviour is unchanged until the bucket is live.
*/
const DEFAULT_IMAGE_BASE_URL = "https://file.mackolikfeeds.com";
function imageBaseUrl(): string {
return (
process.env.IMAGE_BASE_URL?.replace(/\/+$/, "") || DEFAULT_IMAGE_BASE_URL
);
}
export function teamLogoUrl(teamId?: string | null): string | undefined {
if (!teamId) return undefined;
return `${imageBaseUrl()}/teams/${teamId}`;
}
export function competitionLogoUrl(
competitionId?: string | null,
): string | undefined {
if (!competitionId) return undefined;
return `${imageBaseUrl()}/competitions/${competitionId}`;
}
export function countryFlagUrl(countryId?: string | null): string | undefined {
if (!countryId) return undefined;
return `${imageBaseUrl()}/areas/${countryId}`;
}
-59
View File
@@ -1,59 +0,0 @@
import { existsSync, createWriteStream, mkdirSync } from "fs";
import { dirname } from "path";
import axios from "axios";
import { Logger } from "@nestjs/common";
export class ImageUtils {
private static readonly logger = new Logger("ImageUtils");
/**
* Downloads an image from a URL and saves it to a local path.
* Skips download if file already exists.
*/
static async downloadImage(url: string, localPath: string): Promise<boolean> {
try {
// Check if file exists
if (existsSync(localPath)) {
return true;
}
// Ensure directory exists
const dir = dirname(localPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Download
const response = await axios({
url,
method: "GET",
responseType: "stream",
timeout: 5000,
validateStatus: (status) => status === 200, // Only save if 200 OK
});
const writer = createWriteStream(localPath);
response.data.pipe(writer);
return new Promise((resolve, reject) => {
writer.on("finish", () => resolve(true));
writer.on("error", (err) => {
this.logger.warn(
`Failed to write image to ${localPath}: ${err.message}`,
);
reject(new Error(`Failed to write image to ${localPath}`));
});
});
} catch (error: any) {
// Log warning but don't break the application
// 404s are common for missing logos
if (error.response?.status !== 404) {
this.logger.warn(
`Failed to download image from ${url}: ${error.message}`,
);
}
return false;
}
}
}
@@ -22,7 +22,6 @@ import {
DbMarketPayload, DbMarketPayload,
BasketballTeamStats, BasketballTeamStats,
} from "./feeder.types"; } from "./feeder.types";
import { ImageUtils } from "../../common/utils/image.util";
import { deriveStoredMatchStatus } from "../../common/utils/match-status.util"; import { deriveStoredMatchStatus } from "../../common/utils/match-status.util";
@Injectable() @Injectable()
@@ -164,24 +163,6 @@ export class FeederPersistenceService {
oddsArray: DbMarketPayload[], oddsArray: DbMarketPayload[],
officialsData: MatchOfficial[], officialsData: MatchOfficial[],
): Promise<boolean> { ): 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 = [ const teamsToUpsert = [
{ {
id: homeTeamId, id: homeTeamId,
@@ -197,20 +178,6 @@ export class FeederPersistenceService {
}, },
]; ];
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 // DATABASE TRANSACTION
try { try {
await this.prisma.$transaction( await this.prisma.$transaction(
@@ -264,7 +231,6 @@ export class FeederPersistenceService {
countryId: countryId, countryId: countryId,
sport: sport, sport: sport,
competitionSlug: league.competitionSlug, competitionSlug: league.competitionSlug,
logoUrl: `/uploads/competitions/${finalLeagueId}.png`,
} as any, } as any,
}); });
if (league.sortOrder !== undefined) { if (league.sortOrder !== undefined) {
@@ -291,10 +257,7 @@ export class FeederPersistenceService {
if (teamsToCreate.length > 0) { if (teamsToCreate.length > 0) {
await tx.team.createMany({ await tx.team.createMany({
data: teamsToCreate.map((t) => ({ data: teamsToCreate,
...t,
logoUrl: `/uploads/teams/${t.id}.png`,
})),
skipDuplicates: true, skipDuplicates: true,
}); });
} }
@@ -304,7 +267,6 @@ export class FeederPersistenceService {
where: { id: team.id }, where: { id: team.id },
data: { data: {
name: team.name, name: team.name,
logoUrl: `/uploads/teams/${team.id}.png`,
}, },
}); });
} }
@@ -614,9 +576,6 @@ export class FeederPersistenceService {
{ maxWait: 40000, timeout: 40000 }, { maxWait: 40000, timeout: 40000 },
); );
// WAIT FOR IMAGES AFTER TRANSACTION
await Promise.allSettled(imageDownloads);
this.logger.log(`✅ SAVED: [${matchId}] ${matchSummary.matchName}`); this.logger.log(`✅ SAVED: [${matchId}] ${matchSummary.matchName}`);
return true; return true;
} catch (error: any) { } catch (error: any) {
+16 -11
View File
@@ -1,6 +1,10 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service"; import { PrismaService } from "../../database/prisma.service";
import { Sport } from "@prisma/client"; import { Sport } from "@prisma/client";
import {
countryFlagUrl,
teamLogoUrl,
} from "../../common/utils/image-url.util";
@Injectable() @Injectable()
export class LeaguesService { export class LeaguesService {
@@ -12,19 +16,21 @@ export class LeaguesService {
* Get all countries * Get all countries
*/ */
async findAllCountries() { async findAllCountries() {
return this.prisma.country.findMany({ const countries = await this.prisma.country.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
return countries.map((c) => ({ ...c, flag: countryFlagUrl(c.id) }));
} }
/** /**
* Get country by ID * Get country by ID
*/ */
async findCountryById(id: string) { async findCountryById(id: string) {
return this.prisma.country.findUnique({ const country = await this.prisma.country.findUnique({
where: { id }, where: { id },
include: { leagues: true }, include: { leagues: true },
}); });
return country ? { ...country, flag: countryFlagUrl(country.id) } : null;
} }
/** /**
@@ -66,7 +72,7 @@ export class LeaguesService {
* Get all teams * Get all teams
*/ */
async findAllTeams(sport?: Sport, search?: string) { async findAllTeams(sport?: Sport, search?: string) {
return this.prisma.team.findMany({ const teams = await this.prisma.team.findMany({
where: { where: {
...(sport ? { sport } : {}), ...(sport ? { sport } : {}),
...(search ? { name: { contains: search, mode: "insensitive" } } : {}), ...(search ? { name: { contains: search, mode: "insensitive" } } : {}),
@@ -74,28 +80,31 @@ export class LeaguesService {
orderBy: { name: "asc" }, orderBy: { name: "asc" },
take: 100, take: 100,
}); });
return teams.map((t) => ({ ...t, logo: teamLogoUrl(t.id) }));
} }
/** /**
* Get team by ID * Get team by ID
*/ */
async findTeamById(id: string) { async findTeamById(id: string) {
return this.prisma.team.findUnique({ const team = await this.prisma.team.findUnique({
where: { id }, where: { id },
}); });
return team ? { ...team, logo: teamLogoUrl(team.id) } : null;
} }
/** /**
* Search teams by name * Search teams by name
*/ */
async searchTeams(name: string, sport?: Sport) { async searchTeams(name: string, sport?: Sport) {
return this.prisma.team.findMany({ const teams = await this.prisma.team.findMany({
where: { where: {
name: { contains: name, mode: "insensitive" }, name: { contains: name, mode: "insensitive" },
...(sport ? { sport } : {}), ...(sport ? { sport } : {}),
}, },
take: 20, take: 20,
}); });
return teams.map((t) => ({ ...t, logo: teamLogoUrl(t.id) }));
} }
/** /**
@@ -161,13 +170,9 @@ export class LeaguesService {
status: m.status, status: m.status,
state: m.state, state: m.state,
homeTeamName: m.homeTeam?.name, homeTeamName: m.homeTeam?.name,
homeTeamLogo: m.homeTeamId homeTeamLogo: teamLogoUrl(m.homeTeamId) ?? null,
? `https://file.mackolikfeeds.com/teams/${m.homeTeamId}`
: null,
awayTeamName: m.awayTeam?.name, awayTeamName: m.awayTeam?.name,
awayTeamLogo: m.awayTeamId awayTeamLogo: teamLogoUrl(m.awayTeamId) ?? null,
? `https://file.mackolikfeeds.com/teams/${m.awayTeamId}`
: null,
leagueName: m.league?.name, leagueName: m.league?.name,
countryName: m.league?.country?.name, countryName: m.league?.country?.name,
})), })),
+6 -10
View File
@@ -16,6 +16,10 @@ import {
LIVE_STATUS_VALUES_FOR_DB, LIVE_STATUS_VALUES_FOR_DB,
getDisplayMatchStatus, getDisplayMatchStatus,
} from "../../common/utils/match-status.util"; } from "../../common/utils/match-status.util";
import {
countryFlagUrl,
teamLogoUrl,
} from "../../common/utils/image-url.util";
@Injectable() @Injectable()
export class MatchesService { export class MatchesService {
@@ -98,20 +102,12 @@ export class MatchesService {
} }
} }
/**
* Generate URL for the country flag served from Mackolik
*/
private getCountryFlagUrl(countryId?: string | null): string | undefined { private getCountryFlagUrl(countryId?: string | null): string | undefined {
if (!countryId) return undefined; return countryFlagUrl(countryId);
return `https://file.mackolikfeeds.com/areas/${countryId}`;
} }
/**
* Generate URL for the team logo served from local uploads
*/
private getTeamLogoUrl(teamId?: string | null): string | undefined { private getTeamLogoUrl(teamId?: string | null): string | undefined {
if (!teamId) return undefined; return teamLogoUrl(teamId);
return `https://file.mackolikfeeds.com/teams/${teamId}`;
} }
private getLiveFilter(): Prisma.LiveMatchWhereInput { private getLiveFilter(): Prisma.LiveMatchWhereInput {
@@ -7,6 +7,11 @@ import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import { ImageRendererService } from "./image-renderer.service"; import { ImageRendererService } from "./image-renderer.service";
import {
competitionLogoUrl,
countryFlagUrl,
teamLogoUrl,
} from "../../common/utils/image-url.util";
import { CaptionGeneratorService } from "./caption-generator.service"; import { CaptionGeneratorService } from "./caption-generator.service";
import { TwitterService } from "./twitter.service"; import { TwitterService } from "./twitter.service";
import { MetaService } from "./meta.service"; import { MetaService } from "./meta.service";
@@ -387,15 +392,15 @@ export class SocialPosterService {
match.homeTeam?.name || prediction.match_info?.home_team || "Home", match.homeTeam?.name || prediction.match_info?.home_team || "Home",
awayTeam: awayTeam:
match.awayTeam?.name || prediction.match_info?.away_team || "Away", match.awayTeam?.name || prediction.match_info?.away_team || "Away",
homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ""), homeLogo: teamLogoUrl(match.homeTeamId) ?? "",
awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ""), awayLogo: teamLogoUrl(match.awayTeamId) ?? "",
leagueName: match.league?.name || prediction.match_info?.league || "", leagueName: match.league?.name || prediction.match_info?.league || "",
leagueLogo: this.resolveLogoUrl(match.league?.logoUrl || ""), leagueLogo: competitionLogoUrl(match.leagueId) ?? "",
countryName: countryName:
match.league?.country?.name || match.league?.country?.name ||
prediction.match_info?.country || prediction.match_info?.country ||
this.inferCountryName(match.league?.name || ""), this.inferCountryName(match.league?.name || ""),
countryFlag: this.resolveLogoUrl(match.league?.country?.flagUrl || ""), countryFlag: countryFlagUrl(match.league?.country?.id) ?? "",
matchDate, matchDate,
htScore, htScore,
ftScore, ftScore,
@@ -488,22 +493,6 @@ export class SocialPosterService {
: "football"; : "football";
} }
/**
* Convert relative logo paths to full HTTP URLs.
* On the deployed server, logos exist at public/uploads/teams/...
* Locally during dev, we fetch them from the deployed server via APP_BASE_URL.
*/
private resolveLogoUrl(logoUrl: string): string {
if (!logoUrl) return "";
// Already a full URL
if (logoUrl.startsWith("http")) return logoUrl;
// Relative path → check local first, otherwise make full URL
const localPath = path.join(process.cwd(), "public", logoUrl);
if (fs.existsSync(localPath)) return logoUrl; // Keep relative, renderer reads local
// Not local → prepend base URL for remote fetch
return `${this.appBaseUrl}${logoUrl}`;
}
private formatMatchDate(mstUtc: number | bigint): string { private formatMatchDate(mstUtc: number | bigint): string {
const d = new Date(Number(mstUtc)); const d = new Date(Number(mstUtc));
const months = [ const months = [
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "dist-new", "**/*spec.ts"] "exclude": ["node_modules", "test", "dist", "dist-new", "workers", "**/*spec.ts"]
} }
+1 -1
View File
@@ -22,5 +22,5 @@
"strictBindCallApply": false, "strictBindCallApply": false,
"noFallthroughCasesInSwitch": false "noFallthroughCasesInSwitch": false
}, },
"exclude": ["node_modules", "dist", "dist-new", "test"] "exclude": ["node_modules", "dist", "dist-new", "test", "workers"]
} }