diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1763acd..dcac4cd 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -21,6 +21,7 @@ import { RolesGuard } from "./common/guards/roles.guard"; import { LoggingInterceptor } from "./common/interceptors/logging.interceptor"; import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor"; import { TransformInterceptor } from "./common/interceptors/transform.interceptor"; +import { TelegramModule } from "./common/telegram.module"; import configuration from "./config/configuration"; import { validate } from "./config/env.validation"; import { ContactModule } from "./contact/contact.module"; @@ -79,6 +80,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module"; ]), DatabaseModule, RedisModule, + TelegramModule, AuthModule, UsersModule, EmailModule, diff --git a/apps/api/src/common/telegram.module.ts b/apps/api/src/common/telegram.module.ts new file mode 100644 index 0000000..f7eda1d --- /dev/null +++ b/apps/api/src/common/telegram.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from "@nestjs/common"; +import { TelegramService } from "./telegram.service"; + +/** Global so any module can raise an operational alert without extra wiring. */ +@Global() +@Module({ + providers: [TelegramService], + exports: [TelegramService], +}) +export class TelegramModule {} diff --git a/apps/api/src/common/telegram.service.ts b/apps/api/src/common/telegram.service.ts new file mode 100644 index 0000000..b96b6d0 --- /dev/null +++ b/apps/api/src/common/telegram.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Logger } from "@nestjs/common"; + +/** + * Minimal Telegram alerting for operational failures that need a human now + * (currently: PL24 auth down for an hour — i.e. the account may be banned + * again). Fire-and-forget: a failed alert must never affect a request. + * + * Uses the same bot as the Süper Panel — set TELEGRAM_BOT_TOKEN and + * TELEGRAM_CHAT_ID. Unconfigured = silently disabled (local/dev). + */ +@Injectable() +export class TelegramService { + private readonly logger = new Logger(TelegramService.name); + + private get token(): string { + return process.env.TELEGRAM_BOT_TOKEN ?? ""; + } + + private get chatId(): string { + return process.env.TELEGRAM_CHAT_ID ?? ""; + } + + isConfigured(): boolean { + return Boolean(this.token && this.chatId); + } + + /** Send a message. Returns false when disabled or the API refused. */ + async send(text: string, opts: { silent?: boolean } = {}): Promise { + if (!this.isConfigured()) { + this.logger.warn(`Telegram not configured — alert dropped: ${text.slice(0, 120)}`); + return false; + } + try { + const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: this.chatId, + text, + parse_mode: "HTML", + disable_notification: opts.silent ?? false, + disable_web_page_preview: true, + }), + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) { + this.logger.warn(`Telegram sendMessage failed: HTTP ${res.status}`); + return false; + } + return true; + } catch (err) { + this.logger.warn(`Telegram sendMessage error: ${(err as Error).message}`); + return false; + } + } +} diff --git a/apps/api/src/integrations/pl24/pl24-auth.service.spec.ts b/apps/api/src/integrations/pl24/pl24-auth.service.spec.ts index 1d1d037..8b84d5e 100644 --- a/apps/api/src/integrations/pl24/pl24-auth.service.spec.ts +++ b/apps/api/src/integrations/pl24/pl24-auth.service.spec.ts @@ -7,6 +7,9 @@ import { PL24AuthService } from "./pl24-auth.service"; type RedisStub = { store: Map; + get: (k: string) => Promise; + set: (k: string, v: string, ttl?: number) => Promise; + exists: (k: string) => Promise; getJson: (k: string) => Promise; setJson: (k: string, v: unknown) => Promise; del: (k: string) => Promise; @@ -20,6 +23,11 @@ const makeRedis = (opts: { lockTaken?: boolean } = {}): RedisStub => { const counters = new Map(); return { store, + get: async (k: string) => (store.has(k) ? String(store.get(k)) : null), + set: async (k: string, v: string) => { + store.set(k, v); + }, + exists: async (k: string) => store.has(k), getJson: async (k: string) => store.get(k) ?? null, setJson: async (k: string, v: unknown) => { store.set(k, v); @@ -27,7 +35,14 @@ const makeRedis = (opts: { lockTaken?: boolean } = {}): RedisStub => { del: async (k: string) => { store.delete(k); }, - setNx: async () => !opts.lockTaken, + // Gerçek SET NX semantiği: var olan anahtarı ikinci kez yazmaz. Alarm + // tekrarının bastırılması buna dayanıyor. + setNx: async (k: string, v: string) => { + if (opts.lockTaken && k.includes("login-lock")) return false; + if (store.has(k)) return false; + store.set(k, v); + return true; + }, incr: async (k: string) => { const n = (counters.get(k) ?? 0) + 1; counters.set(k, n); @@ -63,10 +78,19 @@ const makeService = (cfgOverrides: Record = {}, redis = makeRedi }; const configService = { get: (k: string, d?: unknown) => cfg[k] ?? d } as never; const budget = makeBudget(); + const telegram = { + sent: [] as string[], + isConfigured: () => true, + send: async function (t: string) { + this.sent.push(t); + return true; + }, + }; return { - svc: new PL24AuthService(configService, redis as never, budget as never), + svc: new PL24AuthService(configService, redis as never, budget as never, telegram as never), redis, budget, + telegram, }; }; @@ -384,3 +408,54 @@ describe("PL24AuthService — login devre kesici", () => { expect(fetchSpy).toHaveBeenCalledTimes(6); }); }); + +describe("PL24AuthService — 1 saatlik kesinti alarmı (Telegram)", () => { + const loginFails = () => ({ + ok: false, + status: 400, + statusText: "Bad Request", + headers: { get: () => null, getSetCookie: () => [] }, + json: async () => ({ type: "urn:login:account-not-active", detail: "account not active" }), + }); + + it("ilk saat içinde alarm göndermez", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginFails())); + const { svc, telegram } = makeService(); + await expect((svc as unknown as Exposed).login("de")).rejects.toThrow(); + expect(telegram.sent).toHaveLength(0); + }); + + it("kesinti 1 saati geçince tek sefer alarm gönderir", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginFails())); + const redis = makeRedis(); + // Kesinti 70 dakika önce başlamış gibi davran (paylaşılan Redis saati). + redis.store.set("pl24:auth:fail-since:de", String(Date.now() - 70 * 60_000)); + const { svc, telegram } = makeService({}, redis); + + await expect((svc as unknown as Exposed).login("de")).rejects.toThrow(); + await new Promise((r) => setTimeout(r, 10)); // void alarm promise'i + + expect(telegram.sent).toHaveLength(1); + expect(telegram.sent[0]).toContain("PL24 giriş yapılamıyor"); + expect(telegram.sent[0]).toContain("70 dakika"); + + // Kesici açık olsa bile ikinci kez sızlanmaz (alerted anahtarı). + await expect((svc as unknown as Exposed).login("de")).rejects.toThrow(); + await new Promise((r) => setTimeout(r, 10)); + expect(telegram.sent).toHaveLength(1); + }); + + it("giriş düzelince kurtarma mesajı gönderir ve saati sıfırlar", async () => { + const redis = makeRedis(); + redis.store.set("pl24:auth:fail-since:de", String(Date.now() - 90 * 60_000)); + redis.store.set("pl24:auth:alerted:de", "1"); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginOk())); + const { svc, telegram } = makeService({}, redis); + + await svc.getSessionCookieForAccount("de"); + await new Promise((r) => setTimeout(r, 10)); + + expect(telegram.sent.some((t) => t.includes("PL24 girişi düzeldi"))).toBe(true); + expect(redis.store.has("pl24:auth:fail-since:de")).toBe(false); + }); +}); diff --git a/apps/api/src/integrations/pl24/pl24-auth.service.ts b/apps/api/src/integrations/pl24/pl24-auth.service.ts index a22a0e5..2c610cb 100644 --- a/apps/api/src/integrations/pl24/pl24-auth.service.ts +++ b/apps/api/src/integrations/pl24/pl24-auth.service.ts @@ -24,6 +24,7 @@ import { Injectable, Logger, type OnModuleInit, UnauthorizedException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { TelegramService } from "../../common/telegram.service"; import { RedisService } from "../../redis/redis.service"; import { PL24BudgetService } from "./pl24-budget.service"; import { PL24_DEFAULTS, PL24_ENDPOINTS, PL24_USER_AGENT } from "./pl24.constants"; @@ -82,6 +83,13 @@ export class PL24AuthService implements OnModuleInit { }; /** Hard ceiling on logins per account per hour (PL24 counts sessions, not requests). */ private static readonly MAX_LOGINS_PER_HOUR = 6; + /** + * How long PL24 auth may stay broken before a human is paged on Telegram. + * Both previous bans were only noticed days later, from DB row counts. + */ + private static readonly ALERT_AFTER_MS = 60 * 60_000; + /** Do not re-page more often than this while the outage continues. */ + private static readonly ALERT_REPEAT_MS = 6 * 60 * 60_000; // ── Config ───────────────────────────────────────────────────────────────── private readonly baseUrl: string; @@ -98,6 +106,7 @@ export class PL24AuthService implements OnModuleInit { private configService: ConfigService, private readonly redis: RedisService, private readonly budget: PL24BudgetService, + private readonly telegram: TelegramService, ) { this.baseUrl = this.configService.get("pl24.baseUrl", "https://www.partslink24.com"); this.companyCode = this.configService.get("pl24.companyCode", ""); @@ -350,6 +359,7 @@ export class PL24AuthService implements OnModuleInit { if (!result.sessionToken) { this.breakerFail(account, result.permanent); const detail = result.error || "Token alinamadi"; + void this.noteLoginFailure(account, detail); this.logger.error(`PL24 login (${account}) failed: ${detail}`); throw new UnauthorizedException(`PL24 (${account}) giris hatasi: ${detail}`); } @@ -362,6 +372,7 @@ export class PL24AuthService implements OnModuleInit { this.sessions[account] = session; await this.persistSession(account, session); this.breakerOk(account); + void this.noteLoginSuccess(account); this.logger.log(`PL24 login (${account}) successful — session established`); return session; } @@ -712,6 +723,9 @@ export class PL24AuthService implements OnModuleInit { private breakerGate(account: PL24Account): void { const b = this.loginBreaker[account]; if (Date.now() < b.openUntil) { + // The breaker suppresses login attempts, so without this the outage clock + // would stop ticking exactly when the account is most likely dead. + void this.noteLoginFailure(account, `login breaker open (${b.fails} failures)`); throw new UnauthorizedException( `PL24 ${account} login breaker open (${b.fails} consecutive failures) — ` + `retrying after ${new Date(b.openUntil).toISOString()}`, @@ -736,6 +750,87 @@ export class PL24AuthService implements OnModuleInit { } } + // ── Outage tracking → Telegram ──────────────────────────────────────────── + + private failSinceKey(account: PL24Account): string { + return `${PL24_DEFAULTS.CACHE_PREFIX}auth:fail-since:${account}`; + } + + private alertedKey(account: PL24Account): string { + return `${PL24_DEFAULTS.CACHE_PREFIX}auth:alerted:${account}`; + } + + /** + * Record a login failure and page a human once the account has been unusable + * for an hour. Shared via Redis so api+worker (and restarts) agree on when the + * outage started — a crash-looping process must not reset the clock. + */ + private async noteLoginFailure(account: PL24Account, reason: string): Promise { + try { + const key = this.failSinceKey(account); + const existing = await this.redis.get(key); + const since = existing ? Number(existing) : Date.now(); + if (!existing) await this.redis.set(key, String(since), 3 * 86_400); + + const downForMs = Date.now() - since; + if (downForMs < PL24AuthService.ALERT_AFTER_MS) return; + if ( + !(await this.redis.setNx( + this.alertedKey(account), + "1", + Math.floor(PL24AuthService.ALERT_REPEAT_MS / 1000), + )) + ) { + return; // already paged recently + } + + const minutes = Math.round(downForMs / 60_000); + const company = account === "de" ? this.companyCode2 : this.companyCode; + await this.telegram.send( + [ + "🔴 PL24 giriş yapılamıyor", + "", + `Hesap: ${company || account}`, + `Süre: ${minutes} dakikadır başarısız`, + `Son hata: ${reason.slice(0, 200)}`, + "", + "Hesap banlanmış olabilir (önceki iki ban: tr 2026-07-24, de 2026-09-04).", + "Katalog decode'ları PL24 olmadan pcat/emex'e düşüyor.", + ].join("\n"), + ); + this.logger.error(`PL24 ${account}: auth down for ${minutes}m — Telegram alert sent`); + } catch (err) { + this.logger.warn(`PL24 outage alert failed: ${(err as Error).message}`); + } + } + + /** Clear the outage clock and, if we had paged, say it recovered. */ + private async noteLoginSuccess(account: PL24Account): Promise { + try { + const key = this.failSinceKey(account); + const since = await this.redis.get(key); + if (!since) return; + await this.redis.del(key); + const wasAlerted = await this.redis.exists(this.alertedKey(account)); + await this.redis.del(this.alertedKey(account)); + if (wasAlerted) { + const minutes = Math.round((Date.now() - Number(since)) / 60_000); + const company = account === "de" ? this.companyCode2 : this.companyCode; + await this.telegram.send( + [ + "🟢 PL24 girişi düzeldi", + "", + `Hesap: ${company || account}`, + `Kesinti: ~${minutes} dakika`, + ].join("\n"), + { silent: true }, + ); + } + } catch { + // never block auth on alerting + } + } + private breakerOk(account: PL24Account): void { const b = this.loginBreaker[account]; if (b.fails >= PL24AuthService.BREAKER_THRESHOLD) { diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 2e85377..cbb7095 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -144,6 +144,12 @@ services: - PREFETCH_MAX_DEPTH=${PREFETCH_MAX_DEPTH:-} - PREFETCH_RATE_PL24=${PREFETCH_RATE_PL24:-} - PREFETCH_DAILY_PL24=${PREFETCH_DAILY_PL24:-} + - PREFETCH_PL24_FAST_DEPTH=${PREFETCH_PL24_FAST_DEPTH:-} + - PREFETCH_PL24_DELAY_MS=${PREFETCH_PL24_DELAY_MS:-} + - PL24_HTTP_DAILY_MAX=${PL24_HTTP_DAILY_MAX:-} + - PL24_HTTP_USER_RESERVE=${PL24_HTTP_USER_RESERVE:-} + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} + - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} - PREFETCH_RATE_EMEX=${PREFETCH_RATE_EMEX:-} - PREFETCH_RATE_PCAT=${PREFETCH_RATE_PCAT:-} - PREFETCH_PCAT_DELAY_MS=${PREFETCH_PCAT_DELAY_MS:-}