From 134692438782d02afb23bf5f6472b2b414eee405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahri=20Can=20Se=C3=A7er?= Date: Sun, 19 Apr 2026 13:23:00 +0300 Subject: [PATCH] gg --- project_summary.md | 1027 ++++++----------- src/common/constants/roles.ts | 12 + src/common/utils/ai-engine-client.ts | 267 +++++ src/common/utils/match-status.util.ts | 203 ++++ src/common/utils/timezone.util.ts | 82 ++ src/modules/admin/admin.controller.ts | 6 + src/modules/auth/guards/auth.guards.ts | 23 +- src/modules/auth/strategies/jwt.strategy.ts | 7 +- .../coupons/services/smart-coupon.service.ts | 40 +- .../feeder/feeder-persistence.service.ts | 41 +- src/modules/feeder/feeder.service.ts | 81 +- src/modules/health/health.controller.ts | 82 +- src/modules/health/health.module.ts | 6 +- src/modules/matches/matches.service.ts | 69 +- src/modules/predictions/dto/index.ts | 15 + .../predictions/predictions.service.ts | 101 +- .../social-poster/social-poster.controller.ts | 2 +- src/modules/spor-toto/spor-toto.controller.ts | 6 +- src/modules/users/users.controller.ts | 4 +- src/services/ai.service.ts | 45 +- src/tasks/data-fetcher.task.ts | 275 +++-- src/tasks/historical-results-sync.task.ts | 35 +- src/tasks/limit-resetter.task.ts | 196 ++-- src/tasks/task-lock.service.ts | 80 ++ src/tasks/tasks.module.ts | 10 +- 25 files changed, 1639 insertions(+), 1076 deletions(-) create mode 100644 src/common/constants/roles.ts create mode 100644 src/common/utils/ai-engine-client.ts create mode 100644 src/common/utils/match-status.util.ts create mode 100644 src/common/utils/timezone.util.ts create mode 100644 src/tasks/task-lock.service.ts diff --git a/project_summary.md b/project_summary.md index b9bc446..3b794d2 100644 --- a/project_summary.md +++ b/project_summary.md @@ -1,789 +1,424 @@ -# Suggest-Bet-BE — Comprehensive Project Summary +# Backend Project Summary -> **Son güncelleme:** 2026-03-12 -> **Bu doküman**, projeyi hiç bilmeyen bir AI veya geliştiricinin projeyi A-Z anlaması için hazırlanmıştır. +## 1. Bu backend ne yapiyor? ---- +Bu backend, spor bahis tahmini ve karar destek sistemi olarak tasarlanmis bir NestJS uygulamasidir. Uygulama yalnizca mac listeleyen bir servis degildir; asil isi su zinciri yonetmektir: -## 1. Proje Amacı ve Genel Bakış +1. Dis kaynaklardan mac, oran, kadro, hakem, eksik oyuncu ve sonuc verilerini toplamak. +2. Bu verileri normalize edip veritabanina yazmak. +3. Python tabanli AI motoruna uygun veri yuzeyi sunmak. +4. Tek mac tahmini, value bet, kupon onerisi ve Spor Toto uretilmis ciktilarini API olarak vermek. +5. Kimlik dogrulama, kullanici limitleri, kullanici kuponlari ve yonetimsel ekranlari desteklemek. -**Suggest-Bet-BE**, yapay zeka destekli bir **spor bahis tahmin ve analiz platformu** backend servisidir. Platform, kullanıcılara: +Projenin pratikteki urun kimligi "sports betting intelligence backend" olarak okunmali. -- Futbol ve basketbol maçları için **AI destekli tahminler** sunar -- Akıllı **kupon önerileri** oluşturur (SAFE, BALANCED, AGGRESSIVE, VALUE, MIRACLE stratejileri) -- **Canlı skor takibi** ve **oran izleme** sağlar -- **Mackolik.com** üzerinden veri scraping ile güncel ve tarihsel maç verileri toplar -- **Google Gemini AI** ile doğal dil maç yorumu üretir -- Kullanıcı kuponlarını kaydeder ve sonuçlarını takip eder (ROI, Win Rate) +## 2. Teknoloji omurgasi -### Teknoloji Stack +- Framework: NestJS 11 +- ORM: Prisma +- Veritabani: PostgreSQL varsayimi ile tasarlanmis Prisma semasi +- Queue/async: BullMQ + Redis opsiyonel +- Scheduler: `@nestjs/schedule` +- AI entegrasyonu: Harici Python/FastAPI servis cagrilari +- Auth: JWT access token + refresh token +- Dokumantasyon: Swagger +- I18n: `nestjs-i18n` -| Katman | Teknoloji | -|--------|-----------| -| **Backend (API)** | NestJS 11 (TypeScript) | -| **AI Engine** | Python FastAPI (v20+) | -| **Veritabanı** | PostgreSQL 16 + Prisma ORM | -| **Kuyruk** | BullMQ + Redis (Opsiyonel) | -| **Cache** | Redis veya In-Memory fallback | -| **Auth** | JWT + Passport (Access + Refresh Token) | -| **AI** | Google Gemini API, Custom Python ML Engine | -| **Scraping** | Axios + Cheerio (Mackolik HTML parsing) | -| **Loglama** | Pino (Structured Logging) | -| **i18n** | nestjs-i18n (TR, EN) | -| **API Docs** | Swagger (NestJS/Swagger) | -| **Deploy** | Docker + Docker Compose | -| **Social** | Twitter API v2 (Social poster) | +Temel giris dosyalari: ---- +- `src/main.ts`: uygulamayi ayaga kaldirir, global filter/interceptor/validation ayarlarini yapar. +- `src/app.module.ts`: tum modulleri birlestirir, feeder/schedule/queue davranislarini ortam degiskenlerine gore sekillendirir. +- `src/config/configuration.ts` ve `src/config/env.validation.ts`: env tabanli konfigurasyon. -## 2. Mimari Genel Bakış +## 3. Mimariyi nasil okumali? -``` -┌──────────────────────────────────────────────────────────────────┐ -│ CLIENTS (Web/Mobile) │ -└───────────────────────────────┬──────────────────────────────────┘ - │ HTTP/REST -┌───────────────────────────────▼──────────────────────────────────┐ -│ NestJS Backend (Port 3005) │ -│ ┌─────────┬──────────┬──────────┬──────────┬─────────────────┐ │ -│ │ Auth │ Admin │ Matches │ Leagues │ Predictions │ │ -│ │ Module │ Module │ Module │ Module │ Module │ │ -│ ├─────────┼──────────┼──────────┼──────────┼─────────────────┤ │ -│ │ Coupons │ Analysis │ Gemini │ Social- │ Health │ │ -│ │ Module │ Module │ Module │ Poster │ Module │ │ -│ └─────────┴──────────┴──────────┴──────────┴─────────────────┘ │ -│ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ Services: AiService | MatchAnalysis | Scraper │ │ -│ ├──────────────────────────────────────────────────────────────┤ │ -│ │ Tasks: DataFetcher (Cron) | LiveUpdater | LimitResetter │ │ -│ ├──────────────────────────────────────────────────────────────┤ │ -│ │ Feeder: FeederService | Scraper | Transformer | Persistence│ │ -│ └──────────────────────────────────────────────────────────────┘ │ -└────┬─────────────────┬────────────────────┬──────────────────────┘ - │ │ │ - ▼ ▼ ▼ -┌─────────┐ ┌──────────────┐ ┌──────────────────┐ -│PostgreSQL│ │ Redis/BullMQ │ │ AI Engine (py) │ -│ (3.6GB) │ │ (Opsiyonel) │ │ FastAPI:8000 │ -└─────────┘ └──────────────┘ └──────────────────┘ - │ - ┌───────▼───────┐ - │ Mackolik API │ - │ (Veri Kaynağı) │ - └───────────────┘ -``` +Backend mantigi 4 ana katmanda okunmali: ---- +### A. Domain API katmani -## 3. Veritabanı Şeması (27 Tablo) +Kullanicinin ya da frontend'in gordugu endpointler burada: -### 3.1 Enum'lar +- `auth` +- `users` +- `admin` +- `matches` +- `leagues` +- `predictions` +- `coupons` +- `analysis` +- `spor-toto` +- `health` -| Enum | Değerler | -|------|----------| -| `Sport` | `football`, `basketball` | -| `UserRole` | `user`, `superadmin` | -| `SubscriptionStatus` | `free`, `active`, `expired` | -| `PlayerPosition` | `goalkeeper`, `defender`, `midfielder`, `striker` | -| `EventType` | `goal`, `card`, `substitute` | -| `MatchPosition` | `home`, `away` | +### B. Veri toplama ve veri isleme katmani -### 3.2 Temel Tablolar ve İlişkileri +Burasi urunun can damari: -#### Spor Verileri (Büyük Tablolar) +- `feeder` modulu +- `data-fetcher.task.ts` +- `historical-results-sync.task.ts` +- `limit-resetter.task.ts` +- scraper/transformer/persistence servisleri -| Tablo | Kayıt (~) | Açıklama | -|-------|-----------|----------| -| `matches` | 237K | Kalıcı maç kayıtları (finished maçlar) | -| `live_matches` | 82 | Aktif/yaklaşan canlı maçlar (günlük döngüsel) | -| `match_player_participation` | 3.3M | Oyuncu maç katılımları (ilk 11 + yedek) | -| `match_player_events` | 1.5M | Maç olayları (gol, kart, oyuncu değişikliği) | -| `match_player_stats` | 345K | Oyuncu istatistikleri (basketbol odaklı) | -| `match_team_stats` | 311K | Takım istatistikleri (possession, shots, basketbol box score) | -| `match_officials` | — | Hakem bilgileri | -| `match_ai_features` | — | AI feature vektörleri (ELO, form skoru, eksik oyuncu etkisi) | +Bu katman yoksa tahmin urunu deger kaybeder, cunku AI motorunun beslendigi veri bu katmanda toplanir. -#### Oran/Bahis Verileri +### C. AI entegrasyon katmani -| Tablo | Kayıt (~) | Açıklama | -|-------|-----------|----------| -| `odd_categories` | 3.2M | Bahis kategorileri (Maç Sonucu, Alt/Üst vb.) | -| `odd_selections` | 8.5M | Bahis seçimleri (1, X, 2, Alt, Üst vb.) ve oranları | -| `odds_history` | — | Oran değişim geçmişi | +Backend kendi basina tum modellemeyi yapmaz. Temel strateji: -#### Referans Verileri +- Match verisini uygun hale getir +- Gerekli cache ve validasyon kontrolunu yap +- Python AI servisini cagir +- Gelen cevabi urun dostu response'a cevir -| Tablo | Kayıt (~) | Açıklama | -|-------|-----------|----------| -| `countries` | 160 | Ülkeler | -| `leagues` | 1,505 | Ligler (Süper Lig, Premier League vb.) | -| `teams` | 19,595 | Takımlar | -| `players` | 217K | Oyuncular | -| `official_roles` | — | Hakem rolleri | +Bu akis en cok `predictions` ve `smart-coupon` tarafinda gorulur. -#### Kullanıcı ve Sistem +### D. Kullanici / urun operasyon katmani -| Tablo | Açıklama | -|-------|----------| -| `users` | Kullanıcı hesapları (email, role, subscription) | -| `refresh_tokens` | JWT refresh tokenları | -| `usage_limits` | Günlük analiz/kupon kullanım limitleri | -| `user_coupons` | Kullanıcı kuponları (PENDING/WON/LOST) | -| `user_coupon_items` | Kupon içindeki tekil bahisler | -| `analyses` | Kullanıcı analiz geçmişi (JSON) | -| `predictions` | AI tahmin cache (6 saat TTL) | -| `ai_predictions_log` | AI tahmin logları (accuracy tracking) | -| `app_settings` | Uygulama ayarları (key-value) | -| `translations` | Çeviri verileri (DB-tabanlı i18n) | +Burada kimlik, limit, kullanici kuponlari, admin ekranlari ve bazi raporlama taraflari vardir. -### 3.3 Kritik İlişkiler +## 4. Uygulamanin davranis ozellikleri -``` -Country 1──N League 1──N Match N──1 Team (home/away) -Match 1──N OddCategory 1──N OddSelection 1──N OddsHistory -Match 1──N MatchPlayerParticipation N──1 Player -Match 1──N MatchPlayerEvents N──1 Player -Match 1──1 Prediction -Match 1──1 MatchAiFeature -User 1──N Analysis -User 1──N UserCoupon 1──N UserCouponItem N──1 Match -User 1──1 UsageLimit -``` +### 4.1 Response standardizasyonu ---- - -## 4. NestJS Modülleri (12 Modül) - -### 4.1 Auth Module (`src/modules/auth/`) - -JWT tabanlı kimlik doğrulama sistemi. - -| Dosya | Açıklama | -|-------|----------| -| `auth.controller.ts` | Register, Login, Refresh, Logout endpointleri | -| `auth.service.ts` | bcrypt ile şifre hash, JWT token üretimi, refresh token yönetimi | -| `guards/auth.guards.ts` | `JwtAuthGuard`, `RolesGuard`, `PermissionsGuard` (global) | -| `strategies/jwt.strategy.ts` | Passport JWT strategy | -| `dto/auth.dto.ts` | `RegisterDto`, `LoginDto`, `RefreshTokenDto`, `TokenResponseDto` | - -**Akış:** Register → Password hash (bcrypt, 12 rounds) → User + UsageLimit oluştur → JWT Access (15m) + Refresh Token (7d) üret - -### 4.2 Admin Module (`src/modules/admin/`) - -Superadmin yönetim paneli. `@Roles('superadmin')` decorator ile korunur. - -**Fonksiyonlar:** -- **Kullanıcı yönetimi:** Listeleme, detay, rol değiştirme, abonelik güncelleme, aktif/pasif toggle, soft delete -- **App Settings:** Key-value ayar okuma/yazma (Redis cache ile) -- **Usage Limits:** Tüm kullanıcı limitlerini listeleme ve toplu sıfırlama -- **Analytics:** Toplam kullanıcı, aktif, premium, maç ve tahmin sayıları - -### 4.3 Matches Module (`src/modules/matches/`) - -Maç listeleme ve detay servisi. - -| Metot | Açıklama | -|-------|----------| -| `findMatches()` | Filtreleme (sport, league, status, date, team) ile maç arama. `live_matches` tablosundan | -| `findUpcomingMatches()` | Kupon üretici için yaklaşan maçları bulma (son 24 saat dahil) | -| `getMatchesAndStructureByIds()` | Maçları lig bazında grupla, odds JSON'ı yapılandır, frontend formatına dönüştür | -| `getActiveLeagues()` | Raw SQL ile aktif ligleri ve canlı maç sayılarını getir (Mackolik-tarzı öncelik sıralama) | -| `listMatches()` | Sayfalı maç listesi (`matches` tablosundan) | -| `getMatchDetailsById()` | Tam maç detayı: kadro, istatistik, oran, olaylar. Önce `matches`, bulamazsa `live_matches` | - -**Önemli:** Takım logoları için `https://file.mackolikfeeds.com/teams/{teamId}` URL şablonu kullanılır. - -### 4.4 Leagues Module (`src/modules/leagues/`) - -Lig, ülke ve takım keşif servisi. - -**Fonksiyonlar:** Ülke listesi, Lig listesi (sport filtre), Takım arama, Takım detay, Takım son maçları, Head-to-head karşılaştırma - -### 4.5 Predictions Module (`src/modules/predictions/`) - -AI tahmin servisi. **Redis/BullMQ gerektirir** (conditional module loading). - -| Dosya | Açıklama | -|-------|----------| -| `predictions.controller.ts` | health, upcoming, value-bets, history, getPrediction, generate, smart-coupon | -| `predictions.service.ts` | BullMQ kuyruğu ile tahmin işleme, cache (6 saat TTL, v20plus model check) | -| `queues/predictions.queue.ts` | BullMQ kuyruk yöneticisi | -| `queues/predictions.processor.ts` | Kuyruk işleyici (worker) | -| `services/ai-feature-store.service.ts` | AI feature hesaplama (V17, şu an devre dışı) | - -**Akış:** İstek → Cache kontrolü (Prediction tablosu, 6 saat TTL) → BullMQ kuyruğa ekle → AI Engine çağır → Sonuç cache'le - -### 4.6 Coupons Module (`src/modules/coupons/`) - -Akıllı kupon üretici ve kullanıcı kupon yönetimi. - -| Servis | Açıklama | -|--------|----------| -| `SmartCouponService` | AI Engine v20+ ile maç analizi, kupon üretimi, Gemini ile Türkçe yorum. 5 strateji destekler | -| `UserCouponService` | Kupon oluşturma, bahis settlement (MS 1/X/2, Alt/Üst, KG Var/Yok), kullanıcı istatistikleri (ROI, Win Rate) | -| `CouponsService` | Legacy servis (şu an boş) | - -**Kupon Stratejileri:** -- `SAFE` — Düşük risk, yüksek güvenilirlik (%78+ confidence, 2 maç) -- `BALANCED` — Orta risk/oran dengesi -- `AGGRESSIVE` — Yüksek oran, düşük güvenilirlik -- `VALUE` — EV+ (Expected Value pozitif) bahisler -- `MIRACLE` — Çok yüksek oran, çok düşük güvenilirlik - -### 4.7 Analysis Module (`src/modules/analysis/`) - -Maç analiz orkestratörü. - -**Fonksiyonlar:** -- `analyzeCoupon()` — Çoklu maç analizi. URL parse → Scrape → AI Engine çağrısı -- `checkUsageLimit()` — Free (10 analiz/3 kupon) vs Premium (50 analiz/10 kupon) limit kontrolü -- `recordUsage()` — Kullanım kaydı -- `getAnalysisHistory()` — Analiz geçmişi - -### 4.8 Gemini Module (`src/modules/gemini/`) - -Google Gemini AI entegrasyonu. `ENABLE_GEMINI=true` ve `GOOGLE_API_KEY` gerektirir. - -**API'ler:** -- `generateText()` — Tek prompt ile metin üretimi -- `chat()` — Multi-turn sohbet -- `generateJSON()` — Yapısal JSON üretimi (schema destekli) - -**Kullanım:** Maç yorumu üretimi (SmartCouponService içinde, Türkçe, bahis terminolojisi ile) - -### 4.9 Social Poster Module (`src/modules/social-poster/`) - -Sosyal medya paylaşım sistemi (Twitter). - -| Servis | Açıklama | -|--------|----------| -| `twitter.service.ts` | Twitter API v2 ile tweet gönderimi | -| `image-renderer.service.ts` | Canvas ile tahmin kartı görsel üretimi | -| `caption-generator.service.ts` | Gemini ile paylaşım metni üretimi | -| `social-poster.service.ts` | Orkestratör: tahmin → görsel → metin → paylaş | -| `meta.service.ts` | Meta (Instagram/Facebook) entegrasyonu (yapılacak) | - -### 4.10 Feeder Module (`src/modules/feeder/`) - -Tarihsel veri toplama sistemi (Mackolik scraping). - -| Servis | Açıklama | -|--------|----------| -| `feeder.service.ts` | Ana orkestratör. Tarihsel tarama (2023-06-01'den bugüne), ters kronolojik, resume desteği | -| `feeder-scraper.service.ts` | HTTP istekleri: livescores, match header, key events, stats, lineups, odds | -| `feeder-transformer.service.ts` | Ham veriyi DB modeline dönüştürme | -| `feeder-persistence.service.ts` | Prisma ile veritabanına kayıt, duplicate kontrolü, state yönetimi | - -**Konfigürasyon:** Concurrency=20, 300ms delay, 50 max retry, 502 exponential backoff - -### 4.11 Health Module (`src/modules/health/`) - -Sistem sağlık kontrolleri: liveness, readiness, AI Engine health. - -### 4.12 Users Module (`src/modules/users/`) - -Kullanıcı CRUD operasyonları. BaseController/BaseService kalıtımı ile generic yapı. - ---- - -## 5. Servisler (`src/services/`) - -| Servis | Dosya | Açıklama | -|--------|-------|----------| -| **AiService** | `ai.service.ts` | Python AI Engine bridge. `POST /v20plus/analyze/{matchId}` çağırır, response'u frontend kontratına map'ler. Analysis strategy üretir (oran bazlı taktik) | -| **MatchAnalysisService** | `match-analysis.service.ts` | 7 fazlı analiz orkestratörü: URL Parse → Scrape → Python Engine → Strategy → Similar Matches → Final Prediction → DB Save | -| **ScraperService** | `scraper.service.ts` | Mackolik HTML scraping: Cheerio ile `data-settings` ve `window.dataLayer` parse. Odds, lineup, stats, event çekimi | - ---- - -## 6. Zamanlanmış Görevler (`src/tasks/`) - -| Görev | Cron | Açıklama | -|-------|------|----------| -| `DataFetcherTask.fetchLiveMatches()` | `*/15 * * * *` | Mackolik API'den futbol maçlarını çek, `live_matches` tablosuna yaz. Top league filtresi | -| `DataFetcherTask.fetchOddsForPreMatches()` | `*/15 * * * *` | Başlamamış maçların oranlarını çek (futbol + basketbol). Retry logic (502/timeout) | -| `DataFetcherTask.fetchBasketballMatches()` | Manuel | Basketbol maçlarını çek. `basketball_top_leagues.json` filtresi | -| `LiveUpdaterTask.updateLiveScores()` | `*/15 * * * *` | Canlı maç skorlarını güncelle (Mackolik match-info API) | -| `LiveUpdaterTask.finalizeFinishedMatches()` | `*/30 * * * *` | Bitmiş maçları `live_matches` → `matches` tablosuna migrate et | -| `LimitResetterTask.resetUsageLimits()` | `0 3 * * *` | Günlük kullanım limitlerini sıfırla (03:00 Istanbul) | -| `LimitResetterTask.cleanupOldData()` | `0 4 * * *` | 30 günlük AI logları sil, 1 günlük bitmiş live_matches sil | -| `LimitResetterTask.checkSubscriptions()` | `0 0 * * *` | Süresi dolmuş abonelikleri `expired` yap | - ---- - -## 7. AI Engine (Python/FastAPI) — `ai-engine/` - -Bağımsız Python mikro servis. Default port: `8000`. - -### 7.1 Endpointler - -| Method | Endpoint | Açıklama | -|--------|----------|----------| -| `POST` | `/v20plus/analyze/{match_id}` | Tekil maç analizi (ana endpoint) | -| `GET` | `/v20plus/analyze-htms/{match_id}` | İlk yarı - Maç sonu analizi | -| `GET` | `/v20plus/analyze-htft/{match_id}` | HT/FT olasılıkları (timeout destekli) | -| `POST` | `/v20plus/coupon` | Akıllı kupon üretimi (strateji + filtreleme) | -| `GET` | `/v20plus/daily-banker` | Günün banko maçları | -| `GET` | `/v20plus/reversal-watchlist` | Score reversal izleme listesi | -| `GET` | `/health` | Sağlık kontrolü | - -### 7.2 Mimari - -``` -ai-engine/ -├── main.py # FastAPI app, route tanımları -├── services/ -│ └── single_match_orchestrator.py # V20+ ana orkestratör (singleton) -├── core/ # Çekirdek algoritmalar -├── features/ # Feature engineering -├── models/ # ML modelleri -├── training/ # Model eğitim scriptleri -├── config/ # Konfigürasyon -├── utils/ # Yardımcı fonksiyonlar -└── tests/ # Test dosyaları -``` - -### 7.3 Tahmin Çıktı Yapısı (SingleMatchPredictionPackage) - -```typescript -{ - model_version: "v20plus.X", - match_info: { match_id, match_name, home_team, away_team, league, match_date_ms }, - data_quality: { label: "HIGH"|"MEDIUM"|"LOW", score, flags, lineup_counts }, - risk: { level: "LOW"|"MEDIUM"|"HIGH"|"EXTREME", score, is_surprise_risk, warnings }, - engine_breakdown: { team, player, odds, referee }, // 4 motor ağırlığı - main_pick: { market, pick, probability, confidence, odds, bet_grade, edge, ... }, - value_pick: { ... }, - bet_advice: { playable, suggested_stake_units, reason }, - bet_summary: [{ market, pick, raw_confidence, calibrated_confidence, bet_grade, ... }], - supporting_picks: [...], - aggressive_pick: { market, pick, probability, confidence, odds }, - scenario_top5: [{ score, prob }], - score_prediction: { ft, ht, xg_home, xg_away, xg_total }, - market_board: { ... }, - reasoning_factors: ["..."], - ai_commentary: "Gemini üretimi Türkçe yorum" -} -``` - ---- - -## 8. API Endpointleri (50 Toplam) - -### Auth (4 endpoint) — Public - -| Method | Path | Açıklama | -|--------|------|----------| -| `POST` | `/api/auth/register` | Kayıt ol | -| `POST` | `/api/auth/login` | Giriş yap | -| `POST` | `/api/auth/refresh` | Token yenile | -| `POST` | `/api/auth/logout` | Çıkış yap (refresh token invalid) | - -### Users (5 endpoint) — Auth gerekli - -| Method | Path | Açıklama | -|--------|------|----------| -| `GET` | `/api/users` | Kullanıcı listesi | -| `GET` | `/api/users/:id` | Kullanıcı detay | -| `PUT` | `/api/users/:id` | Kullanıcı güncelle | -| `POST` | `/api/users/:id/restore` | Silinmiş kullanıcıyı geri al | - -### Admin (11 endpoint) — Superadmin - -| Method | Path | Açıklama | -|--------|------|----------| -| `GET` | `/api/admin/users` | Tüm kullanıcılar (paginated) | -| `GET` | `/api/admin/users/:id` | Kullanıcı detay | -| `PUT` | `/api/admin/users/:id/role` | Rol değiştir | -| `PUT` | `/api/admin/users/:id/subscription` | Abonelik güncelle | -| `PUT` | `/api/admin/users/:id/toggle-active` | Aktif/Pasif toggle | -| `DELETE` | `/api/admin/users/:id` | Soft-delete | -| `GET` | `/api/admin/settings` | Tüm ayarlar | -| `PUT` | `/api/admin/settings/:key` | Ayar güncelle | -| `GET` | `/api/admin/usage-limits` | Kullanım limitleri | -| `POST` | `/api/admin/usage-limits/reset-all` | Toplu limit sıfırla | -| `GET` | `/api/admin/analytics/overview` | Sistem istatistikleri | - -### Matches (4 endpoint) — Public - -| Method | Path | Açıklama | -|--------|------|----------| -| `GET` | `/api/matches` | Maç listesi (paginated) | -| `POST` | `/api/matches/query` | Gelişmiş maç sorgusu (filtreleme) | -| `GET` | `/api/matches/leagues/active` | Aktif ligler (cached 1dk) | -| `GET` | `/api/matches/:id` | Maç detayı (kadro, stat, oran, olaylar) | - -### Leagues (8 endpoint) — Public - -| Method | Path | Açıklama | -|--------|------|----------| -| `GET` | `/api/leagues` | Tüm ligler | -| `GET` | `/api/leagues/:id` | Lig detay | -| `GET` | `/api/leagues/countries` | Ülke listesi | -| `GET` | `/api/leagues/countries/:id` | Ülke detay + ligleri | -| `GET` | `/api/leagues/teams/search` | Takım arama | -| `GET` | `/api/leagues/teams/:id` | Takım detay | -| `GET` | `/api/leagues/teams/:id/matches` | Takım son maçları | -| `GET` | `/api/leagues/teams/h2h` | Head-to-head | - -### Analysis (2 endpoint) — Auth gerekli - -| Method | Path | Açıklama | -|--------|------|----------| -| `POST` | `/api/analysis/analyze-matches` | Çoklu maç analizi | -| `GET` | `/api/analysis/history` | Analiz geçmişi | - -### Coupon (6 endpoint) — Mixed - -| Method | Path | Açıklama | -|--------|------|----------| -| `POST` | `/api/coupon/analyze-match` | Tekil maç analizi (V20) — Public | -| `POST` | `/api/coupon/daily-banko` | Günün bankosu (2 maç, %78+) — Public | -| `POST` | `/api/coupon/suggest` | Akıllı kupon öner (5 strateji) — Public | -| `POST` | `/api/coupon/create` | Kupon kaydet — Auth | -| `GET` | `/api/coupon/my-stats` | Kullanıcı istatistikleri — Auth | -| `GET` | `/api/coupon/history` | Kupon geçmişi — Auth | - -### Predictions (7 endpoint) — Requires Redis - -| Method | Path | Açıklama | -|--------|------|----------| -| `GET` | `/api/predictions/health` | AI Engine health | -| `GET` | `/api/predictions/upcoming` | Yaklaşan tahminler | -| `GET` | `/api/predictions/value-bets` | EV+ fırsatları | -| `GET` | `/api/predictions/history` | Tahmin geçmişi & doğruluk | -| `GET` | `/api/predictions/:matchId` | Tekil maç tahmini (cached) | -| `POST` | `/api/predictions/generate` | Tahmin üret | -| `POST` | `/api/predictions/smart-coupon` | Smart Coupon (V20 AI) | - -### Health (3 endpoint) — Public - -| Method | Path | Açıklama | -|--------|------|----------| -| `GET` | `/api/health` | Readiness | -| `GET` | `/api/health/live` | Liveness | -| `GET` | `/api/health/detail` | Detaylı sağlık | - ---- - -## 9. Common Layer (`src/common/`) - -| Dosya | Açıklama | -|-------|----------| -| `base/base.service.ts` | Generic BaseService (findAll, findOne, create, update, delete) | -| `base/base.controller.ts` | Generic BaseController (CRUD endpointleri) | -| `decorators/index.ts` | `@Public()`, `@Roles()`, `@CurrentUser()` decoratorları | -| `dto/pagination.dto.ts` | `PaginationDto` — page, limit, sortBy, sortOrder, search | -| `filters/global-exception.filter.ts` | GlobalExceptionFilter — HTTP 200 wrapper, dev stack trace, i18n error keys | -| `interceptors/response.interceptor.ts` | ResponseInterceptor — Standart API response wrapper | -| `types/api-response.type.ts` | `ApiResponse`, `createSuccessResponse()`, `createErrorResponse()`, `createPaginatedResponse()` | -| `queues/queue.module.ts` | BullMQ module konfigürasyonu | -| `utils/image.util.ts` | Canvas yardımcı fonksiyonları | - -### Standart API Response Formatı +Backend klasik REST semantigini tam anlamiyla izlemiyor. Basarili cevaplar global response interceptor ile benzer bir yapida donuyor: ```json { "success": true, "status": 200, - "message": "Success", - "data": { ... }, - "errors": [] + "message": "...", + "data": {} } ``` ---- +Hata akisi da global exception filter ile sarmalaniyor. Dikkat edilmesi gereken nokta su: bazi hatalar HTTP seviyesinde 4xx/5xx yerine govdede `success: false` ve `status` ile temsil ediliyor. Frontend'in `create-api-client` katmani da bu yuzden yalnizca HTTP status'e degil response body'ye bakiyor. -## 10. Güvenlik & Guard Sistemi +### 4.2 Global auth / role / permission yaklasimi -Tüm guard'lar **global** olarak uygulanır (`app.module.ts`): +`AppModule` icinde guard'lar global duzeyde baglanmis. Yani endpoint korumasi per-controller bazli tek tek degil, merkezi sekilde uygulanmis. JWT strategy ile kullanici context'i request'e ekleniyor. -1. **ThrottlerGuard** — Rate limiting (default: 100 req/60s) -2. **JwtAuthGuard** — JWT token doğrulama (`@Public()` ile bypass) -3. **RolesGuard** — Rol tabanlı erişim (`@Roles('superadmin')`) -4. **PermissionsGuard** — İzin tabanlı erişim kontrolü +### 4.3 Queue opsiyonel davranis -**Password:** bcrypt, 12 salt rounds -**Token:** JWT Access (15dk) + UUID Refresh Token (7 gün, DB'de saklanır) +Prediction isleri Redis/BullMQ ile queue'lanabilir; fakat queue altyapisi kapalisa backend dogrudan AI servisini cagirabilir. Bu, lokal gelistirme ve daha basit deployment senaryolari icin esneklik sagliyor. ---- +## 5. Veritabani modeli: projenin asil haritasi -## 11. Veri Akış Diyagramları +En kritik dosya `prisma/schema.prisma`. Buradaki modeli anlamadan backend'i tam anlamak zor. -### 11.1 Canlı Maç Veri Akışı +### 5.1 Cekirdek spor varliklari -``` -Mackolik API (her 15dk) - │ - ▼ -DataFetcherTask.fetchLiveMatches() - │ - ├─→ Country upsert - ├─→ League upsert - ├─→ Team upsert (home + away) - └─→ LiveMatch upsert (top_leagues.json filtresi) - - │ (her 15dk) - ▼ -DataFetcherTask.fetchOddsForPreMatches() - │ - └─→ LiveMatch.odds (JSON) + oddsUpdatedAt güncelle +- `Country` +- `League` +- `Team` +- `Player` +- `Match` +- `LiveMatch` - │ (her 15dk) - ▼ -LiveUpdaterTask.updateLiveScores() - │ - └─→ LiveMatch score/state güncelle +Buradaki en onemli fikir: - │ (her 30dk) - ▼ -LiveUpdaterTask.finalizeFinishedMatches() - │ - └─→ LiveMatch → Match tablosuna migrate -``` +- `LiveMatch`: anlik, operasyonel, browse edilen, guncel mac yuzeyi +- `Match`: daha kalici, tarihsel, detayli kayit -### 11.2 Tahmin İstek Akışı +Bu iki tablo ayni seyi tekrar etmiyor; farkli amaclara hizmet ediyor. -``` -Client POST /api/coupon/analyze-match - │ - ▼ -SmartCouponService.analyzeMatch(matchId) - │ - ├─→ AI Engine POST /v20plus/analyze/{matchId} - │ │ - │ └─→ SingleMatchOrchestrator.analyze_match() - │ │ - │ └─→ DB'den veri çek → ML modeli → Tahmin paketi - │ - ├─→ GeminiService.generateText() (commentary, Türkçe) - │ - └─→ SingleMatchPredictionPackage döndür -``` +### 5.2 Mac detay ve istatistik ekosistemi ---- +Semada futbol ve basketbol ayrimi belirgin: -## 12. Konfigürasyon +- takim istatistikleri +- oyuncu istatistikleri +- oyuncu katilimlari +- event kayitlari +- official / referee rolleri +- odds history ve odds selection yapilari -### Environment Variables +Bu veri modeli, yalnizca skor saklamak degil; model feature uretimine uygun detay yakalamak icin kurulmus. -| Değişken | Açıklama | Default | -|----------|----------|---------| -| `NODE_ENV` | Ortam | `development` | -| `PORT` | Sunucu portu | `3005` | -| `DATABASE_URL` | PostgreSQL bağlantısı | — | -| `JWT_SECRET` | JWT imza anahtarı | — | -| `JWT_ACCESS_EXPIRATION` | Access token süresi | `15m` | -| `JWT_REFRESH_EXPIRATION` | Refresh token süresi | `7d` | -| `REDIS_ENABLED` | Redis/BullMQ aktif mi | `false` | -| `REDIS_HOST` | Redis host | `localhost` | -| `REDIS_PORT` | Redis port | `6379` | -| `AI_ENGINE_URL` | Python AI Engine URL | `http://127.0.0.1:8000` | -| `ENABLE_GEMINI` | Gemini AI aktif mi | `false` | -| `GOOGLE_API_KEY` | Gemini API anahtarı | — | +### 5.3 AI ve prediction tarafi -### Config Dosyaları +- `football_ai_features` +- `basketball_ai_features` +- `team_elo_ratings` +- `predictions` +- `ai_predictions_log` -| Dosya | Açıklama | -|-------|----------| -| `top_leagues.json` | Futbol top lig ID'leri (canlı maç filtresi) | -| `basketball_top_leagues.json` | Basketbol top lig ID'leri | -| `src/config/configuration.ts` | NestJS config factory'leri | -| `src/config/env.validation.ts` | Zod ile env doğrulama | +Bu taraf, AI cevabini yalnizca gecici tutmuyor; cache, audit ve yeniden kullanim katmani da olusturuyor. ---- +### 5.4 Kullanici urunu tarafi -## 13. Build & Run Komutları +- `users` +- `refresh_tokens` +- `usage_limits` +- `analyses` +- `user_coupons` +- `user_coupon_items` -```bash -# Development -npm run start:dev # NestJS watch mode (port 3005) +Bu, urunun salt "tahmin motoru" olmayip kullanici tabanli SaaS urune dogru evrildigini gosteriyor. -# Production -npm run build && npm run start:prod +### 5.5 Spor Toto domain'i -# Feeder (Data Collection) -npm run feeder:historical # Tarihsel veri taraması (2023-06→bugün) -npm run feeder:fill-gaps # Eksik veri tamamlama -npm run feeder:basketball # Basketbol verisi -npm run feeder:live # Canlı veri +- `toto_bulletins` +- `toto_bulletin_matches` +- `toto_results` +- `toto_coupons` +- `toto_columns` -# Database -npx prisma generate # Prisma client güncelle -npx prisma migrate dev # Migration çalıştır -npx prisma db seed # Seed data +Bu kisim, sistemin normal match prediction hattindan ayri ama ona paralel bir urun cizgisi. -# Testing -npm run test # Unit testler -npm run test:e2e # E2E testler +## 6. Ana moduller ve rolleri -# AI Engine (Python) -cd ai-engine && uvicorn main:app --host 0.0.0.0 --port 8000 --reload +### 6.1 `auth` -# Swagger -npm run swagger:summary # Endpoint export -``` +Giris, kayit, refresh, logout. JWT tabanli. Refresh token veritabaninda tutuluyor. Frontend NextAuth credentials akisi bunu tuketiyor. ---- +### 6.2 `users` -## 14. Docker Deployment +Kullanici profili, sifre guncelleme, kendi bilgilerini cekme ve kullaniciya ait bazi istatistikler. Bu modul, kullanici urun tarafinin merkezlerinden biri. -```yaml -# docker-compose.yml (3 servis) -services: - backend: # NestJS (port 3005) - ai-engine: # Python FastAPI (port 8000) - postgres: # PostgreSQL 16 (port 5432) - redis: # Redis (opsiyonel, port 6379) -``` +### 6.3 `admin` ---- +Admin analytics ve kullanici listeleri gibi yonetimsel endpointler var. Frontend'de admin content ekranina veri sagliyor. -## 15. Bilinen Durumlar ve Notlar +### 6.4 `matches` -1. **Predictions modülü** Redis gerektirir (`REDIS_ENABLED=true`). Redis yoksa bu modül yüklenmez. -2. **Gemini AI** opsiyoneldir. Devre dışıyken maç yorumu `null` döner. -3. **Feeder V17 AI feature hesaplama** devre dışı bırakılmıştır — V20 modeli Python tarafında çalışır. -4. **Lineup scraping** V20 optimizasyonu için devre dışıdır — sadece Team Stats kullanılır. -5. **Global Exception Filter** tüm hataları HTTP 200 olarak döner, gerçek status body içindedir. -6. **Abonelik sistemi** Free/Active/Expired — Free: 10 analiz + 3 kupon/gün, Active: 50 + 10. -7. **Veri kaynağı** tek: Mackolik.com — hem canlı API hem HTML scraping. -8. **Social Poster** Twitter API v2 ile çalışır, Instagram/Meta henüz implemente edilmemiştir. +Backend'in browse katmani. Genellikle aktif / upcoming / live maclari `live_matches` uzerinden verir. Mac detayinda bazen `matches` tablosuna, bazen fallback ile `live_matches` tablosuna bakar. ---- +Bu modul kullanicinin "sistemde su an ne var?" sorusuna cevap verir. -## 16. Canlı Veritabanı Analizi (MCP ile PostgreSQL Sorguları) +### 6.5 `leagues` -> Aşağıdaki veriler **2026-03-12** tarihinde canlı veritabanından çekilmiştir. +Ligler, ulkeler, takim arama, bazi h2h veya takim merkezli look-up akislarini besler. -### 16.1 Genel İstatistikler +### 6.6 `predictions` -| Metrik | Değer | -|--------|-------| -| **Toplam DB Boyutu** | **3,658 MB (3.6 GB)** | -| **Toplam Tablo** | 27 | -| **Veri Aralığı** | 2023-06-01 → 2026-03-12 | +Modern AI prediction hattinin merkezi moduludur. -### 16.2 Tablo Boyutları (Büyükten Küçüğe) +Yaptigi ana isler: -| Tablo | Kayıt Sayısı | Toplam Boyut | Veri | Index | -|-------|-------------|--------------|------|-------| -| `odd_selections` | **8,511,132** | 1,070 MB | 543 MB | 526 MB | -| `match_player_participation` | **3,342,839** | 1,077 MB | 430 MB | 648 MB | -| `odd_categories` | **3,161,172** | 689 MB | 294 MB | 394 MB | -| `match_player_events` | **1,453,227** | 356 MB | 239 MB | 117 MB | -| `match_player_stats` | **344,688** | 120 MB | 60 MB | 60 MB | -| `match_team_stats` | **310,991** | 91 MB | 37 MB | 54 MB | -| `match_officials` | **340,824** | 75 MB | 29 MB | 47 MB | -| `matches` | **236,859** | 100 MB | 60 MB | 40 MB | -| `players` | **217,040** | 64 MB | 26 MB | 37 MB | -| `teams` | **19,595** | 5.2 MB | — | — | -| `leagues` | **1,505** | 760 KB | — | — | -| `live_matches` | **82** | 1 MB | — | — | -| `countries` | **160** | 120 KB | — | — | -| `match_ai_features` | **279** | 152 KB | — | — | -| `predictions` | **3** | 192 KB | — | — | -| `users` | **1** | 80 KB | — | — | -| `refresh_tokens` | **8** | 80 KB | — | — | -| `usage_limits` | **1** | 80 KB | — | — | -| `app_settings` | **3** | 64 KB | — | — | -| `official_roles` | **5** | 48 KB | — | — | -| `translations` | **0** | 48 KB | — | — | -| `analyses` | **0** | 32 KB | — | — | -| `odds_history` | **0** | 32 KB | — | — | -| `user_coupons` | **0** | 32 KB | — | — | -| `ai_predictions_log` | **0** | 32 KB | — | — | -| `user_coupon_items` | **0** | 24 KB | — | — | +1. Match uygun mu kontrol et. +2. Gerekli veri var mi bak. +3. Cache'de gecerli prediction var mi kontrol et. +4. Yoksa AI motorunu cagir. +5. Gelen cevabi frontend'in kullanacagi zengin response'a cevir. -### 16.3 Spor Bazlı Dağılım +Burada tek bir "winner" alani yok; confidence, signal, edge, playable/not playable gibi karar destegi unsurlari da uretilir. -| Spor | Maç Sayısı | Lig Sayısı | Takım (~) | Ort. Ev Skoru | Ort. Deplasman Skoru | -|------|-----------|-----------|----------|--------------|---------------------| -| **Futbol** | 189,291 | 1,094 | ~23,958 | **1.55** | **1.27** | -| **Basketbol** | 47,568 | 304 | ~5,770 | **84.36** | **81.57** | +### 6.7 `coupons` -### 16.4 Maç Olayları Dağılımı +Iki farkli damar gorunuyor: -| Olay Tipi | Toplam | -|-----------|--------| -| `substitute` (Oyuncu Değişikliği) | **787,101** | -| `card` (Kart) | **409,136** | -| `goal` (Gol) | **256,990** | +- daha eski veya daha basit kupon servis mantigi +- asil AI destekli akis: `smart-coupon.service.ts` -### 16.5 Canlı Maçlar (live_matches — 82 Kayıt) +`SmartCouponService` kullaniciya strateji bazli kupon olusturmak icin AI motoruna gider. Ayrica bazi yorum/icerik zenginlestirme taraflarinda Gemini benzeri servislerle entegrasyon izi var. -| Spor | Durum | Status | Sayı | -|------|-------|--------|------| -| Futbol | `pre` (başlamamış) | timestamp | 41 | -| Futbol | `post` (bitmiş) | state | 4 | -| Futbol | `live` (canlı) | minutes | 1 | -| Basketbol | `pre` (başlamamış) | timestamp | 23 | -| Basketbol | `post` (bitmiş) | state | 13 | +Kullanici kupon kayitlari ise ayri bir servisle veritabanina yazilir ve basit settlement mantigi ile sonradan sonuclandirilabilir. -### 16.6 Türkiye Ligleri (En Çok Maç) +### 6.8 `analysis` -| Lig | Maç Sayısı | -|-----|-----------| -| Nesine 3. Lig | 1,511 | -| Nesine 2. Lig | 1,295 | -| Trendyol 1. Lig | 988 | -| Trendyol Süper Lig | 959 | -| Türkiye Sigorta BSL (Basketbol) | 637 | -| Türkiye Sigorta TBL (Basketbol) | 450 | -| Ziraat Türkiye Kupası | 438 | -| Halkbank KBSL (Kadınlar Basketbol) | 436 | -| Halkbank Kadınlar Basketbol 1.Ligi | 383 | +Bu taraf daha legacy gorunuyor. Akis su tipte: -### 16.7 Global Top 10 Lig (En Çok Maç) +- URL veya match bazli girdi al +- scraping veya veri cekme yap +- AI cagrisi yap +- sonucu `analyses` tablosuna kaydet -| Lig | Ülke | Maç | -|-----|------|-----| -| Segunda Lig RFEF | İspanya | 3,848 | -| Non Lig Premier | İngiltere | 3,553 | -| NBA | ABD | 3,529 | -| Bölgesel Lig | Almanya | 3,457 | -| Hazırlık Maçları | Dünya | 3,454 | -| Serie C | İtalya | 2,923 | -| Ulusal Lig N/S | İngiltere | 2,843 | -| RFEF 3. Lig | İspanya | 2,297 | -| 2. Lig | İsveç | 2,202 | -| 3. Lig | Norveç | 2,188 | +Yeni prediction hattina gore daha eski jenerasyon bir urun parcasi gibi duruyor. -### 16.8 Feeder Scan Durumu (app_settings) +### 6.9 `spor-toto` -| Scan Job | Son İşlenen Tarih | Güncelleme | -|----------|-------------------|------------| -| `historical_scan_state_football_basketball` | **2026-03-11** | 2026-03-11 21:03 | -| `historical_scan_state_football_filtered_desc` | **2025-09-01** | 2026-02-26 09:27 | -| `historical_scan_state_football_filtered` | **2023-10-16** | 2026-02-12 18:03 | +Projenin en ayri domainlerinden biri. -### 16.9 Kullanıcı Durumu +Sadece mac tahmini yapmak yerine: -| Metrik | Değer | -|--------|-------| -| Toplam kullanıcı | **1** (test@test.com) | -| Rol | `user` | -| Abonelik | `free` | -| Kayıt tarihi | 2026-02-09 | -| Aktif kupon | 0 | -| Analiz geçmişi | 0 | +- resmi bultenleri senkronize eder +- 15 maclik Toto veri modelini tutar +- sistem/kolon mantigi ile kupon uretir +- sonuc degerlendirir +- rollover ve istatistik sunar -### 16.10 Hakem Rolleri +Bu modul kendi basina yari-ayri urun gibi dusunulebilir. -| ID | Rol | -|----|-----| -| 1 | Orta Hakem | -| 2 | Yardımcı Hakem | -| 3 | 4. Hakem | -| 4 | VAR | -| 5 | AVAR | +### 6.10 `feeder` -### 16.11 Boş/Kullanılmamış Tablolar +Dis veri akisinin duzeni burada. Bu modul: -Aşağıdaki tablolar henüz production verisine sahip değildir: +- dis kaynak verisini toplar +- donusturur +- normalize eder +- kaydeder +- bazen medya/logo indirir -- `translations` — DB tabanlı i18n (dosya tabanlı i18n kullanılıyor) -- `analyses` — Kullanıcı analiz kayıtları (kullanıcı yok) -- `odds_history` — Oran değişim takibi (henüz implemente edilmemiş) -- `user_coupons` / `user_coupon_items` — Kullanıcı kuponları (kullanıcı yok) -- `ai_predictions_log` — AI tahmin loglama (cleanup job siliyor veya henüz üretilmemiş) +Prediction kalitesinin tabani buradadir. + +## 7. Veri toplama akislari + +### 7.1 Canli ve guncel veri toplama + +`data-fetcher.task.ts` belirli araliklarla calisir ve sunlari yapar: + +- mac listesini yeniler +- canli skor gunceller +- oran ceker +- hakem/kadro/eksik oyuncu datasi toplar +- bazi eksik alanlari tamamlamaya calisir + +Pratikte "uygulamada bugun ne var?" sorusunu bu task besler. + +### 7.2 Tarihsel sync + +`historical-results-sync.task.ts`, tamamlanmis maclari tarihsel ana tablolara tasir ve uzun vadeli feature / history / analysis ihtiyacini destekler. + +### 7.3 Limit ve bakim gorevleri + +`limit-resetter.task.ts`: + +- kullanim limitlerini resetler +- eski verileri temizler +- gerekli periyodik bakim islerini yapar + +### 7.4 Historical feeder modu + +`FEEDER_MODE=historical` gibi davranislar ile uygulama schedule/queue davranisini degistirebilir. Bu, backfill ve buyuk tarihsel veri yukleme senaryolari icin dusunulmus. + +## 8. Veri kaynagi ve persistence mantigi + +Feeder'in persistence katmani sadece `match` satiri yazmaz. Genel akil: + +1. Ulkeyi bul veya olustur. +2. Ligi bul veya olustur. +3. Takimlari bul veya olustur. +4. Mac kaydini upsert et. +5. Oyunculari ve katilimlarini yaz. +6. Event / istatistik / odds verisini kaydet. +7. Gerekirse logolari indir ve storage/public altina al. + +Bu nedenle sistemin veri modeli oldukca "sports data warehouse" tarzi okunabilir. + +## 9. AI entegrasyonu gercekte nasil calisiyor? + +Backend tarafi tek basina model calistirmiyor; Python motorunu tuketiyor. + +Temel endpoint ailesi: + +- tek mac analiz +- kupon olusturma +- daily banker benzeri guvenli secim uretimi +- bazi ozel market/htft/reversal benzeri analizler +- health check + +Prediction servisinde cagin fark yaratan kisimlar: + +- stale/valid prediction cache kontrolu +- model versiyon kontrolu +- odds mevcut mu kontrolu +- AI cevabini urun diline cevirmek +- translation/reason enrichment +- playable market secimi + +Yani backend, AI motoru ile frontend arasinda yalnizca proxy degil; is mantigi katmanidir. + +## 10. Python AI motorunun backend acisindan anlami + +Repo icindeki `ai-engine` klasoru teknik olarak ayrik servis olsa da urun acisindan backend'in uzantisidir. + +Orchestrator tarafinda sunlar birlestiriliyor: + +- match metadata +- odds ve implied probability +- lineup/availability quality +- league reliability +- model ensemble signal'lari +- risk katmanlari +- confidence calibration +- stake / Kelly benzeri kararlar + +Sonuc olarak sadece "MS1" degil; kullanilabilirlik ve edge mantigi uretiliyor. + +Bu nedenle bu proje klasik tahmin API'sinden daha cok "bet selection engine" gibi davranir. + +## 11. Kullanici kuponlari + +Kullanici kuponlarinin iki farkli yuzeyi var: + +- AI'nin onerdigi akilli kupon +- kullanicinin kendi sectigi kuponu kaydetme / listeleme / settlement + +Settlement mantigi tam bir odds engine kadar derin degil; bazi marketlerde string ve temel score karsilastirmalari ile sonuclandirma yapildigi gorunuyor. Bu kisim prod icin yeterli olabilir ama her market turu icin evrensel bir settlement motoru degil. + +## 12. Swagger ve endpoint yuzeyi + +Projede endpoint ozet export scriptleri ve swagger summary dosyalari var. Bu da ekibin API yuzeyini en azindan belgelemeye calistigini gosteriyor. + +Ana endpoint aileleri: + +- `/auth/*` +- `/users/*` +- `/admin/*` +- `/matches/*` +- `/leagues/*` +- `/predictions/*` +- `/coupons/*` +- `/analysis/*` +- `/spor-toto/*` +- `/health` + +Bu endpoint ailesi backend'in urun sinirlarini da iyi anlatiyor. + +## 13. Dikkat edilmesi gereken tasarim kararlari ve tutarsizliklar + +Bu kisim "bug listesi" degil; repo'yu okuyacak AI icin gercek durum notudur. + +### 13.1 HTTP 200 ile hata donme davranisi + +Client tarafinda normal REST beklentisi kurulmamalidir. Body-level status kontrolu gerekir. + +### 13.2 `live_matches` ve `matches` ayrimi cok onemli + +Bu ayrimi kaciran biri backend'i yanlis okur. Tum endpointler ayni kaynaktan beslenmez. + +### 13.3 Legacy ve modern prediction akislari birlikte yasiyor + +`analysis` daha eski, `predictions` + `smart coupon` daha yeni omurga gibi duruyor. + +### 13.4 Soft delete katmani ile schema birebir ortusmuyor olabilir + +Prisma service tarafinda daha genel bir soft delete dusuncesi var ama schema tarafinda bunun her modelde ayni derinlikte uygulanmadigi izlenimi var. + +### 13.5 Queue altyapisi zorunlu degil + +Sistem Redis olmadan da belli modlarda calisabilecek sekilde esnetilmis. + +## 14. Bu backend'i en dogru nasil zihinde tutmali? + +Asagidaki cumle projeyi en iyi ozetler: + +Bu backend, spor verisi toplayan, bunu normalize eden, tarihsel ve canli veri katmanlarini ayiran, AI tabanli bahis karar destek ciktilarini cache'leyip urunlestiren, bunun ustune kupon ve Spor Toto deneyimleri kuran bir NestJS platformudur. + +## 15. AI veya yeni gelisen ekip arkadasi icin kisa okuma sirasi + +Bu repo'yu yeni okuyan biri icin en verimli sira: + +1. `README.md` +2. `prompt.md` +3. `prisma/schema.prisma` +4. `src/app.module.ts` +5. `src/modules/matches/*` +6. `src/modules/predictions/*` +7. `src/modules/coupons/services/smart-coupon.service.ts` +8. `src/modules/spor-toto/*` +9. `src/modules/feeder/*` +10. `src/tasks/*` +11. `ai-engine/main.py` +12. `ai-engine/services/single_match_orchestrator.py` + +## 16. Son soz + +Bu backend'in asil degeri CRUD endpointlerinden degil, su bilesimden gelir: + +- veri toplama kalitesi +- veri modeli derinligi +- AI entegrasyon orkestra mantigi +- urunlestirilmis prediction/coupon/toto akislari + +Bir AI bu repo'yu anlayacaksa, bu projeyi "NestJS API" olarak degil "sports betting intelligence platform backend" olarak okumali. diff --git a/src/common/constants/roles.ts b/src/common/constants/roles.ts new file mode 100644 index 0000000..c5cdd8c --- /dev/null +++ b/src/common/constants/roles.ts @@ -0,0 +1,12 @@ +import { UserRole } from "@prisma/client"; + +export const APP_ROLES = { + user: UserRole.user, + superadmin: UserRole.superadmin, +} as const; + +export const ADMIN_ROLES = [APP_ROLES.superadmin] as const; + +export function normalizeRole(role: string | null | undefined): string { + return role?.trim().toLowerCase() ?? ""; +} diff --git a/src/common/utils/ai-engine-client.ts b/src/common/utils/ai-engine-client.ts new file mode 100644 index 0000000..95f0862 --- /dev/null +++ b/src/common/utils/ai-engine-client.ts @@ -0,0 +1,267 @@ +import axios, { + AxiosError, + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, +} from "axios"; +import { Logger } from "@nestjs/common"; + +export type AiCircuitState = "closed" | "open"; + +export interface AiEngineClientOptions { + baseUrl: string; + logger: Logger; + serviceName: string; + timeoutMs?: number; + maxRetries?: number; + retryDelayMs?: number; + circuitBreakerThreshold?: number; + circuitBreakerCooldownMs?: number; +} + +interface AiEngineRequestConfig extends AxiosRequestConfig { + retryCount?: number; +} + +export interface AiEngineClientSnapshot { + state: AiCircuitState; + consecutiveFailures: number; + openedAt: string | null; +} + +export class AiEngineRequestError extends Error { + status?: number; + detail?: unknown; + isCircuitOpen: boolean; + + constructor( + message: string, + options: { + status?: number; + detail?: unknown; + isCircuitOpen?: boolean; + } = {}, + ) { + super(message); + this.name = "AiEngineRequestError"; + this.status = options.status; + this.detail = options.detail; + this.isCircuitOpen = options.isCircuitOpen ?? false; + } +} + +export class AiEngineClient { + private readonly axiosClient: AxiosInstance; + private readonly logger: Logger; + private readonly serviceName: string; + private readonly defaultTimeoutMs: number; + private readonly maxRetries: number; + private readonly retryDelayMs: number; + private readonly circuitBreakerThreshold: number; + private readonly circuitBreakerCooldownMs: number; + + private consecutiveFailures = 0; + private circuitOpenedAt: number | null = null; + + constructor(options: AiEngineClientOptions) { + this.logger = options.logger; + this.serviceName = options.serviceName; + this.defaultTimeoutMs = options.timeoutMs ?? 30000; + this.maxRetries = options.maxRetries ?? 2; + this.retryDelayMs = options.retryDelayMs ?? 750; + this.circuitBreakerThreshold = options.circuitBreakerThreshold ?? 3; + this.circuitBreakerCooldownMs = + options.circuitBreakerCooldownMs ?? 30000; + + this.axiosClient = axios.create({ + baseURL: options.baseUrl, + timeout: this.defaultTimeoutMs, + }); + } + + async get( + path: string, + config?: AiEngineRequestConfig, + ): Promise> { + return this.request({ + method: "get", + url: path, + ...config, + }); + } + + async post( + path: string, + data?: unknown, + config?: AiEngineRequestConfig, + ): Promise> { + return this.request({ + method: "post", + url: path, + data, + ...config, + }); + } + + getSnapshot(): AiEngineClientSnapshot { + return { + state: this.isCircuitOpen() ? "open" : "closed", + consecutiveFailures: this.consecutiveFailures, + openedAt: this.circuitOpenedAt + ? new Date(this.circuitOpenedAt).toISOString() + : null, + }; + } + + private async request(config: AiEngineRequestConfig): Promise> { + this.ensureCircuitAvailable(); + + const retries = this.resolveRetryCount(config); + let lastError: unknown; + + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + const response = await this.axiosClient.request({ + timeout: this.defaultTimeoutMs, + ...config, + }); + + this.resetFailures(); + return response; + } catch (error) { + lastError = error; + const shouldRetry = attempt < retries && this.isRetriableError(error); + + if (!shouldRetry) { + this.registerFailure(error); + throw this.toRequestError(error); + } + + this.logger.warn( + `[${this.serviceName}] AI request retry ${attempt + 1}/${retries} for ${config.method?.toUpperCase()} ${config.url}`, + ); + await this.delay(this.retryDelayMs * (attempt + 1)); + } + } + + this.registerFailure(lastError); + throw this.toRequestError(lastError); + } + + private resolveRetryCount(config: AiEngineRequestConfig): number { + if (typeof config.retryCount === "number" && config.retryCount >= 0) { + return config.retryCount; + } + + return this.maxRetries; + } + + private ensureCircuitAvailable() { + if (!this.isCircuitOpen()) { + return; + } + + const remainingCooldown = + this.circuitBreakerCooldownMs - (Date.now() - (this.circuitOpenedAt ?? 0)); + + if (remainingCooldown > 0) { + throw new AiEngineRequestError("AI engine circuit breaker is open", { + status: 503, + detail: { + cooldownRemainingMs: remainingCooldown, + }, + isCircuitOpen: true, + }); + } + + this.logger.warn( + `[${this.serviceName}] AI circuit breaker cooldown elapsed, allowing a recovery attempt`, + ); + this.circuitOpenedAt = null; + } + + private isCircuitOpen(): boolean { + return this.circuitOpenedAt !== null; + } + + private resetFailures() { + this.consecutiveFailures = 0; + this.circuitOpenedAt = null; + } + + private registerFailure(error: unknown) { + this.consecutiveFailures += 1; + + const normalizedError = this.toRequestError(error); + this.logger.warn( + `[${this.serviceName}] AI request failed (${this.consecutiveFailures}/${this.circuitBreakerThreshold}): ${normalizedError.message}`, + ); + + if (this.consecutiveFailures >= this.circuitBreakerThreshold) { + this.circuitOpenedAt = Date.now(); + this.logger.error( + `[${this.serviceName}] AI circuit breaker opened after ${this.consecutiveFailures} consecutive failures`, + ); + } + } + + private isRetriableError(error: unknown): boolean { + if (!axios.isAxiosError(error)) { + return false; + } + + if (!error.response) { + return true; + } + + const status = error.response.status; + return status >= 500 || status === 429 || error.code === "ECONNABORTED"; + } + + private toRequestError(error: unknown): AiEngineRequestError { + if (error instanceof AiEngineRequestError) { + return error; + } + + if (axios.isAxiosError(error)) { + const detail = error.response?.data ?? error.message; + const status = error.response?.status; + const message = this.buildAxiosErrorMessage(error); + + return new AiEngineRequestError(message, { + status, + detail, + }); + } + + if (error instanceof Error) { + return new AiEngineRequestError(error.message); + } + + return new AiEngineRequestError("Unknown AI engine error", { + detail: error, + }); + } + + private buildAxiosErrorMessage(error: AxiosError): string { + if (error.code === "ECONNABORTED") { + return "AI engine request timed out"; + } + + if (!error.response) { + return "AI engine is unreachable"; + } + + const detail = + (error.response.data as Record | undefined)?.detail ?? + error.message; + + return typeof detail === "string" + ? detail + : `AI engine request failed with status ${error.response.status}`; + } + + private async delay(ms: number) { + await new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/common/utils/match-status.util.ts b/src/common/utils/match-status.util.ts new file mode 100644 index 0000000..1db15fc --- /dev/null +++ b/src/common/utils/match-status.util.ts @@ -0,0 +1,203 @@ +type ScoreLikeValue = number | string | null | undefined; + +type ScoreLike = { + home?: ScoreLikeValue; + away?: ScoreLikeValue; +} | null; + +export interface MatchStatusLike { + state?: string | null; + status?: string | null; + substate?: string | null; + statusBoxContent?: string | null; + scoreHome?: ScoreLikeValue; + scoreAway?: ScoreLikeValue; + score?: ScoreLike; +} + +const LIVE_STATUS_TOKENS = [ + "live", + "livegame", + "playing", + "half time", + "halftime", + "1h", + "2h", + "ht", + "1q", + "2q", + "3q", + "4q", +]; + +const LIVE_STATE_TOKENS = [ + "live", + "livegame", + "firsthalf", + "secondhalf", + "halftime", + "1h", + "2h", + "ht", + "1q", + "2q", + "3q", + "4q", +]; + +const FINISHED_STATUS_TOKENS = [ + "finished", + "played", + "ft", + "aet", + "pen", + "penalties", + "afterpenalties", + "ended", + "post", + "postgame", + "posted", +]; + +const FINISHED_STATE_TOKENS = [ + "finished", + "post", + "postgame", + "posted", + "ft", + "ended", +]; + +export const LIVE_STATUS_VALUES_FOR_DB = [ + "LIVE", + "live", + "1H", + "2H", + "HT", + "1Q", + "2Q", + "3Q", + "4Q", + "Playing", + "Half Time", + "liveGame", +]; + +export const LIVE_STATE_VALUES_FOR_DB = [ + "live", + "liveGame", + "firsthalf", + "secondhalf", + "halfTime", + "1H", + "2H", + "HT", + "1Q", + "2Q", + "3Q", + "4Q", +]; + +export const FINISHED_STATUS_VALUES_FOR_DB = [ + "Finished", + "Played", + "FT", + "AET", + "PEN", + "Ended", + "post", + "postGame", + "posted", + "Posted", +]; + +export const FINISHED_STATE_VALUES_FOR_DB = [ + "Finished", + "post", + "postGame", + "postgame", + "posted", + "FT", + "Ended", +]; + +function normalizeToken(value: unknown): string { + return String(value || "") + .trim() + .toLowerCase(); +} + +function parseScoreValue(value: ScoreLikeValue): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +export function hasResolvedScore(match: MatchStatusLike): boolean { + const homeScore = parseScoreValue(match.score?.home ?? match.scoreHome); + const awayScore = parseScoreValue(match.score?.away ?? match.scoreAway); + return homeScore !== null && awayScore !== null; +} + +export function isMatchLive(match: MatchStatusLike): boolean { + const state = normalizeToken(match.state); + const status = normalizeToken(match.status); + const substate = normalizeToken(match.substate); + + return ( + LIVE_STATE_TOKENS.includes(state) || + LIVE_STATUS_TOKENS.includes(status) || + LIVE_STATE_TOKENS.includes(substate) + ); +} + +export function isMatchCompleted(match: MatchStatusLike): boolean { + if (normalizeToken(match.statusBoxContent) === "ert") { + return false; + } + + const state = normalizeToken(match.state); + const status = normalizeToken(match.status); + const substate = normalizeToken(match.substate); + + if ( + FINISHED_STATE_TOKENS.includes(state) || + FINISHED_STATUS_TOKENS.includes(status) || + FINISHED_STATE_TOKENS.includes(substate) + ) { + return true; + } + + return hasResolvedScore(match) && !isMatchLive(match); +} + +export function deriveStoredMatchStatus(match: MatchStatusLike): string { + if (normalizeToken(match.statusBoxContent) === "ert") { + return "POSTPONED"; + } + + if (isMatchLive(match)) { + return "LIVE"; + } + + if (isMatchCompleted(match)) { + return "FT"; + } + + return "NS"; +} + +export function getDisplayMatchStatus(match: MatchStatusLike): string { + if (isMatchLive(match)) { + return "LIVE"; + } + + if (isMatchCompleted(match)) { + return "Finished"; + } + + return String(match.status || match.state || "NS"); +} diff --git a/src/common/utils/timezone.util.ts b/src/common/utils/timezone.util.ts new file mode 100644 index 0000000..f2c6e5d --- /dev/null +++ b/src/common/utils/timezone.util.ts @@ -0,0 +1,82 @@ +function extractDateParts(date: Date, timeZone: string) { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + + const parts = formatter.formatToParts(date); + const year = Number(parts.find((part) => part.type === "year")?.value); + const month = Number(parts.find((part) => part.type === "month")?.value); + const day = Number(parts.find((part) => part.type === "day")?.value); + + return { year, month, day }; +} + +export function getDateStringInTimeZone( + date: Date, + timeZone: string, +): string { + const { year, month, day } = extractDateParts(date, timeZone); + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +export function getShiftedDateStringInTimeZone( + daysOffset: number, + timeZone: string, + baseDate: Date = new Date(), +): string { + const { year, month, day } = extractDateParts(baseDate, timeZone); + const shifted = new Date(Date.UTC(year, month - 1, day)); + shifted.setUTCDate(shifted.getUTCDate() + daysOffset); + return shifted.toISOString().split("T")[0]; +} + +function getTimeZoneOffsetMs(date: Date, timeZone: string): number { + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + timeZoneName: "shortOffset", + }); + + const offsetLabel = + formatter.formatToParts(date).find((part) => part.type === "timeZoneName") + ?.value || "GMT+0"; + + const match = offsetLabel.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/); + if (!match) return 0; + + const sign = match[1] === "-" ? -1 : 1; + const hours = Number(match[2] || "0"); + const minutes = Number(match[3] || "0"); + + return sign * (hours * 60 + minutes) * 60 * 1000; +} + +export function getDayBoundsForTimeZone( + dateString: string, + timeZone: string, +): { startMs: number; endMs: number } { + const [year, month, day] = dateString.split("-").map(Number); + const startGuess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0)); + const nextDayGuess = new Date(Date.UTC(year, month - 1, day + 1, 0, 0, 0)); + + const startOffsetMs = getTimeZoneOffsetMs(startGuess, timeZone); + const nextDayOffsetMs = getTimeZoneOffsetMs(nextDayGuess, timeZone); + + const startMs = Date.UTC(year, month - 1, day, 0, 0, 0) - startOffsetMs; + const nextDayStartMs = + Date.UTC(year, month - 1, day + 1, 0, 0, 0) - nextDayOffsetMs; + + return { + startMs, + endMs: nextDayStartMs - 1, + }; +} + +export function getDateOnlyValueForTimeZone( + timeZone: string, + date: Date = new Date(), +): Date { + return new Date(`${getDateStringInTimeZone(date, timeZone)}T00:00:00.000Z`); +} diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index 767f84f..c004b9c 100755 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -259,15 +259,21 @@ export class AdminController { premiumUsers, totalMatches, totalPredictions, + totalCoupons, ] = await Promise.all([ this.prisma.user.count(), this.prisma.user.count({ where: { isActive: true } }), this.prisma.user.count({ where: { subscriptionStatus: "active" } }), this.prisma.match.count(), this.prisma.prediction.count(), + this.prisma.userCoupon.count(), ]); return createSuccessResponse({ + totalUsers, + activeUsers, + totalPredictions, + totalCoupons, users: { total: totalUsers, active: activeUsers, diff --git a/src/modules/auth/guards/auth.guards.ts b/src/modules/auth/guards/auth.guards.ts index 434ccda..1c54070 100755 --- a/src/modules/auth/guards/auth.guards.ts +++ b/src/modules/auth/guards/auth.guards.ts @@ -13,11 +13,13 @@ import { ROLES_KEY, PERMISSIONS_KEY, } from "../../../common/decorators"; +import { normalizeRole } from "../../../common/constants/roles"; interface AuthenticatedUser { id: string; email: string; roles: string[]; + role?: string; permissions: string[]; } @@ -88,11 +90,28 @@ export class RolesGuard implements CanActivate { const user = req.user as AuthenticatedUser | undefined; - if (!user || !user.roles) { + if (!user) { return false; } - const hasRole = requiredRoles.some((role) => user.roles.includes(role)); + const normalizedUserRoles = (user.roles?.length + ? user.roles + : user.role + ? [user.role] + : [] + ).map((role) => normalizeRole(role)); + + const normalizedRequiredRoles = requiredRoles.map((role) => + normalizeRole(role), + ); + + if (normalizedUserRoles.length === 0) { + return false; + } + + const hasRole = normalizedRequiredRoles.some((role) => + normalizedUserRoles.includes(role), + ); if (!hasRole) { throw new ForbiddenException("PERMISSION_DENIED"); } diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts index b44b7d6..c4f655d 100755 --- a/src/modules/auth/strategies/jwt.strategy.ts +++ b/src/modules/auth/strategies/jwt.strategy.ts @@ -3,6 +3,7 @@ import { PassportStrategy } from "@nestjs/passport"; import { ExtractJwt, Strategy } from "passport-jwt"; import { ConfigService } from "@nestjs/config"; import { AuthService, JwtPayload } from "../auth.service"; +import { normalizeRole } from "../../../common/constants/roles"; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -29,9 +30,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) { return null; } + const normalizedRole = normalizeRole(payload.role); + return { ...user, - role: payload.role, + role: normalizedRole, + roles: normalizedRole ? [normalizedRole] : [], + permissions: [], }; } } diff --git a/src/modules/coupons/services/smart-coupon.service.ts b/src/modules/coupons/services/smart-coupon.service.ts index d515630..b9db5ce 100755 --- a/src/modules/coupons/services/smart-coupon.service.ts +++ b/src/modules/coupons/services/smart-coupon.service.ts @@ -1,6 +1,9 @@ import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common"; -import axios from "axios"; import { GeminiService } from "../../gemini/gemini.service"; +import { + AiEngineClient, + AiEngineRequestError, +} from "../../../common/utils/ai-engine-client"; export type PredictionRiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME"; export type PredictionDataQuality = "HIGH" | "MEDIUM" | "LOW"; @@ -126,24 +129,34 @@ export interface SmartCouponResult { export class SmartCouponService { private readonly logger = new Logger(SmartCouponService.name); private readonly aiEngineUrl: string; + private readonly aiEngineClient: AiEngineClient; constructor(private readonly geminiService: GeminiService) { this.aiEngineUrl = process.env.AI_ENGINE_URL || "http://ai-engine:8000"; + this.aiEngineClient = new AiEngineClient({ + baseUrl: this.aiEngineUrl, + logger: this.logger, + serviceName: SmartCouponService.name, + timeoutMs: 60000, + maxRetries: 2, + retryDelayMs: 750, + }); } async analyzeMatch(matchId: string): Promise { let prediction: SingleMatchPredictionPackage; try { - const response = await axios.post( - `${this.aiEngineUrl}/v20plus/analyze/${matchId}`, + const response = await this.aiEngineClient.post( + `/v20plus/analyze/${matchId}`, ); prediction = response.data; - } catch (error) { - if (axios.isAxiosError(error)) { - const detail = error.response?.data?.detail || error.message; + } catch (error: unknown) { + if (error instanceof AiEngineRequestError) { + const detail = + typeof error.detail === "string" ? error.detail : error.message; throw new HttpException( `AI analyze failed: ${detail}`, - error.response?.status || HttpStatus.SERVICE_UNAVAILABLE, + error.status || HttpStatus.SERVICE_UNAVAILABLE, ); } throw new HttpException( @@ -205,8 +218,8 @@ export class SmartCouponService { options: { maxMatches?: number; minConfidence?: number } = {}, ): Promise { try { - const response = await axios.post( - `${this.aiEngineUrl}/v20plus/coupon`, + const response = await this.aiEngineClient.post( + "/v20plus/coupon", { match_ids: matchIds, strategy, @@ -215,13 +228,14 @@ export class SmartCouponService { }, ); return response.data; - } catch (error) { + } catch (error: unknown) { this.logger.error("Failed to generate smart coupon", error); - if (axios.isAxiosError(error)) { - const detail = error.response?.data?.detail || error.message; + if (error instanceof AiEngineRequestError) { + const detail = + typeof error.detail === "string" ? error.detail : error.message; throw new HttpException( `Coupon generation failed: ${detail}`, - error.response?.status || HttpStatus.SERVICE_UNAVAILABLE, + error.status || HttpStatus.SERVICE_UNAVAILABLE, ); } throw new HttpException( diff --git a/src/modules/feeder/feeder-persistence.service.ts b/src/modules/feeder/feeder-persistence.service.ts index 0a707ac..f35f7cb 100755 --- a/src/modules/feeder/feeder-persistence.service.ts +++ b/src/modules/feeder/feeder-persistence.service.ts @@ -22,6 +22,7 @@ import { BasketballTeamStats, } from "./feeder.types"; import { ImageUtils } from "../../common/utils/image.util"; +import { deriveStoredMatchStatus } from "../../common/utils/match-status.util"; @Injectable() export class FeederPersistenceService { @@ -311,33 +312,15 @@ export class FeederPersistenceService { 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"; - } + const status = deriveStoredMatchStatus({ + state: headerData?.matchStatus ?? matchSummary.state, + status: matchSummary.status, + substate: matchSummary.substate, + statusBoxContent: matchSummary.statusBoxContent, + scoreHome: finalScoreHome, + scoreAway: finalScoreAway, + score: matchSummary.score, + }); await tx.match.upsert({ where: { id: matchId }, @@ -870,15 +853,11 @@ export class FeederPersistenceService { } async getExistingMatchIds(matchIds: string[]): Promise { - // 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: {} } }, diff --git a/src/modules/feeder/feeder.service.ts b/src/modules/feeder/feeder.service.ts index e62fbca..d861074 100755 --- a/src/modules/feeder/feeder.service.ts +++ b/src/modules/feeder/feeder.service.ts @@ -24,6 +24,7 @@ import { DbEventPayload, DbMarketPayload, } from "./feeder.types"; +import { isMatchCompleted } from "../../common/utils/match-status.util"; interface ProcessDateOptions { onlyCompletedMatches?: boolean; @@ -113,51 +114,16 @@ export class FeederService { }; } - private parseScoreValue(value: unknown): number | null { - if (value === null || value === undefined || value === "") return null; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; - } - private isCompletedMatchSummary(match: MatchSummary): boolean { - if (match.statusBoxContent === "ERT") return false; - - const normalizedState = String(match.state || "") - .trim() - .toLowerCase(); - const normalizedStatus = String(match.status || "") - .trim() - .toLowerCase(); - const normalizedSubstate = String(match.substate || "") - .trim() - .toLowerCase(); - - if (["postgame", "post"].includes(normalizedState)) return true; - - if ( - ["played", "finished", "ft", "afterpenalties", "penalties"].includes( - normalizedStatus, - ) - ) { - return true; - } - - if ( - ["postgame", "post", "played", "finished", "ft"].includes( - normalizedSubstate, - ) - ) { - return true; - } - - const homeScore = this.parseScoreValue( - match.score?.home ?? match.homeScore, - ); - const awayScore = this.parseScoreValue( - match.score?.away ?? match.awayScore, - ); - - return homeScore !== null && awayScore !== null; + return isMatchCompleted({ + state: match.state, + status: match.status, + substate: match.substate, + statusBoxContent: match.statusBoxContent, + score: match.score, + scoreHome: match.homeScore, + scoreAway: match.awayScore, + }); } async runPreviousDayCompletedMatchesScan( @@ -957,15 +923,30 @@ export class FeederService { */ // ========================================== - if (saved && hasCriticalError) { - // Collect missing components - const missingParts: string[] = []; - if (!stats) missingParts.push("Stats"); + const completedMatch = isMatchCompleted({ + state: headerData?.matchStatus ?? matchSummary.state, + status: matchSummary.status, + substate: matchSummary.substate, + statusBoxContent: matchSummary.statusBoxContent, + scoreHome: headerData?.scoreHome ?? matchSummary.score?.home, + scoreAway: headerData?.scoreAway ?? matchSummary.score?.away, + }); + + const missingParts: string[] = []; + if (scope === "all" && completedMatch) { + if (sport === "football" && !stats) missingParts.push("Stats"); + if (sport === "basketball" && !basketballTeamStats) + missingParts.push("BoxScore"); if (oddsArray.length === 0) missingParts.push("Odds"); - if (officialsData.length === 0) missingParts.push("Officials"); + } + + if (saved && (hasCriticalError || missingParts.length > 0)) { + const reason = hasCriticalError + ? "missing data after upstream errors" + : "incomplete completed-match payload"; this.logger.warn( - `[${matchId}] Saved with MISSING DATA (502). Missing: [${missingParts.join(", ")}]. Scheduled for retry.`, + `[${matchId}] Saved with ${reason}. Missing: [${missingParts.join(", ")}]. Scheduled for retry.`, ); return { success: false, retryable: true }; } diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts index 3b4e206..dfa12cf 100755 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -1,44 +1,90 @@ -import { Controller, Get } from "@nestjs/common"; +import { Controller, Get, Res } from "@nestjs/common"; import { ApiTags, ApiOperation } from "@nestjs/swagger"; -import { - HealthCheck, - HealthCheckService, - PrismaHealthIndicator, -} from "@nestjs/terminus"; +import { Response } from "express"; import { Public } from "../../common/decorators"; import { PrismaService } from "../../database/prisma.service"; +import { PredictionsService } from "../predictions/predictions.service"; @ApiTags("Health") @Controller("health") export class HealthController { constructor( - private health: HealthCheckService, - private prismaHealth: PrismaHealthIndicator, private prisma: PrismaService, + private readonly predictionsService: PredictionsService, ) {} @Get() @Public() - @HealthCheck() @ApiOperation({ summary: "Basic health check" }) - check() { - return this.health.check([]); + async check(@Res() response: Response) { + const database = await this.getDatabaseHealth(); + const aiEngine = await this.predictionsService.checkHealth(); + const ok = database.status === "up" && aiEngine.predictionServiceReady; + + return response.status(ok ? 200 : 503).json({ + status: ok ? "ok" : "degraded", + timestamp: new Date().toISOString(), + checks: { + database, + aiEngine, + }, + }); } @Get("ready") @Public() - @HealthCheck() @ApiOperation({ summary: "Readiness check (includes database)" }) - readiness() { - return this.health.check([ - () => this.prismaHealth.pingCheck("database", this.prisma), - ]); + async readiness(@Res() response: Response) { + const database = await this.getDatabaseHealth(); + const aiEngine = await this.predictionsService.checkHealth(); + const ready = database.status === "up" && aiEngine.predictionServiceReady; + + return response.status(ready ? 200 : 503).json({ + status: ready ? "ready" : "not_ready", + timestamp: new Date().toISOString(), + checks: { + database, + aiEngine, + }, + }); } @Get("live") @Public() @ApiOperation({ summary: "Liveness check" }) - liveness() { - return { status: "ok", timestamp: new Date().toISOString() }; + liveness(@Res() response: Response) { + return response + .status(200) + .json({ status: "ok", timestamp: new Date().toISOString() }); + } + + @Get("dependencies") + @Public() + @ApiOperation({ summary: "Dependency-level health details" }) + async dependencies(@Res() response: Response) { + const database = await this.getDatabaseHealth(); + const aiEngine = await this.predictionsService.checkHealth(); + + return response.status(200).json({ + timestamp: new Date().toISOString(), + checks: { + database, + aiEngine, + }, + }); + } + + private async getDatabaseHealth() { + try { + await this.prisma.$queryRaw`SELECT 1`; + return { + status: "up", + }; + } catch (error: unknown) { + return { + status: "down", + detail: error instanceof Error ? error.message : "Unknown database error", + }; + } } } diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts index 0d85f39..686dd3f 100755 --- a/src/modules/health/health.module.ts +++ b/src/modules/health/health.module.ts @@ -1,11 +1,9 @@ import { Module } from "@nestjs/common"; -import { TerminusModule } from "@nestjs/terminus"; -import { PrismaHealthIndicator } from "@nestjs/terminus"; import { HealthController } from "./health.controller"; +import { PredictionsModule } from "../predictions/predictions.module"; @Module({ - imports: [TerminusModule], + imports: [PredictionsModule], controllers: [HealthController], - providers: [PrismaHealthIndicator], }) export class HealthModule {} diff --git a/src/modules/matches/matches.service.ts b/src/modules/matches/matches.service.ts index 7503b85..d608197 100755 --- a/src/modules/matches/matches.service.ts +++ b/src/modules/matches/matches.service.ts @@ -9,6 +9,13 @@ import { ActiveLeagueDto, } from "./dto"; import { Prisma } from "@prisma/client"; +import { + FINISHED_STATE_VALUES_FOR_DB, + FINISHED_STATUS_VALUES_FOR_DB, + LIVE_STATE_VALUES_FOR_DB, + LIVE_STATUS_VALUES_FOR_DB, + getDisplayMatchStatus, +} from "../../common/utils/match-status.util"; @Injectable() export class MatchesService { @@ -38,23 +45,12 @@ export class MatchesService { OR: [ { status: { - in: [ - "LIVE", - "1H", - "2H", - "HT", - "1Q", - "2Q", - "3Q", - "4Q", - "Playing", - "Half Time", - ], + in: LIVE_STATUS_VALUES_FOR_DB, }, }, { state: { - in: ["live", "firsthalf", "secondhalf"], + in: LIVE_STATE_VALUES_FOR_DB, }, }, ], @@ -66,14 +62,23 @@ export class MatchesService { OR: [ { status: { - in: ["Finished", "Played", "FT", "AET", "PEN", "Ended"], + in: FINISHED_STATUS_VALUES_FOR_DB, }, }, { state: { - in: ["Finished", "post", "FT", "postGame"], + in: FINISHED_STATE_VALUES_FOR_DB, }, }, + { + AND: [ + { scoreHome: { not: null } }, + { scoreAway: { not: null } }, + { + NOT: this.getLiveFilter(), + }, + ], + }, ], }; } @@ -325,16 +330,13 @@ export class MatchesService { } // Map status for frontend - let displayStatus = match.status || "NS"; - if (match.state === "live") { - displayStatus = "LIVE"; - } else if ( - match.state === "post" || - match.state === "FT" || - match.status === "Finished" - ) { - displayStatus = "Finished"; - } + const displayStatus = getDisplayMatchStatus({ + state: match.state, + status: match.status, + substate: match.substate, + scoreHome: match.scoreHome, + scoreAway: match.scoreAway, + }); league.matches.push({ id: match.id, @@ -562,16 +564,13 @@ export class MatchesService { if (liveMatch) { // Map liveMatch status - let displayStatus = liveMatch.status || "NS"; - if (liveMatch.state === "live") { - displayStatus = "LIVE"; - } else if ( - liveMatch.state === "post" || - liveMatch.state === "FT" || - liveMatch.status === "Finished" - ) { - displayStatus = "Finished"; - } + const displayStatus = getDisplayMatchStatus({ + state: liveMatch.state, + status: liveMatch.status, + substate: liveMatch.substate, + scoreHome: liveMatch.scoreHome, + scoreAway: liveMatch.scoreAway, + }); match = { ...liveMatch, diff --git a/src/modules/predictions/dto/index.ts b/src/modules/predictions/dto/index.ts index 016d385..7837d59 100755 --- a/src/modules/predictions/dto/index.ts +++ b/src/modules/predictions/dto/index.ts @@ -461,6 +461,21 @@ export class AIHealthDto { @ApiProperty() predictionServiceReady: boolean; + + @ApiProperty({ required: false, default: true }) + aiEngineReachable?: boolean; + + @ApiProperty({ required: false, enum: ["closed", "open"] }) + circuitState?: "closed" | "open"; + + @ApiProperty({ required: false, default: 0 }) + consecutiveFailures?: number; + + @ApiProperty({ required: false }) + endpoint?: string; + + @ApiProperty({ required: false, nullable: true }) + detail?: string | null; } export * from "./smart-coupon.dto"; diff --git a/src/modules/predictions/predictions.service.ts b/src/modules/predictions/predictions.service.ts index 1e76ddf..3c80cc1 100755 --- a/src/modules/predictions/predictions.service.ts +++ b/src/modules/predictions/predictions.service.ts @@ -19,11 +19,14 @@ import { ValueBetDto, AIHealthDto, } from "./dto"; -import axios, { AxiosError } from "axios"; import { Prisma } from "@prisma/client"; import { FeederService } from "../feeder/feeder.service"; import * as fs from "node:fs"; import * as path from "node:path"; +import { + AiEngineClient, + AiEngineRequestError, +} from "../../common/utils/ai-engine-client"; type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW"; @@ -45,6 +48,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(PredictionsService.name); private queueEvents: QueueEvents | null = null; private readonly aiEngineUrl: string; + private readonly aiEngineClient: AiEngineClient; private readonly topLeagueIds = new Set(); private readonly reasonTranslations: Record = { confidence_below_threshold: "Güven eşiğin altında", @@ -125,6 +129,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { "AI_ENGINE_URL", "http://localhost:8000", ); + this.aiEngineClient = new AiEngineClient({ + baseUrl: this.aiEngineUrl, + logger: this.logger, + serviceName: PredictionsService.name, + timeoutMs: 60000, + maxRetries: 2, + retryDelayMs: 750, + }); this.topLeagueIds = this.loadTopLeagueIds(); } @@ -149,12 +161,50 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { } } - checkHealth(): Promise { - return Promise.resolve({ - status: "healthy", - modelLoaded: true, - predictionServiceReady: true, - }); + async checkHealth(): Promise { + const circuit = this.aiEngineClient.getSnapshot(); + + try { + const response = await this.aiEngineClient.get<{ + status?: string; + model_loaded?: boolean; + prediction_service_ready?: boolean; + }>("/health", { + timeout: 5000, + retryCount: 0, + }); + + return { + status: response.data?.status || "healthy", + modelLoaded: response.data?.model_loaded ?? true, + predictionServiceReady: + response.data?.prediction_service_ready ?? true, + aiEngineReachable: true, + circuitState: circuit.state, + consecutiveFailures: circuit.consecutiveFailures, + endpoint: this.aiEngineUrl, + }; + } catch (error: unknown) { + const requestError = + error instanceof AiEngineRequestError + ? error + : new AiEngineRequestError("AI health check failed"); + + return { + status: requestError.isCircuitOpen ? "circuit_open" : "unhealthy", + modelLoaded: false, + predictionServiceReady: false, + aiEngineReachable: false, + circuitState: this.aiEngineClient.getSnapshot().state, + consecutiveFailures: + this.aiEngineClient.getSnapshot().consecutiveFailures, + endpoint: this.aiEngineUrl, + detail: + typeof requestError.detail === "string" + ? requestError.detail + : requestError.message, + }; + } } async getPredictionById(matchId: string): Promise { @@ -182,22 +232,21 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { // Direct HTTP mode (no Redis) try { - const response = await axios.post( - `${this.aiEngineUrl}/v20plus/analyze/${matchId}`, + const response = await this.aiEngineClient.post( + `/v20plus/analyze/${matchId}`, {}, - { timeout: 60000 }, ); return this.enrichPredictionResponse( response.data as MatchPredictionDto, matchContext, ); } catch (e: unknown) { - const error = e as AxiosError>; - const status = error?.response?.status; - const detail = - error?.response?.data?.detail || - error?.response?.data || - error?.message; + const requestError = + e instanceof AiEngineRequestError + ? e + : new AiEngineRequestError("AI Engine request failed"); + const status = requestError.status; + const detail = requestError.detail || requestError.message; this.logger.error( `Direct AI Engine call failed for ${matchId}: status=${status}, detail=${JSON.stringify(detail)}`, ); @@ -988,14 +1037,18 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { // Direct HTTP mode try { - const response = await axios.post( - `${this.aiEngineUrl}/smart-coupon`, + const response = await this.aiEngineClient.post( + "/smart-coupon", { match_ids: matchIds, strategy, ...options }, - { timeout: 60000 }, ); return response.data; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); + } catch (error: unknown) { + const message = + error instanceof AiEngineRequestError + ? error.message + : error instanceof Error + ? error.message + : String(error); this.logger.error(`Direct smart coupon call failed: ${message}`); this.throwAiError(message); } @@ -1018,6 +1071,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { HttpStatus.BAD_GATEWAY, ); } + if (message.includes("circuit breaker is open")) { + throw new HttpException( + "AI Engine is temporarily unavailable", + HttpStatus.SERVICE_UNAVAILABLE, + ); + } throw new HttpException( "Failed to get prediction from AI Engine", HttpStatus.SERVICE_UNAVAILABLE, diff --git a/src/modules/social-poster/social-poster.controller.ts b/src/modules/social-poster/social-poster.controller.ts index 6de2dfe..b62b2af 100644 --- a/src/modules/social-poster/social-poster.controller.ts +++ b/src/modules/social-poster/social-poster.controller.ts @@ -8,7 +8,7 @@ import { RolesGuard } from "../auth/guards/auth.guards"; @ApiTags("Social Poster") @ApiBearerAuth() @UseGuards(RolesGuard) -@Roles("admin") +@Roles("superadmin") @Controller("social-poster") export class SocialPosterController { constructor(private readonly socialPosterService: SocialPosterService) {} diff --git a/src/modules/spor-toto/spor-toto.controller.ts b/src/modules/spor-toto/spor-toto.controller.ts index 98de84c..3570ced 100644 --- a/src/modules/spor-toto/spor-toto.controller.ts +++ b/src/modules/spor-toto/spor-toto.controller.ts @@ -43,7 +43,7 @@ export class SporTotoController { @Post("sync") @UseGuards(JwtAuthGuard) - @Roles("admin") + @Roles("superadmin") @ApiBearerAuth() @HttpCode(HttpStatus.OK) @ApiOperation({ @@ -114,7 +114,7 @@ export class SporTotoController { @Post("bulletins") @UseGuards(JwtAuthGuard) - @Roles("admin") + @Roles("superadmin") @ApiBearerAuth() @HttpCode(HttpStatus.CREATED) @ApiOperation({ @@ -135,7 +135,7 @@ export class SporTotoController { @Patch("bulletins/:id/results") @UseGuards(JwtAuthGuard) - @Roles("admin") + @Roles("superadmin") @ApiBearerAuth() @HttpCode(HttpStatus.OK) @ApiOperation({ diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index ba38fa6..b0403de 100755 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -84,7 +84,7 @@ export class UsersController extends BaseController< } // Override create to require admin role - @Roles("admin") + @Roles("superadmin") async create( ...args: Parameters< BaseController["create"] @@ -94,7 +94,7 @@ export class UsersController extends BaseController< } // Override delete to require admin role - @Roles("admin") + @Roles("superadmin") async delete( ...args: Parameters< BaseController["delete"] diff --git a/src/services/ai.service.ts b/src/services/ai.service.ts index bf6bb82..6f4b0b2 100755 --- a/src/services/ai.service.ts +++ b/src/services/ai.service.ts @@ -1,7 +1,9 @@ import { Injectable, Logger } from "@nestjs/common"; -import { HttpService } from "@nestjs/axios"; import { ConfigService } from "@nestjs/config"; -import { firstValueFrom } from "rxjs"; +import { + AiEngineClient, + AiEngineRequestError, +} from "../common/utils/ai-engine-client"; export interface AIPredictionResult { matchId: string; @@ -40,13 +42,21 @@ export interface AIPredictionResult { export class AiService { private readonly logger = new Logger(AiService.name); private readonly pythonEngineUrl: string; + private readonly aiEngineClient: AiEngineClient; constructor( - private readonly httpService: HttpService, private readonly configService: ConfigService, ) { this.pythonEngineUrl = this.configService.get("AI_ENGINE_URL") || "http://127.0.0.1:8000"; + this.aiEngineClient = new AiEngineClient({ + baseUrl: this.pythonEngineUrl, + logger: this.logger, + serviceName: AiService.name, + timeoutMs: 30000, + maxRetries: 2, + retryDelayMs: 500, + }); } /** @@ -71,14 +81,9 @@ export class AiService { `Calling Python V25 Engine for ${matchDetails.homeTeam} vs ${matchDetails.awayTeam}`, ); - const response = await firstValueFrom( - this.httpService.post( - `${this.pythonEngineUrl}/v20plus/analyze/${matchId}`, - {}, - { - timeout: 30000, - }, - ), + const response = await this.aiEngineClient.post( + `/v20plus/analyze/${matchId}`, + {}, ); if (response.data) { @@ -86,8 +91,14 @@ export class AiService { } return null; - } catch (error: any) { - this.logger.warn(`Python Engine error: ${error.message}`); + } catch (error: unknown) { + const message = + error instanceof AiEngineRequestError + ? error.message + : error instanceof Error + ? error.message + : "Unknown AI engine error"; + this.logger.warn(`Python Engine error: ${message}`); return null; } } @@ -286,10 +297,12 @@ export class AiService { */ async checkHealth(): Promise { try { - const response = await firstValueFrom( - this.httpService.get(`${this.pythonEngineUrl}/health`, { + const response = await this.aiEngineClient.get<{ status?: string }>( + "/health", + { timeout: 5000, - }), + retryCount: 0, + }, ); return response.data?.status === "healthy"; } catch { diff --git a/src/tasks/data-fetcher.task.ts b/src/tasks/data-fetcher.task.ts index fd701ec..c6b6112 100755 --- a/src/tasks/data-fetcher.task.ts +++ b/src/tasks/data-fetcher.task.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { Injectable, Logger } from "@nestjs/common"; import { Cron } from "@nestjs/schedule"; import { HttpService } from "@nestjs/axios"; import { PrismaService } from "../database/prisma.service"; @@ -8,10 +8,22 @@ import * as fs from "fs"; import * as path from "path"; import { Prisma } from "@prisma/client"; import { SidelinedResponse } from "../modules/feeder/feeder.types"; +import { + FINISHED_STATE_VALUES_FOR_DB, + FINISHED_STATUS_VALUES_FOR_DB, + LIVE_STATE_VALUES_FOR_DB, + LIVE_STATUS_VALUES_FOR_DB, +} from "../common/utils/match-status.util"; +import { + getDateStringInTimeZone, + getDayBoundsForTimeZone, + getShiftedDateStringInTimeZone, +} from "../common/utils/timezone.util"; +import { TaskLockService } from "./task-lock.service"; -// ──────────────────────────────────────────────────────────────── +// ──────────────────────────────────────────────────────────────── // Types -// ──────────────────────────────────────────────────────────────── +// ──────────────────────────────────────────────────────────────── interface LiveScoreTeamPayload { id: string; @@ -64,75 +76,119 @@ interface LiveLineupsJson { type SportType = "football" | "basketball"; -// ──────────────────────────────────────────────────────────────── +// ──────────────────────────────────────────────────────────────── // Service -// ──────────────────────────────────────────────────────────────── +// ──────────────────────────────────────────────────────────────── @Injectable() export class DataFetcherTask { private readonly logger = new Logger(DataFetcherTask.name); + private readonly timeZone = "Europe/Istanbul"; constructor( private readonly httpService: HttpService, private readonly prisma: PrismaService, private readonly scraper: FeederScraperService, + private readonly taskLock: TaskLockService, ) {} - // ──────────────────────────────────────────────────────────── - // CRON 1: Main sync — every 15 minutes - // Phases: match list → live scores → odds → lineups - // ──────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────── + // CRON 1: Main sync — every 15 minutes + // Phases: match list → live scores → odds → lineups + // ──────────────────────────────────────────────────────────── @Cron("*/15 * * * *") async syncLiveMatches(): Promise { if (this.shouldSkipInHistoricalMode("syncLiveMatches")) return; - this.logger.log("━━━ syncLiveMatches START ━━━"); - - const today = new Date().toISOString().split("T")[0]; - - // Phase 1: Match list (football + basketball) - await this.syncMatchList(today); - - // Phase 2: Live score updates - await this.updateLiveScores(); - - // Phase 3: Odds + referee + lineups + sidelined (via processMatchOdds) - await this.fetchOddsForMatches(); - - // Phase 4: Fill missing lineups (backup for edge cases) - await this.fillMissingLineups(); - - this.logger.log("━━━ syncLiveMatches END ━━━"); + await this.taskLock.runWithLease( + "syncLiveMatches", + 30 * 60 * 1000, + async () => { + await this.runLiveSync(); + }, + this.logger, + ); } - // ──────────────────────────────────────────────────────────── - // CRON 2: Daily cleanup + full sync — 07:00 Istanbul - // Truncates live_matches, then runs full sync - // ──────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────── + // CRON 2: Daily cleanup + full sync — 07:00 Istanbul + // Preserve yesterday as a fallback until the 08:00 archive job completes. + // ──────────────────────────────────────────────────────────── @Cron("0 7 * * *", { timeZone: "Europe/Istanbul" }) async cleanAndFullSync(): Promise { if (this.shouldSkipInHistoricalMode("cleanAndFullSync")) return; - this.logger.log("🧹 cleanAndFullSync: Truncating live_matches..."); + await this.taskLock.runWithLease( + "cleanAndFullSync", + 2 * 60 * 60 * 1000, + async () => { + this.logger.log( + "cleanAndFullSync: Pruning stale live_matches while preserving yesterday for archive fallback...", + ); - try { - const deleted = await this.prisma.liveMatch.deleteMany({}); - this.logger.log( - `🧹 Deleted ${deleted.count} live matches. Starting full sync...`, - ); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - this.logger.error(`Truncate failed: ${message}`); - return; - } + try { + const yesterdayDate = getShiftedDateStringInTimeZone( + -1, + this.timeZone, + ); + const { startMs: yesterdayStartMs } = getDayBoundsForTimeZone( + yesterdayDate, + this.timeZone, + ); + const cutoffDate = new Date(yesterdayStartMs); - // Run full sync immediately after cleanup - await this.syncLiveMatches(); + const deleted = await this.prisma.liveMatch.deleteMany({ + where: { + OR: [ + { mstUtc: { lt: BigInt(yesterdayStartMs) } }, + { + AND: [ + { mstUtc: null }, + { updatedAt: { lt: cutoffDate } }, + { + OR: [ + { status: { in: FINISHED_STATUS_VALUES_FOR_DB } }, + { state: { in: FINISHED_STATE_VALUES_FOR_DB } }, + ], + }, + ], + }, + ], + }, + }); + + this.logger.log( + `Pruned ${deleted.count} stale live matches. Starting full sync...`, + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Stale live_match cleanup failed: ${message}`); + return; + } + + await this.runLiveSync(); + }, + this.logger, + ); } - // ──────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────── // Phase 1: Fetch match list for all sports - // ──────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────── + + private async runLiveSync(): Promise { + if (this.shouldSkipInHistoricalMode("syncLiveMatches")) return; + + this.logger.log("syncLiveMatches START"); + + const today = getDateStringInTimeZone(new Date(), this.timeZone); + await this.syncMatchList(today); + await this.updateLiveScores(); + await this.fetchOddsForMatches(); + await this.fillMissingLineups(); + + this.logger.log("syncLiveMatches END"); + } private async syncMatchList(date: string): Promise { // Football @@ -141,7 +197,7 @@ export class DataFetcherTask { await this.fetchMatchesForSport("football", date, footballLeagues); } else { this.logger.warn( - "top_leagues.json is missing/empty — writing ALL football matches", + "top_leagues.json is missing/empty — writing ALL football matches", ); await this.fetchMatchesForSport("football", date, new Set()); } @@ -170,17 +226,18 @@ export class DataFetcherTask { } } - // ──────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────── // Phase 2: Live score updates (merged from live-updater.task) - // ──────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────── private async updateLiveScores(): Promise { try { const liveMatches = await this.prisma.liveMatch.findMany({ where: { - state: { - in: ["live", "firsthalf", "secondhalf", "1H", "2H", "HT", "LIVE"], - }, + OR: [ + { state: { in: LIVE_STATE_VALUES_FOR_DB } }, + { status: { in: LIVE_STATUS_VALUES_FOR_DB } }, + ], }, select: { id: true, matchSlug: true }, }); @@ -191,7 +248,7 @@ export class DataFetcherTask { } this.logger.log( - `📡 Updating scores for ${liveMatches.length} live matches`, + `📡 Updating scores for ${liveMatches.length} live matches`, ); for (const match of liveMatches) { @@ -219,19 +276,19 @@ export class DataFetcherTask { } } - this.logger.log("📡 Live score update complete"); + this.logger.log("📡 Live score update complete"); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); this.logger.error(`Live score update failed: ${message}`); } } - // ──────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────── // Phase 3: Odds + referee + lineups + sidelined - // ──────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────── private async fetchOddsForMatches(): Promise { - this.logger.log("💰 Fetching odds for live matches..."); + this.logger.log("💰 Fetching odds for live matches..."); try { // Load both league filters @@ -266,11 +323,11 @@ export class DataFetcherTask { }); if (matchesToFetch.length === 0) { - this.logger.log("💰 No matches to fetch odds for"); + this.logger.log("💰 No matches to fetch odds for"); return; } - this.logger.log(`💰 Fetching odds for ${matchesToFetch.length} matches`); + this.logger.log(`💰 Fetching odds for ${matchesToFetch.length} matches`); let successCount = 0; let errorCount = 0; @@ -299,7 +356,7 @@ export class DataFetcherTask { // Retry failed matches (502/Timeout) if (failedMatches.length > 0) { this.logger.warn( - `⚠️ Retrying ${failedMatches.length} failed matches (502/Timeout)...`, + `⚠️ Retrying ${failedMatches.length} failed matches (502/Timeout)...`, ); for (const match of failedMatches) { @@ -307,19 +364,19 @@ export class DataFetcherTask { try { await this.processMatchOdds(match); successCount++; - this.logger.log(`✅ Retry successful for match ${match.id}`); + this.logger.log(`✅ Retry successful for match ${match.id}`); } catch (retryErr: unknown) { const message = retryErr instanceof Error ? retryErr.message : String(retryErr); this.logger.error( - `❌ Retry failed for match ${match.id}: ${message}`, + `❌ Retry failed for match ${match.id}: ${message}`, ); } } } this.logger.log( - `💰 Odds complete: ${successCount} success, ${errorCount} errors (initially)`, + `💰 Odds complete: ${successCount} success, ${errorCount} errors (initially)`, ); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); @@ -327,14 +384,36 @@ export class DataFetcherTask { } } - // ──────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────── // Phase 4: Fill missing lineups (backup) - // ──────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────── private async fillMissingLineups(): Promise { try { const matchesToUpdate = await this.prisma.liveMatch.findMany({ - where: { status: { notIn: ["FT", "post", "postGame"] } }, + where: { + sport: "football", + NOT: { + OR: [ + { status: { in: FINISHED_STATUS_VALUES_FOR_DB } }, + { state: { in: FINISHED_STATE_VALUES_FOR_DB } }, + { + AND: [ + { scoreHome: { not: null } }, + { scoreAway: { not: null } }, + { + NOT: { + OR: [ + { status: { in: LIVE_STATUS_VALUES_FOR_DB } }, + { state: { in: LIVE_STATE_VALUES_FOR_DB } }, + ], + }, + }, + ], + }, + ], + }, + }, select: { id: true, matchSlug: true, lineups: true, sport: true }, take: 30, }); @@ -345,11 +424,11 @@ export class DataFetcherTask { ); if (toUpdate.length === 0) { - this.logger.debug("👕 All lineups already filled"); + this.logger.debug("👕 All lineups already filled"); return; } - this.logger.log(`👕 Filling lineups for ${toUpdate.length} matches...`); + this.logger.log(`👕 Filling lineups for ${toUpdate.length} matches...`); for (const match of toUpdate) { try { @@ -374,7 +453,7 @@ export class DataFetcherTask { }, }); - this.logger.log(`👕 Lineups filled for match ${match.id}`); + this.logger.log(`👕 Lineups filled for match ${match.id}`); await this.delay(500); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); @@ -387,9 +466,9 @@ export class DataFetcherTask { } } - // ──────────────────────────────────────────────────────────── - // Unified match fetcher — DRY for football + basketball - // ──────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────── + // Unified match fetcher — DRY for football + basketball + // ──────────────────────────────────────────────────────────── private async fetchMatchesForSport( sport: SportType, @@ -650,7 +729,7 @@ export class DataFetcherTask { upsertCount + skippedCount === targetMatches.length ) { this.logger.log( - `[${sport}] ⏳ Progress: ${upsertCount + skippedCount}/${targetMatches.length} (Saved: ${upsertCount}, Skipped: ${skippedCount})`, + `[${sport}] ⏳ Progress: ${upsertCount + skippedCount}/${targetMatches.length} (Saved: ${upsertCount}, Skipped: ${skippedCount})`, ); } } catch (err: unknown) { @@ -668,10 +747,10 @@ export class DataFetcherTask { } } - // ──────────────────────────────────────────────────────────── - // processMatchOdds — odds + referee + lineups + sidelined - // (Preserved from original — no logic changes) - // ──────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────── + // processMatchOdds — odds + referee + lineups + sidelined + // (Preserved from original — no logic changes) + // ──────────────────────────────────────────────────────────── private async processMatchOdds(match: LiveMatchOddsTarget): Promise { const matchSlug = match.matchSlug || "match"; @@ -687,7 +766,7 @@ export class DataFetcherTask { let lineups: LiveLineupsJson | null = null; let sidelined: SidelinedResponse | null = null; - // 1. Fetch Odds from İddaa page + // 1. Fetch Odds from İddaa page const oddsUrl = `https://www.mackolik.com/${sportPath}/${matchSlug}/iddaa/${match.id}`; try { const response = await firstValueFrom( @@ -722,7 +801,7 @@ export class DataFetcherTask { typeof mainResp.data === "string" ? mainResp.data : "", ); } catch { - // Non-critical — referee is optional + // Non-critical — referee is optional } } @@ -751,7 +830,7 @@ export class DataFetcherTask { subs: substitutions?.stats?.away || [], }, }; - this.logger.log(`👥 Lineups found for ${match.matchName}`); + this.logger.log(`👥 Lineups found for ${match.matchName}`); } else { this.logger.debug(`No lineups (yet) for ${match.matchName}`); } @@ -779,7 +858,7 @@ export class DataFetcherTask { sidelined.awayTeam?.totalSidelined > 0 ) { this.logger.log( - `🚑 Sidelined: ${sidelined.homeTeam.totalSidelined}(H) - ${sidelined.awayTeam.totalSidelined}(A) for ${match.matchName}`, + `🚑 Sidelined: ${sidelined.homeTeam.totalSidelined}(H) - ${sidelined.awayTeam.totalSidelined}(A) for ${match.matchName}`, ); } } @@ -813,22 +892,22 @@ export class DataFetcherTask { sidelined.awayTeam.totalSidelined > 0)) ) { this.logger.log( - `✅ Loop update: ${match.matchName} | Odds: ${Object.keys(odds).length} | Ref: ${refereeName || "N/A"} | Lineups: ${lineups ? "Yes" : "No"} | Sidelined: ${sidelined ? "Yes" : "No"}`, + `✅ Loop update: ${match.matchName} | Odds: ${Object.keys(odds).length} | Ref: ${refereeName || "N/A"} | Lineups: ${lineups ? "Yes" : "No"} | Sidelined: ${sidelined ? "Yes" : "No"}`, ); } else { this.logger.debug( - `❕ No detailed data for ${match.matchName}, marked check.`, + `❕ No detailed data for ${match.matchName}, marked check.`, ); } } - // ──────────────────────────────────────────────────────────── - // HTML Extraction Helpers (preserved — no logic changes) - // ──────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────── + // HTML Extraction Helpers (preserved — no logic changes) + // ──────────────────────────────────────────────────────────── /** * Extract odds from Mackolik HTML page - * Returns structured odds object: { "MS": {"1": 2.10, "X": 3.40}, "AU25": {"Alt": 2.05, "Üst": 1.75} } + * Returns structured odds object: { "MS": {"1": 2.10, "X": 3.40}, "AU25": {"Alt": 2.05, "Üst": 1.75} } */ private extractOddsFromHtml( html: string, @@ -914,17 +993,17 @@ export class DataFetcherTask { const lower = name.toLowerCase(); // Specific & Compound names FIRST - if (lower.includes("ilk yarı/maç sonucu")) return "HTFT"; - if (lower.includes("1. yarı sonucu")) return "HT"; - if (lower.includes("çifte şans")) return "CS"; + if (lower.includes("ilk yarı/maç sonucu")) return "HTFT"; + if (lower.includes("1. yarı sonucu")) return "HT"; + if (lower.includes("çifte ÅŸans")) return "CS"; // General names LATER - if (lower.includes("maç sonucu") && !lower.includes("handikap")) + if (lower.includes("maç sonucu") && !lower.includes("handikap")) return "MS"; - if (lower.includes("karşılıklı gol")) return "KG"; - if (lower.includes("2,5 alt/üst") || lower.includes("2.5")) return "AU25"; - if (lower.includes("1,5 alt/üst") || lower.includes("1.5")) return "AU15"; - if (lower.includes("3,5 alt/üst") || lower.includes("3.5")) return "AU35"; + if (lower.includes("karşılıklı gol")) return "KG"; + if (lower.includes("2,5 alt/üst") || lower.includes("2.5")) return "AU25"; + if (lower.includes("1,5 alt/üst") || lower.includes("1.5")) return "AU15"; + if (lower.includes("3,5 alt/üst") || lower.includes("3.5")) return "AU35"; return null; } @@ -934,7 +1013,7 @@ export class DataFetcherTask { */ private extractRefereeFromHtml(html: string): string | null { try { - // Strategy 1: Mackolik officials section — head referee in '--main' list item + // Strategy 1: Mackolik officials section — head referee in '--main' list item const mainOfficialPattern = /official-list-item--main[^>]*>\s*(?:<[^>]*>\s*)*?]*official-name[^>]*>\s*([^<]+)/i; const mainMatch = mainOfficialPattern.exec(html); @@ -970,9 +1049,9 @@ export class DataFetcherTask { return null; } - // ──────────────────────────────────────────────────────────── - // Low-level Helpers (preserved — no logic changes) - // ──────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────── + // Low-level Helpers (preserved — no logic changes) + // ──────────────────────────────────────────────────────────── private shouldSkipInHistoricalMode(jobName: string): boolean { if (process.env.FEEDER_MODE === "historical") { diff --git a/src/tasks/historical-results-sync.task.ts b/src/tasks/historical-results-sync.task.ts index bc1066d..df83d61 100644 --- a/src/tasks/historical-results-sync.task.ts +++ b/src/tasks/historical-results-sync.task.ts @@ -1,12 +1,16 @@ import { Injectable, Logger } from "@nestjs/common"; import { Cron } from "@nestjs/schedule"; import { FeederService } from "../modules/feeder/feeder.service"; +import { TaskLockService } from "./task-lock.service"; @Injectable() export class HistoricalResultsSyncTask { private readonly logger = new Logger(HistoricalResultsSyncTask.name); - constructor(private readonly feederService: FeederService) {} + constructor( + private readonly feederService: FeederService, + private readonly taskLock: TaskLockService, + ) {} private shouldSkipInHistoricalMode(jobName: string): boolean { if (process.env.FEEDER_MODE === "historical") { @@ -25,17 +29,24 @@ export class HistoricalResultsSyncTask { return; } - this.logger.log( - "Starting previous-day completed match sync for football and basketball...", - ); + await this.taskLock.runWithLease( + "syncPreviousDayCompletedMatches", + 6 * 60 * 60 * 1000, + async () => { + this.logger.log( + "Starting previous-day completed match sync for football and basketball...", + ); - try { - await this.feederService.runPreviousDayCompletedMatchesScan(); - this.logger.log("Previous-day completed match sync finished"); - } catch (error: any) { - this.logger.error( - `Previous-day completed match sync failed: ${error.message}`, - ); - } + try { + await this.feederService.runPreviousDayCompletedMatchesScan(); + this.logger.log("Previous-day completed match sync finished"); + } catch (error: any) { + this.logger.error( + `Previous-day completed match sync failed: ${error.message}`, + ); + } + }, + this.logger, + ); } } diff --git a/src/tasks/limit-resetter.task.ts b/src/tasks/limit-resetter.task.ts index 0a547af..0851ff3 100755 --- a/src/tasks/limit-resetter.task.ts +++ b/src/tasks/limit-resetter.task.ts @@ -1,12 +1,28 @@ import { Injectable, Logger } from "@nestjs/common"; import { Cron } from "@nestjs/schedule"; import { PrismaService } from "../database/prisma.service"; +import { + FINISHED_STATE_VALUES_FOR_DB, + FINISHED_STATUS_VALUES_FOR_DB, + LIVE_STATE_VALUES_FOR_DB, + LIVE_STATUS_VALUES_FOR_DB, +} from "../common/utils/match-status.util"; +import { + getDateOnlyValueForTimeZone, + getShiftedDateStringInTimeZone, + getDayBoundsForTimeZone, +} from "../common/utils/timezone.util"; +import { TaskLockService } from "./task-lock.service"; @Injectable() export class LimitResetterTask { private readonly logger = new Logger(LimitResetterTask.name); + private readonly timeZone = "Europe/Istanbul"; - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly taskLock: TaskLockService, + ) {} private shouldSkipInHistoricalMode(jobName: string): boolean { if (process.env.FEEDER_MODE === "historical") { @@ -22,34 +38,39 @@ export class LimitResetterTask { @Cron("0 3 * * *", { timeZone: "Europe/Istanbul" }) async resetUsageLimits() { if (this.shouldSkipInHistoricalMode("resetUsageLimits")) return; - this.logger.log("Starting daily usage limit reset job..."); + await this.taskLock.runWithLease( + "resetUsageLimits", + 30 * 60 * 1000, + async () => { + this.logger.log("Starting daily usage limit reset job..."); - try { - const today = new Date(); - today.setHours(0, 0, 0, 0); + try { + const today = getDateOnlyValueForTimeZone(this.timeZone); - // Reset all limits that were last reset before today - const result = await this.prisma.usageLimit.updateMany({ - where: { - lastResetDate: { lt: today }, - }, - data: { - analysisCount: 0, - couponCount: 0, - lastResetDate: today, - }, - }); + const result = await this.prisma.usageLimit.updateMany({ + where: { + lastResetDate: { lt: today }, + }, + data: { + analysisCount: 0, + couponCount: 0, + lastResetDate: today, + }, + }); - if (result.count > 0) { - this.logger.log( - `Usage limits for ${result.count} users have been reset`, - ); - } else { - this.logger.log("No user limits needed resetting"); - } - } catch (error: any) { - this.logger.error(`Limit reset job failed: ${error.message}`); - } + if (result.count > 0) { + this.logger.log( + `Usage limits for ${result.count} users have been reset`, + ); + } else { + this.logger.log("No user limits needed resetting"); + } + } catch (error: any) { + this.logger.error(`Limit reset job failed: ${error.message}`); + } + }, + this.logger, + ); } /** @@ -58,37 +79,65 @@ export class LimitResetterTask { @Cron("0 4 * * *", { timeZone: "Europe/Istanbul" }) async cleanupOldData() { if (this.shouldSkipInHistoricalMode("cleanupOldData")) return; - this.logger.log("Starting data cleanup job..."); + await this.taskLock.runWithLease( + "cleanupOldData", + 60 * 60 * 1000, + async () => { + this.logger.log("Starting data cleanup job..."); - try { - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + try { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - // Delete old AI prediction logs - const deletedLogs = await this.prisma.aiPredictionsLog.deleteMany({ - where: { - createdAt: { lt: thirtyDaysAgo }, - }, - }); + const deletedLogs = await this.prisma.aiPredictionsLog.deleteMany({ + where: { + createdAt: { lt: thirtyDaysAgo }, + }, + }); - // Delete old live matches (finished more than 1 day ago) - // Historical data is already persisted in the 'matches' table - const oneDayAgo = new Date(); - oneDayAgo.setDate(oneDayAgo.getDate() - 1); + const yesterdayDate = getShiftedDateStringInTimeZone( + -1, + this.timeZone, + ); + const { startMs: yesterdayStartMs } = getDayBoundsForTimeZone( + yesterdayDate, + this.timeZone, + ); + const liveMatchCutoff = new Date(yesterdayStartMs); - const deletedLiveMatches = await this.prisma.liveMatch.deleteMany({ - where: { - state: "Finished", - updatedAt: { lt: oneDayAgo }, - }, - }); + const deletedLiveMatches = await this.prisma.liveMatch.deleteMany({ + where: { + updatedAt: { lt: liveMatchCutoff }, + OR: [ + { status: { in: FINISHED_STATUS_VALUES_FOR_DB } }, + { state: { in: FINISHED_STATE_VALUES_FOR_DB } }, + { + AND: [ + { scoreHome: { not: null } }, + { scoreAway: { not: null } }, + { + NOT: { + OR: [ + { status: { in: LIVE_STATUS_VALUES_FOR_DB } }, + { state: { in: LIVE_STATE_VALUES_FOR_DB } }, + ], + }, + }, + ], + }, + ], + }, + }); - this.logger.log( - `Cleanup complete: ${deletedLogs.count} old logs, ${deletedLiveMatches.count} old live matches`, - ); - } catch (error: any) { - this.logger.error(`Cleanup job failed: ${error.message}`); - } + this.logger.log( + `Cleanup complete: ${deletedLogs.count} old logs, ${deletedLiveMatches.count} old live matches`, + ); + } catch (error: any) { + this.logger.error(`Cleanup job failed: ${error.message}`); + } + }, + this.logger, + ); } /** @@ -97,26 +146,33 @@ export class LimitResetterTask { @Cron("0 0 * * *", { timeZone: "Europe/Istanbul" }) async checkSubscriptions() { if (this.shouldSkipInHistoricalMode("checkSubscriptions")) return; - this.logger.log("Checking expired subscriptions..."); + await this.taskLock.runWithLease( + "checkSubscriptions", + 30 * 60 * 1000, + async () => { + this.logger.log("Checking expired subscriptions..."); - try { - const now = new Date(); + try { + const now = new Date(); - const result = await this.prisma.user.updateMany({ - where: { - subscriptionStatus: "active", - subscriptionExpiresAt: { lt: now }, - }, - data: { - subscriptionStatus: "expired", - }, - }); + const result = await this.prisma.user.updateMany({ + where: { + subscriptionStatus: "active", + subscriptionExpiresAt: { lt: now }, + }, + data: { + subscriptionStatus: "expired", + }, + }); - if (result.count > 0) { - this.logger.log(`${result.count} subscriptions marked as expired`); - } - } catch (error: any) { - this.logger.error(`Subscription check failed: ${error.message}`); - } + if (result.count > 0) { + this.logger.log(`${result.count} subscriptions marked as expired`); + } + } catch (error: any) { + this.logger.error(`Subscription check failed: ${error.message}`); + } + }, + this.logger, + ); } } diff --git a/src/tasks/task-lock.service.ts b/src/tasks/task-lock.service.ts new file mode 100644 index 0000000..d12e05c --- /dev/null +++ b/src/tasks/task-lock.service.ts @@ -0,0 +1,80 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../database/prisma.service"; + +@Injectable() +export class TaskLockService { + private readonly logger = new Logger(TaskLockService.name); + private readonly activeTasks = new Set(); + + constructor(private readonly prisma: PrismaService) {} + + async runWithLease( + key: string, + ttlMs: number, + task: () => Promise, + logger: Logger, + ): Promise { + if (this.activeTasks.has(key)) { + logger.warn(`Skipping ${key}: task is already running in this process`); + return null; + } + + const owner = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const acquired = await this.acquireLease(key, owner, ttlMs); + + if (!acquired) { + logger.warn(`Skipping ${key}: lease is already held by another instance`); + return null; + } + + this.activeTasks.add(key); + + try { + return await task(); + } finally { + this.activeTasks.delete(key); + await this.releaseLease(key, owner); + } + } + + private async acquireLease( + key: string, + owner: string, + ttlMs: number, + ): Promise { + const rows = await this.prisma.$queryRaw<{ key: string }[]>( + Prisma.sql` + INSERT INTO app_settings (key, value, updated_at) + VALUES (${this.getDbKey(key)}, ${owner}, NOW() + (${ttlMs} * INTERVAL '1 millisecond')) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, + updated_at = EXCLUDED.updated_at + WHERE app_settings.updated_at < NOW() + OR app_settings.value = ${owner} + RETURNING key + `, + ); + + return rows.length > 0; + } + + private async releaseLease(key: string, owner: string): Promise { + try { + await this.prisma.$executeRaw( + Prisma.sql` + DELETE FROM app_settings + WHERE key = ${this.getDbKey(key)} + AND value = ${owner} + `, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Failed to release task lease ${key}: ${message}`); + } + } + + private getDbKey(key: string): string { + return `task_lock:${key}`; + } +} diff --git a/src/tasks/tasks.module.ts b/src/tasks/tasks.module.ts index b9bfb00..281bd2d 100755 --- a/src/tasks/tasks.module.ts +++ b/src/tasks/tasks.module.ts @@ -1,15 +1,14 @@ import { Module } from "@nestjs/common"; -import { ScheduleModule } from "@nestjs/schedule"; import { HttpModule } from "@nestjs/axios"; import { DataFetcherTask } from "./data-fetcher.task"; import { HistoricalResultsSyncTask } from "./historical-results-sync.task"; import { LimitResetterTask } from "./limit-resetter.task"; +import { TaskLockService } from "./task-lock.service"; import { DatabaseModule } from "../database/database.module"; import { FeederModule } from "../modules/feeder/feeder.module"; @Module({ imports: [ - ScheduleModule.forRoot(), HttpModule.register({ timeout: 30000, headers: { @@ -20,7 +19,12 @@ import { FeederModule } from "../modules/feeder/feeder.module"; DatabaseModule, FeederModule, ], - providers: [DataFetcherTask, HistoricalResultsSyncTask, LimitResetterTask], + providers: [ + TaskLockService, + DataFetcherTask, + HistoricalResultsSyncTask, + LimitResetterTask, + ], exports: [DataFetcherTask, HistoricalResultsSyncTask, LimitResetterTask], }) export class TasksModule {}