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,
BasketballTeamStats,
} from "./feeder.types";
import { ImageUtils } from "../../common/utils/image.util";
import { deriveStoredMatchStatus } from "../../common/utils/match-status.util";
@Injectable()
@@ -164,24 +163,6 @@ export class FeederPersistenceService {
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,
@@ -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
try {
await this.prisma.$transaction(
@@ -264,7 +231,6 @@ export class FeederPersistenceService {
countryId: countryId,
sport: sport,
competitionSlug: league.competitionSlug,
logoUrl: `/uploads/competitions/${finalLeagueId}.png`,
} as any,
});
if (league.sortOrder !== undefined) {
@@ -291,10 +257,7 @@ export class FeederPersistenceService {
if (teamsToCreate.length > 0) {
await tx.team.createMany({
data: teamsToCreate.map((t) => ({
...t,
logoUrl: `/uploads/teams/${t.id}.png`,
})),
data: teamsToCreate,
skipDuplicates: true,
});
}
@@ -304,7 +267,6 @@ export class FeederPersistenceService {
where: { id: team.id },
data: {
name: team.name,
logoUrl: `/uploads/teams/${team.id}.png`,
},
});
}
@@ -614,9 +576,6 @@ export class FeederPersistenceService {
{ 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) {
+16 -11
View File
@@ -1,6 +1,10 @@
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { Sport } from "@prisma/client";
import {
countryFlagUrl,
teamLogoUrl,
} from "../../common/utils/image-url.util";
@Injectable()
export class LeaguesService {
@@ -12,19 +16,21 @@ export class LeaguesService {
* Get all countries
*/
async findAllCountries() {
return this.prisma.country.findMany({
const countries = await this.prisma.country.findMany({
orderBy: { name: "asc" },
});
return countries.map((c) => ({ ...c, flag: countryFlagUrl(c.id) }));
}
/**
* Get country by ID
*/
async findCountryById(id: string) {
return this.prisma.country.findUnique({
const country = await this.prisma.country.findUnique({
where: { id },
include: { leagues: true },
});
return country ? { ...country, flag: countryFlagUrl(country.id) } : null;
}
/**
@@ -66,7 +72,7 @@ export class LeaguesService {
* Get all teams
*/
async findAllTeams(sport?: Sport, search?: string) {
return this.prisma.team.findMany({
const teams = await this.prisma.team.findMany({
where: {
...(sport ? { sport } : {}),
...(search ? { name: { contains: search, mode: "insensitive" } } : {}),
@@ -74,28 +80,31 @@ export class LeaguesService {
orderBy: { name: "asc" },
take: 100,
});
return teams.map((t) => ({ ...t, logo: teamLogoUrl(t.id) }));
}
/**
* Get team by ID
*/
async findTeamById(id: string) {
return this.prisma.team.findUnique({
const team = await this.prisma.team.findUnique({
where: { id },
});
return team ? { ...team, logo: teamLogoUrl(team.id) } : null;
}
/**
* Search teams by name
*/
async searchTeams(name: string, sport?: Sport) {
return this.prisma.team.findMany({
const teams = await this.prisma.team.findMany({
where: {
name: { contains: name, mode: "insensitive" },
...(sport ? { sport } : {}),
},
take: 20,
});
return teams.map((t) => ({ ...t, logo: teamLogoUrl(t.id) }));
}
/**
@@ -161,13 +170,9 @@ export class LeaguesService {
status: m.status,
state: m.state,
homeTeamName: m.homeTeam?.name,
homeTeamLogo: m.homeTeamId
? `https://file.mackolikfeeds.com/teams/${m.homeTeamId}`
: null,
homeTeamLogo: teamLogoUrl(m.homeTeamId) ?? null,
awayTeamName: m.awayTeam?.name,
awayTeamLogo: m.awayTeamId
? `https://file.mackolikfeeds.com/teams/${m.awayTeamId}`
: null,
awayTeamLogo: teamLogoUrl(m.awayTeamId) ?? null,
leagueName: m.league?.name,
countryName: m.league?.country?.name,
})),
+6 -10
View File
@@ -16,6 +16,10 @@ import {
LIVE_STATUS_VALUES_FOR_DB,
getDisplayMatchStatus,
} from "../../common/utils/match-status.util";
import {
countryFlagUrl,
teamLogoUrl,
} from "../../common/utils/image-url.util";
@Injectable()
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 {
if (!countryId) return undefined;
return `https://file.mackolikfeeds.com/areas/${countryId}`;
return countryFlagUrl(countryId);
}
/**
* Generate URL for the team logo served from local uploads
*/
private getTeamLogoUrl(teamId?: string | null): string | undefined {
if (!teamId) return undefined;
return `https://file.mackolikfeeds.com/teams/${teamId}`;
return teamLogoUrl(teamId);
}
private getLiveFilter(): Prisma.LiveMatchWhereInput {
@@ -7,6 +7,11 @@ import * as fs from "fs";
import * as path from "path";
import { ImageRendererService } from "./image-renderer.service";
import {
competitionLogoUrl,
countryFlagUrl,
teamLogoUrl,
} from "../../common/utils/image-url.util";
import { CaptionGeneratorService } from "./caption-generator.service";
import { TwitterService } from "./twitter.service";
import { MetaService } from "./meta.service";
@@ -387,15 +392,15 @@ export class SocialPosterService {
match.homeTeam?.name || prediction.match_info?.home_team || "Home",
awayTeam:
match.awayTeam?.name || prediction.match_info?.away_team || "Away",
homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ""),
awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ""),
homeLogo: teamLogoUrl(match.homeTeamId) ?? "",
awayLogo: teamLogoUrl(match.awayTeamId) ?? "",
leagueName: match.league?.name || prediction.match_info?.league || "",
leagueLogo: this.resolveLogoUrl(match.league?.logoUrl || ""),
leagueLogo: competitionLogoUrl(match.leagueId) ?? "",
countryName:
match.league?.country?.name ||
prediction.match_info?.country ||
this.inferCountryName(match.league?.name || ""),
countryFlag: this.resolveLogoUrl(match.league?.country?.flagUrl || ""),
countryFlag: countryFlagUrl(match.league?.country?.id) ?? "",
matchDate,
htScore,
ftScore,
@@ -488,22 +493,6 @@ export class SocialPosterService {
: "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 {
const d = new Date(Number(mstUtc));
const months = [
+1 -1
View File
@@ -1,4 +1,4 @@
{
"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,
"noFallthroughCasesInSwitch": false
},
"exclude": ["node_modules", "dist", "dist-new", "test"]
"exclude": ["node_modules", "dist", "dist-new", "test", "workers"]
}