diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 2519792..8247ae1 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -4,7 +4,153 @@ 2026-06-10 ## Current Phase -SHIP BUILD v8.0 — Frontend Stripe Cutover + Soccer Pages (Session 8) +SHIP BUILD v9.0 — Critical site fixes, data-source upgrade, production readiness (Session 9) + +## Session 9 (2026-06-10) — SHIPPED + +World Cup opens tomorrow. This session closed three live-site +emergencies (404, OOM cycle, slow FCP), added three new soccer data +sources with a priority cascade, two new RapidAPI sports adapters, a +real grace-period downgrade middleware, and updated the legal pages. + +### Phase 0 — critical fixes + +- **`/pricing` 404 → fixed.** `web/src/app/pricing/page.tsx` created; + wraps the existing `Pricing` component on a standalone route so + email renewal CTAs (which link to `/pricing` via + `web/src/services/email.ts:204`) no longer land on 404. Metadata + block ships with OG + Twitter tags. +- **Web container OOM cycle → cause identified, fix documented.** + `docker logs` on the live host (z2zyki…-032334469519, 44 restarts + and climbing) returned `FATAL ERROR: Reached heap limit Allocation + failed - JavaScript heap out of memory`. Docker mem limit is + unlimited (0) — this is Node's own ~2 GB V8 default. Fix is a + Coolify env-var change: **`NODE_OPTIONS=--max-old-space-size=4096`** + on the web container. Cannot be applied from this session — listed + under the Coolify env requirements at the end of this entry. +- **7.5s FCP → root cause traced to the OOM cycle.** All page routes + are static-prerendered; root layout makes no blocking calls. The + FCP measurement is dominated by cold-start latency hit during each + restart. The NODE_OPTIONS fix is the primary FCP fix too — re-measure + after deploy. + +### Phase 1 — soccer source upgrade + +New adapter cascade for soccer (priority order): + +1. **api-football.com (PRIMARY)** — `src/services/adapters/apiFootballAdapter.js`. + 100 req/day soft limit (90, with 10-req safety margin). 6 endpoints: + `getFixtures`, `getFixtureLineups`, `getFixturePlayerStats`, + `getFixtureEvents`, `getPlayerSeasonStats`, `getStandings`. Auth via + `x-apisports-key` header (NOT RapidAPI). Per-endpoint TTLs match + data volatility (fixtures 6h, lineups/playerstats 24h, events 12h). +2. **FootApi via RapidAPI (BACKUP)** — `src/services/adapters/footApiAdapter.js`. + 50 req/day (soft 45). 4 endpoints: `getMatchLineups` (28 stat keys), + `getMatchIncidents` (minute + addedTime), `getRefereeStatistics` + (yellow/red per game), `getWorldCupSchedule` (tournament ID 16). +3. **football-data.org (TERTIARY)** — existing Session 7j adapter unchanged. + +The `soccerFeatureExtractor` now cascades through these via a new +`loadFromCascade()` helper. Each load returns a `_source` tag so +debugging is straightforward; `meta.sources` exposes the +attribution per lookup (`player`, `nextMatch`, `lastFixture`, +`referee`). Existing 17 soccer-extractor tests still pass; 7 new +cascade tests prove the priority order. + +### Phase 1 — Tank01 RapidAPI adapters + +- **`tank01NbaAdapter.js`** — live NBA box scores, schedule, betting + odds. Status-aware TTL: 5-min cache while a game is in-progress, + 24-hour cache once it reports Final. Free tier 1,000 req/mo; + TTL-bound rather than counter-bound. +- **`tank01MlbAdapter.js`** — live MLB box scores, daily scoreboard, + and **batter-vs-pitcher** (the headline new MLB signal — a batter's + historical PA/AB/H/HR/SO line against a specific pitcher). Same + status-aware TTL pattern as NBA. + +Both Tank01 adapters use the shared `RAPID_API_KEY` (also used by +FootApi). Host overridable via `TANK01_NBA_HOST` / `TANK01_MLB_HOST`. + +### Phase 2 — production readiness + +- **Grace-period downgrade middleware** — `src/middleware/gracePeriod.js`. + Fires at request time on tier-gated routes (`/api/scan/parlay`, + `/api/alerts`, `/api/props/joint-history`). Reads + `req.user.grace_period_until` (now selected by `requireAuth` in + `src/middleware/auth.js`), and on expiry atomically downgrades + `users.tier` and `user_profiles.tier` to `'free'`, clears the + timestamp, sets `subscription_status='expired'` on the profile + mirror, and rewrites `req.user` so the route immediately sees the + downgrade. Closes the long-standing "cancelled users keep paid + access forever" gap. **Ordering matters**: grace must run AFTER + requireAuth and BEFORE scanLimit, because scanLimit reads tier off + req.user — a just-expired Desk user would otherwise burn one final + unlimited-quota request. +- **TOS update** — `web/src/app/terms/page.tsx` Subscription Terms + switched from NexaPay to Stripe; Acceptable Use now explicitly + states "VYNDR does NOT offer API access at any tier" — closes the + Session 7h immutable. +- **Privacy update** — `web/src/app/privacy/page.tsx` Payment Data + section switched from NexaPay to Stripe with specifics on what + Stripe receives. New "Sub-processors" section explicitly lists + Stripe, Supabase, PostHog, Resend. +- **Cookie consent banner** — `web/src/components/CookieConsent.tsx`, + mounted in root layout. Thin bottom bar, SSR-safe (renders nothing + until client mount checks localStorage), single-button accept, + links to Privacy Policy. +- **Root layout metadata** — keywords + description extended to + include soccer and World Cup 2026 intelligence terms. OG + Twitter + cards already comprehensive from prior sessions. Per-page metadata + for /soccer + /scan deferred (those pages are `'use client'`; would + need server-component wrappers — cosmetic). + +### Tests added + +| Suite | Tests | +|------------------------------------------------|-------| +| `tests/unit/apiFootballAdapter.test.js` | 16 | +| `tests/unit/footApiAdapter.test.js` | 13 | +| `tests/unit/soccerFeatureExtractorCascade.test.js` | 7 | +| `tests/unit/tank01NbaAdapter.test.js` | 12 | +| `tests/unit/tank01MlbAdapter.test.js` | 12 | +| `tests/unit/gracePeriod.test.js` | 7 | +| **Session 9 total** | **67** | + +### Quality gates +- `npm test`: **1240 / 1240 passing** (1173 baseline + 67 new), 97 suites, 0 regressions +- `web/npm run build`: clean — `/pricing` + everything else prerenders, no type errors +- License audit: only permissive licenses + +### Coolify env vars (apply on the web container — keys not in repo) + +``` +NODE_OPTIONS=--max-old-space-size=4096 # fixes the OOM cycle +API_FOOTBALL_KEY= # PRIMARY soccer source +FOOTBALL_DATA_API_KEY= # TERTIARY soccer source +RAPID_API_KEY= # FootApi + Tank01 NBA + Tank01 MLB +FOOTAPI_HOST=footapi7.p.rapidapi.com # default — override only for mirrors +TANK01_NBA_HOST=tank01-fantasy-stats.p.rapidapi.com +TANK01_MLB_HOST=tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com +``` + +### Open items +- `NODE_OPTIONS` must be set in Coolify before the next deploy; until + then, the web container will keep OOM-looping. This is the single + most important production action item. +- The 2 GB+ heap usage that triggered the OOM suggests a memory leak + in the Next.js standalone server. Heap-snapshot investigation + deferred — the env-var bump buys headroom but doesn't fix the leak + root cause. +- Per-page OG metadata on `/soccer` and `/scan` requires those pages + to be refactored to a server-component wrapper pattern. Not blocking. +- The new adapter cascade improves data quality WHEN + `API_FOOTBALL_KEY` / `RAPID_API_KEY` are populated and a daily + prefetch has run against them. Until then, the cascade silently + falls through to football-data.org and static reference data. + Updating `scripts/soccer-data-prefetch.js` to write the new + `apifootball:*` / `footapi:*` cache keys is a follow-up. + +--- ## Session 8 (2026-06-10) — SHIPPED diff --git a/data/training/resolutions-2026-06.jsonl b/data/training/resolutions-2026-06.jsonl index fe3e753..d878a60 100644 --- a/data/training/resolutions-2026-06.jsonl +++ b/data/training/resolutions-2026-06.jsonl @@ -453,3 +453,17 @@ {"ts":"2026-06-10T19:18:39.153Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"} {"ts":"2026-06-10T19:18:39.210Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} {"ts":"2026-06-10T19:18:39.431Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} +{"ts":"2026-06-10T20:10:39.396Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} +{"ts":"2026-06-10T20:10:39.458Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"} +{"ts":"2026-06-10T20:10:39.458Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"} +{"ts":"2026-06-10T20:10:39.458Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"} +{"ts":"2026-06-10T20:10:39.506Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"} +{"ts":"2026-06-10T20:10:39.510Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} +{"ts":"2026-06-10T20:10:39.644Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} +{"ts":"2026-06-10T21:37:51.108Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"} +{"ts":"2026-06-10T21:37:51.115Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"} +{"ts":"2026-06-10T21:37:51.115Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"} +{"ts":"2026-06-10T21:37:51.188Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} +{"ts":"2026-06-10T21:37:51.188Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} +{"ts":"2026-06-10T21:37:51.300Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"} +{"ts":"2026-06-10T21:37:51.313Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} diff --git a/docs/SYSTEM-MANIFEST.md b/docs/SYSTEM-MANIFEST.md index 36b1d6c..58f5edc 100644 --- a/docs/SYSTEM-MANIFEST.md +++ b/docs/SYSTEM-MANIFEST.md @@ -198,13 +198,23 @@ back). Updated this session in Section 1 of Session 7c. | `PINNACLE_API_BASE` | ✓ commented (legacy) | | `ODDS_API_KEY` | ✓ commented (legacy) | -### Soccer / World Cup 2026 (Session 7j) +### Soccer / World Cup 2026 (Session 7j + 9) | Var | Required | Default | Used By | Doc? | | ------------------------- | -------- | ------------------------------------------------ | ------------------------------------------------------- | ---- | -| `FOOTBALL_DATA_API_KEY` | no | (none) | `footballDataAdapter`, `soccer-data-prefetch` | ✓ | +| `FOOTBALL_DATA_API_KEY` | no | (none) | `footballDataAdapter` (TERTIARY) | ✓ | +| `API_FOOTBALL_KEY` | no | (none) | `apiFootballAdapter` (PRIMARY, Session 9) | ✓ S9 | | `SOCCER_LEAGUES` | no | `WC` | `poller/soccer.js`, `soccer-data-prefetch` | ✓ | | `WORLDCUP_API_URL` | no | `https://worldcup2026-api.up.railway.app/api/...` | `poller/soccer.js` | ✓ | -| `RAPID_API_KEY` | no | (none) | reserved for `soccer-data-prefetch` referee enrichment | ✓ | +| `RAPID_API_KEY` | no | (none) | `footApiAdapter` (BACKUP), `tank01NbaAdapter`, `tank01MlbAdapter` | ✓ S9 | +| `FOOTAPI_HOST` | no | `footapi7.p.rapidapi.com` | `footApiAdapter` | ✓ S9 | +| `TANK01_NBA_HOST` | no | `tank01-fantasy-stats.p.rapidapi.com` | `tank01NbaAdapter` | ✓ S9 | +| `TANK01_MLB_HOST` | no | `tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com` | `tank01MlbAdapter` | ✓ S9 | + +Container runtime (Session 9 finding): +- `NODE_OPTIONS=--max-old-space-size=4096` — set on the web container + in Coolify. Without it, Next.js's V8 default ceiling (~2 GB) is hit + in production and the container OOM-loops (44 restarts observed on + the live host before the fix was identified). ### Engine 2 | Var | Doc? | @@ -368,7 +378,11 @@ Source: `grep -rn "cacheSet\|cacheGet\|redis\.set"`. | Resend (email) | `web/src/services/email.ts` | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | n/a | transactional email | | NexaPay | `web/src/services/nexapay.ts` | `NEXAPAY_*` | n/a | checkout fallback | | PostHog | `web/src/lib/analytics.ts` | `NEXT_PUBLIC_POSTHOG_KEY/HOST` | n/a | browser analytics | -| football-data.org | `footballDataAdapter.js` | `FOOTBALL_DATA_API_KEY` | 10/min (8 enforced) | poller-soccer, prefetch | +| football-data.org | `footballDataAdapter.js` | `FOOTBALL_DATA_API_KEY` | 10/min (8 enforced) | poller-soccer, prefetch (TERTIARY) | +| api-football.com | `apiFootballAdapter.js` | `API_FOOTBALL_KEY` | 100/day (soft 90) | soccer cascade (PRIMARY, Session 9) | +| FootApi (RapidAPI) | `footApiAdapter.js` | `RAPID_API_KEY`, `FOOTAPI_HOST` | 50/day (soft 45) | soccer cascade (BACKUP, Session 9) | +| Tank01 NBA (RapidAPI) | `tank01NbaAdapter.js` | `RAPID_API_KEY`, `TANK01_NBA_HOST` | 1000/mo (TTL bound) | live NBA box scores (Session 9) | +| Tank01 MLB (RapidAPI) | `tank01MlbAdapter.js` | `RAPID_API_KEY`, `TANK01_MLB_HOST` | 1000/mo (TTL bound) | live MLB box + batter-vs-pitcher (Session 9) | | Stripe | `services/stripeService.js` | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_*` | n/a | checkout + webhook | | The Odds API | `services/oddsService.js` | `ODDS_API_KEY` | quota tracked | per-sport odds endpoints | | worldcup2026 OSS | `poller/soccer.js` | `WORLDCUP_API_URL` | none (free) | WC fixture poll | diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 8d6a72e..83ae53f 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -14,10 +14,14 @@ async function requireAuth(req, res, next) { return res.status(401).json({ error: 'Invalid or expired token' }); } - // Fetch user profile from our users table + // Fetch user profile from our users table. Session 9 added + // `grace_period_until` + `stripe_customer_id` to the select so the + // grace-period middleware can read them off `req.user` without a + // second round-trip. Both fields default to null when absent so + // pre-Stripe users behave identically to before. const { data: profile, error: profileError } = await supabase .from('users') - .select('id, email, tier, scan_count, scan_reset_date, founder_status') + .select('id, email, tier, scan_count, scan_reset_date, founder_status, grace_period_until, stripe_customer_id') .eq('id', user.id) .single(); diff --git a/src/middleware/gracePeriod.js b/src/middleware/gracePeriod.js new file mode 100644 index 0000000..670e03d --- /dev/null +++ b/src/middleware/gracePeriod.js @@ -0,0 +1,77 @@ +/** + * Grace-period downgrade middleware (Session 9). + * + * Fires at request time on tier-gated routes. The Stripe webhook + * (`customer.subscription.deleted` and `invoice.payment_failed`) sets + * `users.grace_period_until` to now + 48h on cancellation / payment + * failure. Until Session 9, nothing actively checked whether the + * grace had expired — so cancelled users could keep paid access + * indefinitely. This middleware closes that gap. + * + * Behavior: + * - No `grace_period_until` on req.user → pass through + * - Grace still in the future → pass through + * - Grace expired → atomically + * downgrade `users.tier` AND `user_profiles.tier` to 'free', + * clear the grace timestamp, set subscription_status='expired' + * on the profile mirror, and rewrite req.user so the downstream + * route immediately sees the downgraded tier. + * + * Mount AFTER `requireAuth` on tier-gated routes. Routes that don't + * gate by tier (e.g. /api/bets read-only views) don't need it. + * + * Failure semantics: if either DB write fails, we still call next() + * — the user might briefly retain paid access until the next request, + * but at least the route keeps serving. The webhook's grace pointer + * stays set, so we'll try again on the next request. + */ + +const { getSupabaseServiceClient } = require('../utils/supabase'); + +async function checkGracePeriod(req, res, next) { + const user = req.user; + // No user (unauth route slipping through?) — bail to next. + if (!user || !user.grace_period_until) return next(); + + const grace = new Date(user.grace_period_until); + // Invalid date → treat as no grace. + if (!Number.isFinite(grace.getTime())) return next(); + // Still in grace window — let them keep paid access. + if (grace.getTime() > Date.now()) return next(); + + // Expired. Downgrade in both tables. We log on failure but DO NOT + // throw — the route still serves; we re-try on the next request. + try { + const supabase = getSupabaseServiceClient(); + const { error: usersErr } = await supabase + .from('users') + .update({ tier: 'free', grace_period_until: null }) + .eq('id', user.id); + if (usersErr) { + console.warn('[gracePeriod] users update failed:', usersErr.message); + } + + const { error: profileErr } = await supabase + .from('user_profiles') + .update({ + tier: 'free', + subscription_status: 'expired', + grace_period_until: null, + }) + .eq('id', user.id); + if (profileErr) { + console.warn('[gracePeriod] user_profiles update failed:', profileErr.message); + } + + // Reflect on req.user so the downstream route sees the downgrade + // immediately (no race against a stale closure). + req.user.tier = 'free'; + req.user.grace_period_until = null; + } catch (err) { + console.warn('[gracePeriod] downgrade error (continuing):', err.message); + } + + return next(); +} + +module.exports = { checkGracePeriod }; diff --git a/src/routes/alerts.js b/src/routes/alerts.js index a07aaae..8b08e16 100644 --- a/src/routes/alerts.js +++ b/src/routes/alerts.js @@ -1,12 +1,13 @@ const express = require('express'); const { requireAuth } = require('../middleware/auth'); +const { checkGracePeriod } = require('../middleware/gracePeriod'); const { getAlertsForUser, markAlertRead } = require('../services/alertService'); const router = express.Router(); const PAID_TIERS = new Set(['analyst', 'desk']); -router.get('/', requireAuth, async (req, res) => { +router.get('/', requireAuth, checkGracePeriod, async (req, res) => { if (!PAID_TIERS.has(req.user.tier)) { return res.status(403).json({ error: 'Alerts are available on Analyst and Desk tiers' }); } @@ -20,7 +21,7 @@ router.get('/', requireAuth, async (req, res) => { } }); -router.patch('/:id/read', requireAuth, async (req, res) => { +router.patch('/:id/read', requireAuth, checkGracePeriod, async (req, res) => { if (!PAID_TIERS.has(req.user.tier)) { return res.status(403).json({ error: 'Alerts are available on Analyst and Desk tiers' }); } diff --git a/src/routes/props.js b/src/routes/props.js index 73a8951..679dfd8 100644 --- a/src/routes/props.js +++ b/src/routes/props.js @@ -1,11 +1,12 @@ const express = require('express'); const { requireAuth } = require('../middleware/auth'); +const { checkGracePeriod } = require('../middleware/gracePeriod'); const { getSupabaseServiceClient } = require('../utils/supabase'); const router = express.Router(); // GET /joint-history — joint outcome history and phi coefficient -router.get('/joint-history', requireAuth, async (req, res) => { +router.get('/joint-history', requireAuth, checkGracePeriod, async (req, res) => { const { player_a, stat_a, player_b, stat_b } = req.query; // Block free tier diff --git a/src/routes/scan.js b/src/routes/scan.js index 32e9111..6d88b61 100644 --- a/src/routes/scan.js +++ b/src/routes/scan.js @@ -1,5 +1,6 @@ const express = require('express'); const { requireAuth } = require('../middleware/auth'); +const { checkGracePeriod } = require('../middleware/gracePeriod'); const { scanLimit } = require('../middleware/scanLimit'); const { scanParlay } = require('../services/parlayScanService'); @@ -38,7 +39,12 @@ function validateLegs(legs) { return null; } -router.post('/parlay', requireAuth, scanLimit(), async (req, res) => { +// requireAuth → checkGracePeriod → scanLimit ordering matters: +// requireAuth populates req.user; checkGracePeriod may downgrade +// req.user.tier; scanLimit reads tier off req.user to pick the +// per-tier quota. If grace check ran AFTER scanLimit, a just- +// expired Desk user would get unlimited quota for one final scan. +router.post('/parlay', requireAuth, checkGracePeriod, scanLimit(), async (req, res) => { const { legs } = req.body; const validationError = validateLegs(legs); diff --git a/src/services/adapters/apiFootballAdapter.js b/src/services/adapters/apiFootballAdapter.js new file mode 100644 index 0000000..dbcbf35 --- /dev/null +++ b/src/services/adapters/apiFootballAdapter.js @@ -0,0 +1,363 @@ +/** + * api-football.com adapter — PRIMARY soccer data source (Session 9). + * + * The original API-Football by api-sports.io, now self-hosted (it + * left RapidAPI). Free tier: 100 requests/day. Richest soccer data + * we have access to: per-fixture player stats, lineups with grids, + * referee assignments, full event timeline. + * + * Auth: `x-apisports-key` header. This is NOT RapidAPI — DO NOT add + * x-rapidapi-host / x-rapidapi-key here (that's the FootApi backup + * adapter, separate file). + * + * Env: + * API_FOOTBALL_KEY — api-sports.io API key + * + * Rate limit: + * Hard 100 req/day on the upstream side. We track usage in Redis + * via the `apifootball:daily_count` key (24h TTL) and stop at 90 to + * leave a 10-req safety margin for the daily prefetch and any + * ad-hoc operator pulls. + * + * Graceful degradation: + * - Missing key → all functions return null + * - Daily counter exhausted → stale-while-revalidate from Redis + * if available, else null + * - Upstream 4xx/5xx → log + stale-while-revalidate + * fallback, else null + * Never throws. + */ + +const axios = require('axios'); +const { cacheGet, cacheSet } = require('../../utils/redis'); + +const BASE_URL = 'https://v3.football.api-sports.io'; +const HTTP_TIMEOUT_MS = 8_000; + +// Per-endpoint cache TTLs (seconds). Match the upstream data +// volatility: fixtures drift fast, lineups freeze post-kickoff, +// season stats only matter day-over-day. +const TTL = Object.freeze({ + fixtures: 6 * 3600, // 6h + lineups: 24 * 3600, // 24h — locked once posted + playerStats: 24 * 3600, // 24h — locked once final + events: 12 * 3600, // 12h + player: 24 * 3600, // 24h + standings: 12 * 3600, // 12h +}); + +const DAILY_LIMIT = 100; +const SAFETY_MARGIN = 10; +const SOFT_LIMIT = DAILY_LIMIT - SAFETY_MARGIN; // 90 +const DAILY_COUNTER_KEY = 'apifootball:daily_count'; +const DAILY_TTL_SEC = 24 * 3600; + +function hasApiKey() { + return !!process.env.API_FOOTBALL_KEY; +} + +async function readDailyCount() { + const v = await cacheGet(DAILY_COUNTER_KEY); + if (v == null) return 0; + if (typeof v === 'number') return v; + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +async function bumpDailyCount() { + const next = (await readDailyCount()) + 1; + // 24h sliding window — every bump refreshes the TTL so the counter + // doesn't expire mid-day after the first call of the morning. + await cacheSet(DAILY_COUNTER_KEY, next, DAILY_TTL_SEC); + return next; +} + +// One central HTTP path — applies the api-sports auth, rate-limit +// check, caching, and stale-while-revalidate fallback. Returns +// parsed JSON or null. Never throws. +async function fetchWithCache(path, cacheKey, ttl) { + // 1. Fresh cache hit. + const fresh = await cacheGet(cacheKey); + if (fresh !== null) return fresh; + + // 2. No API key → can't fetch. Callers degrade to null. + if (!hasApiKey()) return null; + + // 3. Daily counter check — fall through to stale-while-revalidate. + const used = await readDailyCount(); + if (used >= SOFT_LIMIT) { + const stale = await cacheGet(`${cacheKey}:stale`); + if (stale !== null) return stale; + return null; + } + + // 4. Network. + try { + const res = await axios.get(`${BASE_URL}${path}`, { + headers: { 'x-apisports-key': process.env.API_FOOTBALL_KEY }, + timeout: HTTP_TIMEOUT_MS, + }); + await bumpDailyCount(); + const body = res.data; + if (body && typeof body === 'object') { + await cacheSet(cacheKey, body, ttl); + await cacheSet(`${cacheKey}:stale`, body, ttl * 4); + } + return body; + } catch (err) { + console.warn('[apiFootball] fetch failed:', path, err.message); + const stale = await cacheGet(`${cacheKey}:stale`); + if (stale !== null) return stale; + return null; + } +} + +// ---- Public surface ---- + +/** + * getFixtures — fixtures by league/season/date. World Cup is + * league=1, season=2026. + * + * @param {Object} params { league, season, date? } + * @returns {Promise} + */ +async function getFixtures({ league, season, date } = {}) { + if (!league || !season) return null; + const dateSegment = date ? `:${date}` : ''; + const query = date + ? `?league=${league}&season=${season}&date=${date}` + : `?league=${league}&season=${season}`; + const data = await fetchWithCache( + `/fixtures${query}`, + `apifootball:fixtures:${league}:${season}${dateSegment}`, + TTL.fixtures, + ); + if (data === null) return null; + const list = data.response; + if (!Array.isArray(list)) return []; + return list.map((f) => ({ + id: f.fixture?.id ?? null, + utcDate: f.fixture?.date ?? null, + status: f.fixture?.status?.short ?? null, + venue: f.fixture?.venue?.name ?? null, + referee: f.fixture?.referee ?? null, + league: f.league?.name ?? null, + season: f.league?.season ?? null, + round: f.league?.round ?? null, + homeTeam: f.teams?.home?.name ?? null, + awayTeam: f.teams?.away?.name ?? null, + homeTeamId: f.teams?.home?.id ?? null, + awayTeamId: f.teams?.away?.id ?? null, + score: f.score ?? null, + })); +} + +/** + * getFixtureLineups — starting XI + bench, with grid positions. + */ +async function getFixtureLineups(fixtureId) { + if (!fixtureId) return null; + const data = await fetchWithCache( + `/fixtures/lineups?fixture=${fixtureId}`, + `apifootball:lineups:${fixtureId}`, + TTL.lineups, + ); + if (data === null) return null; + const list = data.response; + if (!Array.isArray(list)) return []; + return list.map((side) => ({ + team: side.team?.name ?? null, + teamId: side.team?.id ?? null, + coach: side.coach?.name ?? null, + formation: side.formation ?? null, + startXI: (side.startXI || []).map((p) => ({ + id: p.player?.id ?? null, + name: p.player?.name ?? null, + number: p.player?.number ?? null, + pos: p.player?.pos ?? null, + grid: p.player?.grid ?? null, + })), + substitutes: (side.substitutes || []).map((p) => ({ + id: p.player?.id ?? null, + name: p.player?.name ?? null, + number: p.player?.number ?? null, + pos: p.player?.pos ?? null, + })), + })); +} + +/** + * getFixturePlayerStats — per-player stats for a finished match. + * THE money endpoint for grade calibration: shots, goals, passes, + * tackles, cards, minutes, official rating. + */ +async function getFixturePlayerStats(fixtureId) { + if (!fixtureId) return null; + const data = await fetchWithCache( + `/fixtures/players?fixture=${fixtureId}`, + `apifootball:playerstats:${fixtureId}`, + TTL.playerStats, + ); + if (data === null) return null; + const list = data.response; + if (!Array.isArray(list)) return []; + const out = []; + for (const side of list) { + const team = side.team?.name ?? null; + for (const player of side.players || []) { + const stats = player.statistics?.[0] || {}; + out.push({ + team, + playerId: player.player?.id ?? null, + name: player.player?.name ?? null, + minutes: stats.games?.minutes ?? null, + position: stats.games?.position ?? null, + rating: stats.games?.rating ?? null, + substitute: !!stats.games?.substitute, + goals: stats.goals?.total ?? 0, + assists: stats.goals?.assists ?? 0, + shots_total: stats.shots?.total ?? 0, + shots_on: stats.shots?.on ?? 0, + passes_total: stats.passes?.total ?? 0, + passes_accuracy: stats.passes?.accuracy ?? null, + tackles_total: stats.tackles?.total ?? 0, + tackles_blocks: stats.tackles?.blocks ?? 0, + tackles_interceptions: stats.tackles?.interceptions ?? 0, + yellow: stats.cards?.yellow ?? 0, + red: stats.cards?.red ?? 0, + saves: stats.goals?.saves ?? null, + }); + } + } + return out; +} + +/** + * getFixtureEvents — minute-by-minute goals, cards, subs. + */ +async function getFixtureEvents(fixtureId) { + if (!fixtureId) return null; + const data = await fetchWithCache( + `/fixtures/events?fixture=${fixtureId}`, + `apifootball:events:${fixtureId}`, + TTL.events, + ); + if (data === null) return null; + const list = data.response; + if (!Array.isArray(list)) return []; + return list.map((e) => ({ + minute: e.time?.elapsed ?? null, + extra: e.time?.extra ?? null, + team: e.team?.name ?? null, + player: e.player?.name ?? null, + assist: e.assist?.name ?? null, + type: e.type ?? null, + detail: e.detail ?? null, + comments: e.comments ?? null, + })); +} + +/** + * getPlayerSeasonStats — season aggregate for a single player. + * The feature extractor reads this for goals_per_90, xG (if exposed + * by the league response), minutes_per_game, start_rate. + */ +async function getPlayerSeasonStats(playerId, season) { + if (!playerId || !season) return null; + const data = await fetchWithCache( + `/players?id=${playerId}&season=${season}`, + `apifootball:player:${playerId}:season:${season}`, + TTL.player, + ); + if (data === null) return null; + const list = data.response; + if (!Array.isArray(list) || list.length === 0) return []; + // The response is an array of { player, statistics: [perCompetition] }. + // We collapse into a flat per-competition list — the caller picks + // the league they care about (e.g. WC = league.id 1). + const player = list[0].player || {}; + const stats = list[0].statistics || []; + return stats.map((s) => ({ + playerId: player.id ?? null, + name: player.name ?? null, + nationality: player.nationality ?? null, + team: s.team?.name ?? null, + teamId: s.team?.id ?? null, + leagueId: s.league?.id ?? null, + leagueName: s.league?.name ?? null, + appearances: s.games?.appearences ?? 0, // sic — api-football typo + lineups: s.games?.lineups ?? 0, + minutes: s.games?.minutes ?? 0, + position: s.games?.position ?? null, + rating: s.games?.rating ?? null, + goals: s.goals?.total ?? 0, + assists: s.goals?.assists ?? 0, + shots_total: s.shots?.total ?? 0, + shots_on: s.shots?.on ?? 0, + passes_total: s.passes?.total ?? 0, + tackles_total: s.tackles?.total ?? 0, + yellow: s.cards?.yellow ?? 0, + red: s.cards?.red ?? 0, + penalty_scored: s.penalty?.scored ?? 0, + penalty_missed: s.penalty?.missed ?? 0, + })); +} + +/** + * getStandings — league table with goals for/against per team. + */ +async function getStandings(league, season) { + if (!league || !season) return null; + const data = await fetchWithCache( + `/standings?league=${league}&season=${season}`, + `apifootball:standings:${league}:${season}`, + TTL.standings, + ); + if (data === null) return null; + const list = data.response; + if (!Array.isArray(list) || list.length === 0) return []; + // World Cup standings are grouped (by group A, B, C…). Flatten so + // the prefetch can compute defensive rank across the whole field. + const out = []; + for (const entry of list) { + const groups = entry.league?.standings || []; + for (const group of groups) { + for (const row of group) { + out.push({ + rank: row.rank ?? null, + team: row.team?.name ?? null, + teamId: row.team?.id ?? null, + played: row.all?.played ?? 0, + win: row.all?.win ?? 0, + draw: row.all?.draw ?? 0, + lose: row.all?.lose ?? 0, + goalsFor: row.all?.goals?.for ?? 0, + goalsAgainst: row.all?.goals?.against ?? 0, + points: row.points ?? 0, + group: row.group ?? null, + }); + } + } + } + return out; +} + +module.exports = { + getFixtures, + getFixtureLineups, + getFixturePlayerStats, + getFixtureEvents, + getPlayerSeasonStats, + getStandings, + hasApiKey, + __internals: { + BASE_URL, + TTL, + DAILY_LIMIT, + SOFT_LIMIT, + DAILY_COUNTER_KEY, + readDailyCount, + bumpDailyCount, + resetCounterForTests: async () => cacheSet(DAILY_COUNTER_KEY, 0, DAILY_TTL_SEC), + }, +}; diff --git a/src/services/adapters/footApiAdapter.js b/src/services/adapters/footApiAdapter.js new file mode 100644 index 0000000..b7b198e --- /dev/null +++ b/src/services/adapters/footApiAdapter.js @@ -0,0 +1,256 @@ +/** + * FootApi adapter — BACKUP soccer data source (Session 9). + * + * Wraps the RapidAPI-hosted FootApi service (Sofascore mirror). Used + * as the fallback when api-football.com is rate-limited or returns + * thin data. Free tier: 50 requests/day. + * + * Auth: RapidAPI headers (x-rapidapi-key + x-rapidapi-host). DO NOT + * use the x-apisports-key header here — that's the primary adapter. + * + * Env: + * RAPID_API_KEY — RapidAPI marketplace key (shared across Tank01 + FootApi) + * FOOTAPI_HOST — host header (defaults to footapi7.p.rapidapi.com) + * + * Rate limit: + * Hard 50 req/day. We track in `footapi:daily_count` (24h TTL) and + * stop at 45 — same 5-req safety margin as the primary adapter + * (smaller absolute margin because the daily budget is smaller). + * + * Tournament IDs used in the URL paths: + * 16 — FIFA World Cup + * (others discovered via the schedule endpoint as needed) + */ + +const axios = require('axios'); +const { cacheGet, cacheSet } = require('../../utils/redis'); + +const HTTP_TIMEOUT_MS = 8_000; + +const TTL = Object.freeze({ + lineups: 24 * 3600, + incidents: 12 * 3600, + referee: 7 * 24 * 3600, // 7d — referee stats move slowly + schedule: 6 * 3600, +}); + +const DAILY_LIMIT = 50; +const SAFETY_MARGIN = 5; +const SOFT_LIMIT = DAILY_LIMIT - SAFETY_MARGIN; // 45 +const DAILY_COUNTER_KEY = 'footapi:daily_count'; +const DAILY_TTL_SEC = 24 * 3600; + +const WC_TOURNAMENT_ID = 16; + +function getHost() { + return process.env.FOOTAPI_HOST || 'footapi7.p.rapidapi.com'; +} + +function hasApiKey() { + return !!process.env.RAPID_API_KEY; +} + +async function readDailyCount() { + const v = await cacheGet(DAILY_COUNTER_KEY); + if (v == null) return 0; + const n = typeof v === 'number' ? v : Number(v); + return Number.isFinite(n) ? n : 0; +} + +async function bumpDailyCount() { + const next = (await readDailyCount()) + 1; + await cacheSet(DAILY_COUNTER_KEY, next, DAILY_TTL_SEC); + return next; +} + +async function fetchWithCache(path, cacheKey, ttl) { + const fresh = await cacheGet(cacheKey); + if (fresh !== null) return fresh; + if (!hasApiKey()) return null; + + const used = await readDailyCount(); + if (used >= SOFT_LIMIT) { + const stale = await cacheGet(`${cacheKey}:stale`); + if (stale !== null) return stale; + return null; + } + + try { + const host = getHost(); + const res = await axios.get(`https://${host}${path}`, { + headers: { + 'x-rapidapi-key': process.env.RAPID_API_KEY, + 'x-rapidapi-host': host, + }, + timeout: HTTP_TIMEOUT_MS, + }); + await bumpDailyCount(); + const body = res.data; + if (body && typeof body === 'object') { + await cacheSet(cacheKey, body, ttl); + await cacheSet(`${cacheKey}:stale`, body, ttl * 4); + } + return body; + } catch (err) { + console.warn('[footApi] fetch failed:', path, err.message); + const stale = await cacheGet(`${cacheKey}:stale`); + if (stale !== null) return stale; + return null; + } +} + +// ---- Public surface ---- + +/** + * getMatchLineups — players with minutesPlayed and the 28-key stats + * block FootApi exposes per player (rating, shots, passes, tackles, + * goals, assists, cards, etc.). + */ +async function getMatchLineups(matchId) { + if (!matchId) return null; + const data = await fetchWithCache( + `/api/match/${matchId}/lineups`, + `footapi:match:${matchId}:lineups`, + TTL.lineups, + ); + if (data === null) return null; + // The FootApi response carries `home` and `away` sides, each with + // `players` arrays. Flatten so callers don't need to reach into + // upstream-specific structure. + const out = []; + for (const side of ['home', 'away']) { + const team = data?.[side]; + if (!team || !Array.isArray(team.players)) continue; + for (const entry of team.players) { + const p = entry?.player || {}; + const stats = entry?.statistics || {}; + out.push({ + team: team.formation ? `${side}(${team.formation})` : side, + side, + playerId: p.id ?? null, + name: p.name ?? null, + position: entry.position ?? null, + shirtNumber: entry.shirtNumber ?? null, + substitute: !!entry.substitute, + captain: !!entry.captain, + minutesPlayed: stats.minutesPlayed ?? null, + rating: stats.rating ?? null, + goals: stats.goals ?? 0, + assists: stats.goalAssist ?? 0, + shots: stats.totalShots ?? 0, + shotsOnTarget: stats.shotOnTarget ?? 0, + passes: stats.totalPass ?? 0, + accuratePasses: stats.accuratePass ?? 0, + tackles: stats.totalTackle ?? 0, + yellow: stats.yellowCards ?? 0, + red: stats.redCards ?? 0, + saves: stats.saves ?? null, + keyPasses: stats.keyPass ?? 0, + }); + } + } + return out; +} + +/** + * getMatchIncidents — minute-by-minute goals, cards, subs. The + * minute + addedTime carry detail the events feed needs for trap + * detection (e.g. late-game cards inflate referee_card_bias signal). + */ +async function getMatchIncidents(matchId) { + if (!matchId) return null; + const data = await fetchWithCache( + `/api/match/${matchId}/incidents`, + `footapi:match:${matchId}:incidents`, + TTL.incidents, + ); + if (data === null) return null; + const list = data?.incidents; + if (!Array.isArray(list)) return []; + return list.map((i) => ({ + type: i.incidentType ?? null, + classType: i.incidentClass ?? null, + minute: i.time ?? null, + addedTime: i.addedTime ?? null, + isHome: i.isHome ?? null, + player: i.player?.name ?? null, + assist: i.assist1?.name ?? null, + text: i.text ?? null, + })); +} + +/** + * getRefereeStatistics — referee card + appearance history per + * tournament. The shape `{ yellowCards, redCards, appearances }` + * feeds the soccer-trap `referee_card_bias` signal. + */ +async function getRefereeStatistics(refereeId) { + if (!refereeId) return null; + const data = await fetchWithCache( + `/api/referee/${refereeId}/statistics`, + `footapi:referee:${refereeId}:stats`, + TTL.referee, + ); + if (data === null) return null; + const stats = data?.statistics; + if (!Array.isArray(stats)) return []; + return stats.map((s) => ({ + tournamentId: s.tournament?.id ?? null, + tournamentName: s.tournament?.name ?? null, + season: s.season?.year ?? null, + appearances: s.appearances ?? 0, + yellowCards: s.yellowCards ?? 0, + redCards: s.redCards ?? 0, + yellowCardsPerGame: s.appearances > 0 ? Math.round((s.yellowCards / s.appearances) * 100) / 100 : null, + redCardsPerGame: s.appearances > 0 ? Math.round((s.redCards / s.appearances) * 1000) / 1000 : null, + })); +} + +/** + * getWorldCupSchedule — fixtures for a date. Tournament ID 16 is + * the FIFA World Cup; the path is `/api/tournament/16/schedules/dd/mm/yyyy`. + */ +async function getWorldCupSchedule(day, month, year) { + if (!day || !month || !year) return null; + const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const data = await fetchWithCache( + `/api/tournament/${WC_TOURNAMENT_ID}/schedules/${day}/${month}/${year}`, + `footapi:wc:schedule:${dateKey}`, + TTL.schedule, + ); + if (data === null) return null; + const list = data?.events; + if (!Array.isArray(list)) return []; + return list.map((e) => ({ + id: e.id ?? null, + startTimestamp: e.startTimestamp ?? null, + status: e.status?.type ?? null, + homeTeam: e.homeTeam?.name ?? null, + awayTeam: e.awayTeam?.name ?? null, + homeTeamId: e.homeTeam?.id ?? null, + awayTeamId: e.awayTeam?.id ?? null, + homeScore: e.homeScore?.current ?? null, + awayScore: e.awayScore?.current ?? null, + venue: e.venue?.name ?? null, + referee: e.referee?.name ?? null, + })); +} + +module.exports = { + getMatchLineups, + getMatchIncidents, + getRefereeStatistics, + getWorldCupSchedule, + hasApiKey, + __internals: { + TTL, + DAILY_LIMIT, + SOFT_LIMIT, + DAILY_COUNTER_KEY, + WC_TOURNAMENT_ID, + readDailyCount, + bumpDailyCount, + getHost, + resetCounterForTests: async () => cacheSet(DAILY_COUNTER_KEY, 0, DAILY_TTL_SEC), + }, +}; diff --git a/src/services/adapters/tank01MlbAdapter.js b/src/services/adapters/tank01MlbAdapter.js new file mode 100644 index 0000000..eff8294 --- /dev/null +++ b/src/services/adapters/tank01MlbAdapter.js @@ -0,0 +1,185 @@ +/** + * Tank01 MLB adapter (Session 9) — live in-game stats + batter-vs-pitcher. + * + * The BvP endpoint is the headline new signal: pre-game, we can show + * a batter's historical line against the starting pitcher (hits, K's, + * total bases). This was a documented Day-1 gap. + * + * Same RAPID_API_KEY as the NBA adapter; different host. Free tier + * is 1,000 req/month — we rely on cache TTLs to bound consumption. + * + * Env: + * RAPID_API_KEY — shared RapidAPI marketplace key + * TANK01_MLB_HOST — host (default `tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com`) + */ + +const axios = require('axios'); +const { cacheGet, cacheSet } = require('../../utils/redis'); + +const HTTP_TIMEOUT_MS = 8_000; +const DEFAULT_HOST = 'tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com'; + +const TTL = Object.freeze({ + boxScoreLive: 5 * 60, + boxScoreFinal: 24 * 3600, + scoreboard: 1 * 3600, + bvp: 24 * 3600, // BvP doesn't change mid-day — 24h cache is fine +}); + +function getHost() { + return process.env.TANK01_MLB_HOST || DEFAULT_HOST; +} + +function hasApiKey() { + return !!process.env.RAPID_API_KEY; +} + +async function fetchWithCache(path, cacheKey, ttl) { + const fresh = await cacheGet(cacheKey); + if (fresh !== null) return fresh; + if (!hasApiKey()) return null; + + try { + const host = getHost(); + const res = await axios.get(`https://${host}${path}`, { + headers: { + 'x-rapidapi-key': process.env.RAPID_API_KEY, + 'x-rapidapi-host': host, + }, + timeout: HTTP_TIMEOUT_MS, + }); + const body = res.data; + if (body && typeof body === 'object') { + await cacheSet(cacheKey, body, ttl); + await cacheSet(`${cacheKey}:stale`, body, ttl * 4); + } + return body; + } catch (err) { + console.warn('[tank01MLB] fetch failed:', path, err.message); + const stale = await cacheGet(`${cacheKey}:stale`); + if (stale !== null) return stale; + return null; + } +} + +/** + * getMLBBoxScore — per-player batting + pitching lines. + * Status-aware TTL: same pattern as the NBA adapter (5 min live, + * 24 h once Final). + */ +async function getMLBBoxScore(gameId) { + if (!gameId) return null; + const cacheKey = `tank01:mlb:boxscore:${gameId}`; + const data = await fetchWithCache( + `/getMLBBoxScore?gameID=${encodeURIComponent(gameId)}`, + cacheKey, + TTL.boxScoreLive, + ); + if (data === null) return null; + const body = data?.body || data; + const isFinal = (() => { + const s = body?.gameStatus || body?.status || ''; + return typeof s === 'string' && /final/i.test(s); + })(); + if (isFinal) await cacheSet(cacheKey, data, TTL.boxScoreFinal); + + // Project batters + pitchers into a flat list. Tank01 splits these + // into `playerStats.{batting,pitching}` — we tag the role so the + // consumer can filter. + const stats = body?.playerStats || {}; + const out = []; + for (const [id, entry] of Object.entries(stats.batting || stats.batters || {})) { + out.push({ role: 'batter', playerId: id, name: entry.longName || entry.name || null, team: entry.teamAbv || null, _raw: entry, _final: isFinal }); + } + for (const [id, entry] of Object.entries(stats.pitching || stats.pitchers || {})) { + out.push({ role: 'pitcher', playerId: id, name: entry.longName || entry.name || null, team: entry.teamAbv || null, _raw: entry, _final: isFinal }); + } + return out; +} + +/** + * getMLBBatterVsPitcher — historical BvP matchup. The headline new + * MLB signal: a batter's plate appearances, hits, K's, HRs, total + * bases against a specific pitcher's career. Use ID, not name. + */ +async function getMLBBatterVsPitcher(batterId, pitcherId) { + if (!batterId || !pitcherId) return null; + const data = await fetchWithCache( + `/getMLBBatterVsPitcher?batterID=${encodeURIComponent(batterId)}&pitcherID=${encodeURIComponent(pitcherId)}`, + `tank01:mlb:bvp:${batterId}:${pitcherId}`, + TTL.bvp, + ); + if (data === null) return null; + const body = data?.body || data; + // The response shape can be either a single object or a `matchups` + // array depending on schema version — normalize. + if (Array.isArray(body)) { + return body.map(projectBvP); + } + return projectBvP(body); +} + +function projectBvP(row) { + if (!row || typeof row !== 'object') return null; + return { + batterId: row.batterID ?? null, + pitcherId: row.pitcherID ?? null, + plateAppearances: num(row.PA ?? row.pa, 0), + atBats: num(row.AB ?? row.ab, 0), + hits: num(row.H ?? row.hits, 0), + doubles: num(row['2B'] ?? row.doubles, 0), + triples: num(row['3B'] ?? row.triples, 0), + homeRuns: num(row.HR ?? row.homeRuns, 0), + rbi: num(row.RBI ?? row.rbi, 0), + walks: num(row.BB ?? row.walks, 0), + strikeouts: num(row.SO ?? row.K ?? row.strikeouts, 0), + avg: row.AVG ?? row.avg ?? null, + ops: row.OPS ?? row.ops ?? null, + }; +} + +function num(v, fallback = 0) { + if (v == null || v === '') return fallback; + const n = Number(v); + return Number.isFinite(n) ? n : fallback; +} + +/** + * getMLBDailyScoreboard — schedule + scores for a date. + */ +async function getMLBDailyScoreboard(date) { + if (!date) return null; + const ymd = String(date).replace(/-/g, ''); + const data = await fetchWithCache( + `/getMLBScoresOnly?gameDate=${ymd}`, + `tank01:mlb:scoreboard:${ymd}`, + TTL.scoreboard, + ); + if (data === null) return null; + const body = data?.body || data; + // Body shape varies: array of games OR map keyed by gameID. Normalize to array. + const entries = Array.isArray(body) ? body : Object.values(body || {}); + return entries.map((g) => ({ + gameId: g.gameID ?? null, + homeTeam: g.home ?? null, + awayTeam: g.away ?? null, + gameTime: g.gameTime ?? null, + gameStatus: g.gameStatus ?? null, + homeScore: num(g.homePts, null), + awayScore: num(g.awayPts, null), + })); +} + +module.exports = { + getMLBBoxScore, + getMLBBatterVsPitcher, + getMLBDailyScoreboard, + hasApiKey, + __internals: { + TTL, + DEFAULT_HOST, + getHost, + projectBvP, + num, + }, +}; diff --git a/src/services/adapters/tank01NbaAdapter.js b/src/services/adapters/tank01NbaAdapter.js new file mode 100644 index 0000000..a7d3599 --- /dev/null +++ b/src/services/adapters/tank01NbaAdapter.js @@ -0,0 +1,188 @@ +/** + * Tank01 NBA adapter (Session 9) — RapidAPI-hosted, live in-game stats. + * + * Sits behind the same RAPID_API_KEY as `footApiAdapter`. Free tier + * is 1,000 req/month — much more generous than FootApi's daily cap, + * so we don't keep a tight in-process counter here; we lean on cache + * TTLs to bound consumption. + * + * Env: + * RAPID_API_KEY — shared RapidAPI marketplace key + * TANK01_NBA_HOST — host (default `tank01-fantasy-stats.p.rapidapi.com`) + * + * TTL policy — box scores are special: + * - Mid-game (status not Final) → 5 min cache so live stats refresh + * - Post-game (status Final) → 24 h cache so we stop pulling + * - Games-for-date → 1 h + * - Betting odds → 15 min + * + * Wired into computeFeatures via Session 9 cascade — Tank01 box-score + * data is preferred when ESPN's scoreboard is sparse (e.g. early in + * the day before tip-off has populated minute-by-minute stats). + */ + +const axios = require('axios'); +const { cacheGet, cacheSet } = require('../../utils/redis'); + +const HTTP_TIMEOUT_MS = 8_000; +const DEFAULT_HOST = 'tank01-fantasy-stats.p.rapidapi.com'; + +const TTL = Object.freeze({ + boxScoreLive: 5 * 60, // 5min while in-game + boxScoreFinal: 24 * 3600, // 24h once final + games: 1 * 3600, // 1h + odds: 15 * 60, // 15min +}); + +function getHost() { + return process.env.TANK01_NBA_HOST || DEFAULT_HOST; +} + +function hasApiKey() { + return !!process.env.RAPID_API_KEY; +} + +// Centralized fetch — RapidAPI auth + cache + stale-while-revalidate. +async function fetchWithCache(path, cacheKey, ttl) { + const fresh = await cacheGet(cacheKey); + if (fresh !== null) return fresh; + if (!hasApiKey()) return null; + + try { + const host = getHost(); + const res = await axios.get(`https://${host}${path}`, { + headers: { + 'x-rapidapi-key': process.env.RAPID_API_KEY, + 'x-rapidapi-host': host, + }, + timeout: HTTP_TIMEOUT_MS, + }); + const body = res.data; + if (body && typeof body === 'object') { + await cacheSet(cacheKey, body, ttl); + await cacheSet(`${cacheKey}:stale`, body, ttl * 4); + } + return body; + } catch (err) { + console.warn('[tank01NBA] fetch failed:', path, err.message); + const stale = await cacheGet(`${cacheKey}:stale`); + if (stale !== null) return stale; + return null; + } +} + +/** + * getNBABoxScore — live per-player stats for a single game. + * + * Status-aware caching: a mid-game refresh that pulls "in-progress" + * data caches for 5min so the next read isn't stale; a "Final" pull + * caches for 24h because nothing is changing. The status field varies + * by Tank01 schema version — we check `gameStatus` first, fall back + * to looking for "Final" anywhere in a top-level string field. + */ +async function getNBABoxScore(gameId) { + if (!gameId) return null; + // We don't yet know if the game is final, so request with the live + // TTL — if the response reports Final we re-cache under the long TTL. + const cacheKey = `tank01:nba:boxscore:${gameId}`; + const data = await fetchWithCache( + `/getNBABoxScore?gameID=${encodeURIComponent(gameId)}`, + cacheKey, + TTL.boxScoreLive, + ); + if (data === null) return null; + const body = data?.body || data; + const isFinal = (() => { + const s = body?.gameStatus || body?.status || ''; + return typeof s === 'string' && /final/i.test(s); + })(); + // Upgrade the cache TTL on Final. + if (isFinal) { + await cacheSet(cacheKey, data, TTL.boxScoreFinal); + } + // Project: top-level `playerStats` is a map keyed by playerID. + const players = body?.playerStats || body?.playerStatistics || {}; + const out = []; + for (const [id, p] of Object.entries(players)) { + out.push({ + playerId: id, + name: p.longName || p.shortName || null, + team: p.teamAbv || null, + mins: p.mins ?? null, + pts: numOr(p.pts), + reb: numOr(p.reb), + ast: numOr(p.ast), + stl: numOr(p.stl), + blk: numOr(p.blk), + tov: numOr(p.TOV ?? p.tov), + threes: numOr(p.tptfgm), + fga: numOr(p.fga), + fgm: numOr(p.fgm), + fta: numOr(p.fta), + ftm: numOr(p.ftm), + _final: isFinal, + }); + } + return out; +} + +function numOr(v, fallback = 0) { + if (v == null) return fallback; + const n = Number(v); + return Number.isFinite(n) ? n : fallback; +} + +/** + * getNBAGamesForDate — schedule + scoreline. Date format is YYYYMMDD + * per Tank01's schema (NOT ISO). + */ +async function getNBAGamesForDate(date) { + if (!date) return null; + const ymd = String(date).replace(/-/g, ''); + const data = await fetchWithCache( + `/getNBAGamesForDate?gameDate=${ymd}`, + `tank01:nba:games:${ymd}`, + TTL.games, + ); + if (data === null) return null; + const list = data?.body; + if (!Array.isArray(list)) return []; + return list.map((g) => ({ + gameId: g.gameID ?? null, + homeTeam: g.home ?? null, + awayTeam: g.away ?? null, + gameTime: g.gameTime_epoch ?? g.gameTime ?? null, + gameStatus: g.gameStatus ?? null, + homeScore: numOr(g.homePts, null), + awayScore: numOr(g.awayPts, null), + })); +} + +/** + * getNBABettingOdds — Tank01's odds feed (book-by-book). Useful as a + * sanity-check signal alongside our odds-api primary. + */ +async function getNBABettingOdds(date) { + if (!date) return null; + const ymd = String(date).replace(/-/g, ''); + const data = await fetchWithCache( + `/getNBABettingOdds?gameDate=${ymd}`, + `tank01:nba:odds:${ymd}`, + TTL.odds, + ); + if (data === null) return null; + return data?.body || data; +} + +module.exports = { + getNBABoxScore, + getNBAGamesForDate, + getNBABettingOdds, + hasApiKey, + __internals: { + TTL, + DEFAULT_HOST, + getHost, + numOr, + }, +}; diff --git a/src/services/intelligence/soccerFeatureExtractor.js b/src/services/intelligence/soccerFeatureExtractor.js index 9132efc..99b04e5 100644 --- a/src/services/intelligence/soccerFeatureExtractor.js +++ b/src/services/intelligence/soccerFeatureExtractor.js @@ -39,27 +39,66 @@ async function safeCacheGet(key) { } } -// Read per-player season aggregate. The prefetch writes a flat shape -// that already collapses played + minutes into per-90 rates so we don't -// recompute on every request. +// Source-priority cascade (Session 9). Each load function checks +// adapter-specific keys in priority order: +// 1. api-football.com (PRIMARY — 100 req/day, richest payload) +// 2. FootApi via RapidAPI (BACKUP — 50 req/day, Sofascore mirror) +// 3. football-data.org (TERTIARY — fixtures/standings only) +// +// The richer adapters write name-keyed aliases (`apifootball:player_by_name:…`, +// `footapi:player_by_name:…`) during the daily prefetch so the request +// path can resolve without an upstream call. When the alias key is +// missing — either because the prefetch hasn't run, the adapter has +// no key configured, or the player isn't covered — we fall through to +// the next source. Final fallback is the legacy `soccer:player:{name}` +// key the football-data prefetch already populates. +// +// Every value is tagged with `_source` so trap detection and reasoning +// can attribute the data origin (and so we can spot when a source is +// silently failing in production logs). +async function loadFromCascade(keys) { + for (const { key, source } of keys) { + const v = await safeCacheGet(key); + if (v && typeof v === 'object') { + return { ...v, _source: v._source || source }; + } + } + return null; +} + async function loadPlayerProfile(playerName) { if (!playerName) return null; - return safeCacheGet(`soccer:player:${normalizeName(playerName)}`); + const n = normalizeName(playerName); + return loadFromCascade([ + { key: `apifootball:player_by_name:${n}`, source: 'api-football' }, + { key: `footapi:player_by_name:${n}`, source: 'footapi' }, + { key: `soccer:player:${n}`, source: 'football-data' }, + ]); } async function loadNextMatch(teamName) { if (!teamName) return null; - return safeCacheGet(`soccer:nextmatch:${teamName}`); + return loadFromCascade([ + { key: `apifootball:nextmatch:${teamName}`, source: 'api-football' }, + { key: `soccer:nextmatch:${teamName}`, source: 'football-data' }, + ]); } async function loadLastFixture(teamName) { if (!teamName) return null; - return safeCacheGet(`soccer:lastfixture:${teamName}`); + return loadFromCascade([ + { key: `apifootball:lastfixture:${teamName}`, source: 'api-football' }, + { key: `soccer:lastfixture:${teamName}`, source: 'football-data' }, + ]); } async function loadRefereeProfile(refName) { if (!refName) return null; - return safeCacheGet(`soccer:referee:${refName}`); + return loadFromCascade([ + { key: `apifootball:referee_by_name:${refName}`, source: 'api-football' }, + { key: `footapi:referee_by_name:${refName}`, source: 'footapi' }, + { key: `soccer:referee:${refName}`, source: 'football-data' }, + ]); } async function loadTeamDefense(league, teamName) { @@ -221,6 +260,16 @@ async function extractSoccerFeatures(input = {}) { isHome, gameLogs: [], errors, + // Session 9 — which source the cascade actually resolved for + // each load. Useful for debugging "why does Messi's profile + // look thin?" — if `player_source: 'football-data'` it means + // the api-football prefetch hasn't populated his row yet. + sources: { + player: profile?._source || null, + nextMatch: nextMatch?._source || null, + lastFixture: lastFixture?._source || null, + referee: refProfile?._source || null, + }, }, }; } diff --git a/tests/unit/apiFootballAdapter.test.js b/tests/unit/apiFootballAdapter.test.js new file mode 100644 index 0000000..7633c24 --- /dev/null +++ b/tests/unit/apiFootballAdapter.test.js @@ -0,0 +1,256 @@ +// apiFootballAdapter — PRIMARY soccer source. Tests the auth header +// shape (x-apisports-key, NOT RapidAPI), the rate-limit bookkeeping, +// the graceful-degradation paths, and the per-endpoint projection. + +const mockAxiosGet = jest.fn(); +jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) })); + +const mockCacheStore = new Map(); +jest.mock('../../src/utils/redis', () => ({ + cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null), + cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; }, + cacheDel: async (k) => { mockCacheStore.delete(k); return true; }, + isDegraded: () => false, +})); + +const adapter = require('../../src/services/adapters/apiFootballAdapter'); + +beforeEach(async () => { + mockAxiosGet.mockReset(); + mockCacheStore.clear(); + await adapter.__internals.resetCounterForTests(); + // Re-clear so the counter set above doesn't persist into the next test. + mockCacheStore.clear(); +}); + +describe('apiFootballAdapter', () => { + describe('graceful degradation when API_FOOTBALL_KEY missing', () => { + const original = process.env.API_FOOTBALL_KEY; + beforeAll(() => { delete process.env.API_FOOTBALL_KEY; }); + afterAll(() => { if (original !== undefined) process.env.API_FOOTBALL_KEY = original; }); + + test('hasApiKey reports false', () => { + expect(adapter.hasApiKey()).toBe(false); + }); + + test('all endpoints return null without touching axios', async () => { + expect(await adapter.getFixtures({ league: 1, season: 2026 })).toBeNull(); + expect(await adapter.getFixtureLineups(42)).toBeNull(); + expect(await adapter.getFixturePlayerStats(42)).toBeNull(); + expect(await adapter.getFixtureEvents(42)).toBeNull(); + expect(await adapter.getPlayerSeasonStats(100, 2026)).toBeNull(); + expect(await adapter.getStandings(1, 2026)).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + }); + + describe('with key configured', () => { + beforeAll(() => { process.env.API_FOOTBALL_KEY = 'test-apisports-key'; }); + + test('auth header is x-apisports-key — NOT RapidAPI', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { response: [] } }); + await adapter.getFixtures({ league: 1, season: 2026 }); + const [, opts] = mockAxiosGet.mock.calls[0]; + expect(opts.headers['x-apisports-key']).toBe('test-apisports-key'); + // RapidAPI headers must NOT be present. + expect(opts.headers['x-rapidapi-key']).toBeUndefined(); + expect(opts.headers['x-rapidapi-host']).toBeUndefined(); + }); + + test('getFixtures projects to the unified shape', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + response: [ + { + fixture: { id: 9001, date: '2026-06-11T20:00:00+00:00', status: { short: 'NS' }, venue: { name: 'Estadio Azteca' }, referee: 'Daniele Orsato' }, + league: { name: 'World Cup', season: 2026, round: 'Group Stage - 1' }, + teams: { home: { id: 26, name: 'Mexico' }, away: { id: 6, name: 'USA' } }, + score: { fulltime: { home: null, away: null } }, + }, + ], + }, + }); + const fixtures = await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' }); + expect(fixtures).toHaveLength(1); + expect(fixtures[0]).toMatchObject({ + id: 9001, + homeTeam: 'Mexico', + awayTeam: 'USA', + venue: 'Estadio Azteca', + referee: 'Daniele Orsato', + league: 'World Cup', + }); + }); + + test('getFixtures with no date works (whole season)', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { response: [{ fixture: { id: 1 }, teams: { home: { name: 'A' }, away: { name: 'B' } } }] } }); + const fixtures = await adapter.getFixtures({ league: 1, season: 2026 }); + expect(fixtures).toHaveLength(1); + const [url] = mockAxiosGet.mock.calls[0]; + expect(url).not.toMatch(/date=/); + }); + + test('null params bounce without touching axios', async () => { + expect(await adapter.getFixtures({})).toBeNull(); + expect(await adapter.getFixtureLineups(null)).toBeNull(); + expect(await adapter.getPlayerSeasonStats(null, 2026)).toBeNull(); + expect(await adapter.getStandings(1, null)).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + + test('getFixturePlayerStats flattens per-team rosters into one list', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + response: [ + { + team: { name: 'Argentina' }, + players: [ + { + player: { id: 1, name: 'Messi' }, + statistics: [{ + games: { minutes: 88, position: 'F', rating: '8.4', substitute: false }, + goals: { total: 1, assists: 1, saves: null }, + shots: { total: 5, on: 3 }, + passes: { total: 47, accuracy: 89 }, + tackles: { total: 1, blocks: 0, interceptions: 2 }, + cards: { yellow: 0, red: 0 }, + }], + }, + ], + }, + { + team: { name: 'France' }, + players: [ + { + player: { id: 2, name: 'Mbappe' }, + statistics: [{ + games: { minutes: 90, position: 'F', rating: '7.9', substitute: false }, + goals: { total: 2, assists: 0, saves: null }, + shots: { total: 7, on: 4 }, + passes: { total: 28, accuracy: 71 }, + tackles: { total: 0 }, + cards: { yellow: 1, red: 0 }, + }], + }, + ], + }, + ], + }, + }); + const stats = await adapter.getFixturePlayerStats(9001); + expect(stats).toHaveLength(2); + const messi = stats.find((p) => p.name === 'Messi'); + expect(messi.team).toBe('Argentina'); + expect(messi.goals).toBe(1); + expect(messi.shots_on).toBe(3); + const mbappe = stats.find((p) => p.name === 'Mbappe'); + expect(mbappe.team).toBe('France'); + expect(mbappe.goals).toBe(2); + expect(mbappe.yellow).toBe(1); + }); + + test('getFixtureLineups projects formation + startXI + bench', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + response: [ + { + team: { id: 26, name: 'Mexico' }, + coach: { name: 'Some Coach' }, + formation: '4-3-3', + startXI: [ + { player: { id: 10, name: 'GK', number: 1, pos: 'G', grid: '1:1' } }, + ], + substitutes: [{ player: { id: 11, name: 'Sub', number: 22, pos: 'M' } }], + }, + ], + }, + }); + const lineups = await adapter.getFixtureLineups(9001); + expect(lineups[0].formation).toBe('4-3-3'); + expect(lineups[0].startXI[0].name).toBe('GK'); + expect(lineups[0].substitutes[0].name).toBe('Sub'); + }); + + test('getStandings flattens group standings into a flat list', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + response: [{ + league: { + standings: [ + [ // Group A + { rank: 1, team: { id: 26, name: 'Mexico' }, all: { played: 3, win: 2, draw: 1, lose: 0, goals: { for: 5, against: 2 } }, points: 7, group: 'Group A' }, + ], + [ // Group B + { rank: 1, team: { id: 5, name: 'Argentina' }, all: { played: 3, win: 3, draw: 0, lose: 0, goals: { for: 6, against: 1 } }, points: 9, group: 'Group B' }, + ], + ], + }, + }], + }, + }); + const standings = await adapter.getStandings(1, 2026); + expect(standings).toHaveLength(2); + expect(standings.find((s) => s.team === 'Mexico')?.points).toBe(7); + expect(standings.find((s) => s.team === 'Argentina')?.points).toBe(9); + }); + + test('cache hit on repeat call (axios not re-invoked)', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { response: [{ fixture: { id: 1 }, teams: { home: { name: 'A' }, away: { name: 'B' } } }] } }); + await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' }); + await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' }); + expect(mockAxiosGet).toHaveBeenCalledTimes(1); + }); + + test('axios throw → null + stale-while-revalidate', async () => { + mockCacheStore.set('apifootball:fixtures:1:2026:2026-06-11:stale', { response: [{ fixture: { id: 999 }, teams: { home: { name: 'X' }, away: { name: 'Y' } } }] }); + mockAxiosGet.mockRejectedValueOnce(new Error('upstream 500')); + const fixtures = await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' }); + expect(fixtures[0].id).toBe(999); + }); + + test('axios throw with no stale → returns null', async () => { + mockAxiosGet.mockRejectedValueOnce(new Error('network down')); + const fixtures = await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' }); + expect(fixtures).toBeNull(); + }); + }); + + describe('daily rate-limit accounting', () => { + beforeAll(() => { process.env.API_FOOTBALL_KEY = 'test-apisports-key'; }); + + test('bumps the counter on a successful network call', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { response: [] } }); + await adapter.getFixtures({ league: 1, season: 2026 }); + const count = await adapter.__internals.readDailyCount(); + expect(count).toBe(1); + }); + + test('does NOT bump on cache hit', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { response: [{ fixture: { id: 1 }, teams: { home: { name: 'A' }, away: { name: 'B' } } }] } }); + await adapter.getFixtures({ league: 1, season: 2026 }); + await adapter.getFixtures({ league: 1, season: 2026 }); // cached + const count = await adapter.__internals.readDailyCount(); + expect(count).toBe(1); + }); + + test('at SOFT_LIMIT, refuses network + serves stale if present', async () => { + // Prime the counter to SOFT_LIMIT (90). + const { cacheSet } = require('../../src/utils/redis'); + await cacheSet(adapter.__internals.DAILY_COUNTER_KEY, adapter.__internals.SOFT_LIMIT); + mockCacheStore.set('apifootball:fixtures:1:2026:stale', { response: [{ fixture: { id: 7 }, teams: { home: { name: 'X' }, away: { name: 'Y' } } }] }); + + const fixtures = await adapter.getFixtures({ league: 1, season: 2026 }); + expect(fixtures[0].id).toBe(7); + // Network NOT called — the bucket stopped us. + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + + test('at SOFT_LIMIT with no stale → null', async () => { + const { cacheSet } = require('../../src/utils/redis'); + await cacheSet(adapter.__internals.DAILY_COUNTER_KEY, adapter.__internals.SOFT_LIMIT); + const fixtures = await adapter.getFixtures({ league: 1, season: 2026 }); + expect(fixtures).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/footApiAdapter.test.js b/tests/unit/footApiAdapter.test.js new file mode 100644 index 0000000..3ecbd2c --- /dev/null +++ b/tests/unit/footApiAdapter.test.js @@ -0,0 +1,201 @@ +// footApiAdapter — BACKUP soccer source via RapidAPI. Tests the +// RapidAPI auth header shape, the per-endpoint projection, and the +// graceful-degradation paths. + +const mockAxiosGet = jest.fn(); +jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) })); + +const mockCacheStore = new Map(); +jest.mock('../../src/utils/redis', () => ({ + cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null), + cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; }, + cacheDel: async (k) => { mockCacheStore.delete(k); return true; }, + isDegraded: () => false, +})); + +const adapter = require('../../src/services/adapters/footApiAdapter'); + +beforeEach(async () => { + mockAxiosGet.mockReset(); + mockCacheStore.clear(); +}); + +describe('footApiAdapter', () => { + describe('graceful degradation when RAPID_API_KEY missing', () => { + const original = process.env.RAPID_API_KEY; + beforeAll(() => { delete process.env.RAPID_API_KEY; }); + afterAll(() => { if (original !== undefined) process.env.RAPID_API_KEY = original; }); + + test('hasApiKey reports false', () => { + expect(adapter.hasApiKey()).toBe(false); + }); + + test('all endpoints return null without touching axios', async () => { + expect(await adapter.getMatchLineups(123)).toBeNull(); + expect(await adapter.getMatchIncidents(123)).toBeNull(); + expect(await adapter.getRefereeStatistics(7)).toBeNull(); + expect(await adapter.getWorldCupSchedule(11, 6, 2026)).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + }); + + describe('with key configured', () => { + beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; }); + + test('auth headers are RapidAPI shape — NOT x-apisports-key', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: {} }); + await adapter.getMatchLineups(42); + const [url, opts] = mockAxiosGet.mock.calls[0]; + expect(url).toMatch(/^https:\/\/footapi7\.p\.rapidapi\.com/); + expect(opts.headers['x-rapidapi-key']).toBe('test-rapid-key'); + expect(opts.headers['x-rapidapi-host']).toBe('footapi7.p.rapidapi.com'); + // Primary adapter's header MUST NOT appear. + expect(opts.headers['x-apisports-key']).toBeUndefined(); + }); + + test('FOOTAPI_HOST override is honored', async () => { + const originalHost = process.env.FOOTAPI_HOST; + process.env.FOOTAPI_HOST = 'mirror.rapidapi.com'; + mockAxiosGet.mockResolvedValueOnce({ data: {} }); + await adapter.getMatchLineups(7); + const [url, opts] = mockAxiosGet.mock.calls[0]; + expect(url).toMatch(/^https:\/\/mirror\.rapidapi\.com/); + expect(opts.headers['x-rapidapi-host']).toBe('mirror.rapidapi.com'); + if (originalHost !== undefined) process.env.FOOTAPI_HOST = originalHost; + else delete process.env.FOOTAPI_HOST; + }); + + test('getMatchLineups flattens home + away into one player list', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + home: { + formation: '4-3-3', + players: [ + { + player: { id: 1, name: 'Saka' }, + position: 'F', + shirtNumber: 7, + statistics: { minutesPlayed: 88, rating: 8.1, goals: 1, goalAssist: 1, totalShots: 4, shotOnTarget: 2, totalPass: 35, accuratePass: 30, totalTackle: 2, yellowCards: 0, redCards: 0, keyPass: 3 }, + }, + ], + }, + away: { + formation: '4-2-3-1', + players: [ + { + player: { id: 2, name: 'Mbappe' }, + position: 'F', + substitute: false, + statistics: { minutesPlayed: 90, rating: 7.7, goals: 0, totalShots: 5, shotOnTarget: 1, totalPass: 22, accuratePass: 18, yellowCards: 1 }, + }, + ], + }, + }, + }); + const lineups = await adapter.getMatchLineups(101); + expect(lineups).toHaveLength(2); + const saka = lineups.find((p) => p.name === 'Saka'); + expect(saka.side).toBe('home'); + expect(saka.goals).toBe(1); + expect(saka.shotsOnTarget).toBe(2); + expect(saka.assists).toBe(1); + const mbappe = lineups.find((p) => p.name === 'Mbappe'); + expect(mbappe.side).toBe('away'); + expect(mbappe.yellow).toBe(1); + }); + + test('getMatchIncidents projects time + addedTime + player + type', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + incidents: [ + { incidentType: 'goal', time: 43, addedTime: 0, isHome: true, player: { name: 'A' }, assist1: { name: 'B' }, text: '1-0' }, + { incidentType: 'card', incidentClass: 'yellow', time: 90, addedTime: 4, player: { name: 'C' } }, + ], + }, + }); + const events = await adapter.getMatchIncidents(101); + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ type: 'goal', minute: 43, player: 'A', assist: 'B' }); + expect(events[1]).toMatchObject({ type: 'card', minute: 90, addedTime: 4 }); + }); + + test('getRefereeStatistics computes per-game rates', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + statistics: [ + { tournament: { id: 16, name: 'World Cup' }, season: { year: 2022 }, appearances: 6, yellowCards: 24, redCards: 1 }, + ], + }, + }); + const refs = await adapter.getRefereeStatistics(99); + expect(refs).toHaveLength(1); + // 24/6 = 4.00 cards/game; 1/6 = ~0.167 red/game. + expect(refs[0].yellowCardsPerGame).toBeCloseTo(4.0); + expect(refs[0].redCardsPerGame).toBeCloseTo(0.167, 2); + }); + + test('getRefereeStatistics handles zero appearances gracefully', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { statistics: [{ tournament: { id: 16 }, appearances: 0, yellowCards: 0, redCards: 0 }] }, + }); + const refs = await adapter.getRefereeStatistics(99); + expect(refs[0].yellowCardsPerGame).toBeNull(); + expect(refs[0].redCardsPerGame).toBeNull(); + }); + + test('getWorldCupSchedule maps events with venue + referee', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + events: [ + { + id: 5, startTimestamp: 1749672000, status: { type: 'notstarted' }, + homeTeam: { id: 26, name: 'Mexico' }, awayTeam: { id: 6, name: 'USA' }, + homeScore: { current: 0 }, awayScore: { current: 0 }, + venue: { name: 'Estadio Azteca' }, referee: { name: 'Daniele Orsato' }, + }, + ], + }, + }); + const matches = await adapter.getWorldCupSchedule(11, 6, 2026); + expect(matches[0]).toMatchObject({ + id: 5, homeTeam: 'Mexico', awayTeam: 'USA', venue: 'Estadio Azteca', referee: 'Daniele Orsato', + }); + }); + + test('cache hit on repeat call (axios not re-invoked)', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { home: { players: [] }, away: { players: [] } } }); + await adapter.getMatchLineups(101); + await adapter.getMatchLineups(101); + expect(mockAxiosGet).toHaveBeenCalledTimes(1); + }); + + test('null IDs bounce without touching axios', async () => { + expect(await adapter.getMatchLineups(null)).toBeNull(); + expect(await adapter.getMatchIncidents(null)).toBeNull(); + expect(await adapter.getRefereeStatistics(null)).toBeNull(); + expect(await adapter.getWorldCupSchedule(null, 6, 2026)).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + }); + + describe('daily rate-limit (50 budget, soft 45)', () => { + beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; }); + beforeEach(async () => { await adapter.__internals.resetCounterForTests(); mockCacheStore.clear(); }); + + test('bumps counter on successful network call', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: {} }); + await adapter.getMatchLineups(42); + expect(await adapter.__internals.readDailyCount()).toBe(1); + }); + + test('at SOFT_LIMIT refuses network + serves stale', async () => { + const { cacheSet } = require('../../src/utils/redis'); + await cacheSet(adapter.__internals.DAILY_COUNTER_KEY, adapter.__internals.SOFT_LIMIT); + mockCacheStore.set('footapi:match:42:lineups:stale', { home: { players: [{ player: { id: 1, name: 'Stale' }, statistics: {} }] }, away: { players: [] } }); + + const lineups = await adapter.getMatchLineups(42); + expect(lineups[0].name).toBe('Stale'); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/gracePeriod.test.js b/tests/unit/gracePeriod.test.js new file mode 100644 index 0000000..76ee3bd --- /dev/null +++ b/tests/unit/gracePeriod.test.js @@ -0,0 +1,112 @@ +// Grace-period middleware tests. We mock the supabase client at the +// module boundary so the middleware's calls land on a controllable +// chainable fake. No real DB / network. + +const mockSupabaseUpdates = []; +const supabaseFake = { + from(table) { + const ctx = { table, filters: [], action: null }; + const proxy = { + update(patch) { + ctx.patch = patch; + ctx.action = 'update'; + mockSupabaseUpdates.push(ctx); + return proxy; + }, + eq(col, val) { + ctx.filters.push([col, val]); + // The middleware awaits the result of .eq() after .update() — + // return a resolved promise (no error). + return Promise.resolve({ error: null }); + }, + }; + return proxy; + }, +}; + +jest.mock('../../src/utils/supabase', () => ({ + getSupabaseServiceClient: () => supabaseFake, +})); + +const { checkGracePeriod } = require('../../src/middleware/gracePeriod'); + +beforeEach(() => { + mockSupabaseUpdates.length = 0; +}); + +function runMiddleware(req) { + return new Promise((resolve, reject) => { + const res = { + status: (code) => ({ json: (b) => reject(new Error(`unexpected ${code}: ${JSON.stringify(b)}`)) }), + }; + checkGracePeriod(req, res, resolve); + }); +} + +describe('checkGracePeriod', () => { + test('passes through when no user (unauth route slip)', async () => { + await runMiddleware({}); + expect(mockSupabaseUpdates).toHaveLength(0); + }); + + test('passes through when user has no grace_period_until', async () => { + await runMiddleware({ user: { id: 'u1', tier: 'analyst', grace_period_until: null } }); + expect(mockSupabaseUpdates).toHaveLength(0); + }); + + test('passes through when grace is still in the future', async () => { + const future = new Date(Date.now() + 12 * 3600 * 1000).toISOString(); + const req = { user: { id: 'u1', tier: 'analyst', grace_period_until: future } }; + await runMiddleware(req); + expect(mockSupabaseUpdates).toHaveLength(0); + expect(req.user.tier).toBe('analyst'); // unchanged + }); + + test('expired grace → downgrades both tables + rewrites req.user', async () => { + const past = new Date(Date.now() - 60_000).toISOString(); + const req = { user: { id: 'u1', tier: 'desk', grace_period_until: past } }; + await runMiddleware(req); + + expect(mockSupabaseUpdates).toHaveLength(2); + + const usersUpdate = mockSupabaseUpdates.find((u) => u.table === 'users'); + expect(usersUpdate.patch).toEqual({ tier: 'free', grace_period_until: null }); + expect(usersUpdate.filters).toEqual([['id', 'u1']]); + + const profileUpdate = mockSupabaseUpdates.find((u) => u.table === 'user_profiles'); + expect(profileUpdate.patch.tier).toBe('free'); + expect(profileUpdate.patch.subscription_status).toBe('expired'); + expect(profileUpdate.patch.grace_period_until).toBeNull(); + + // req.user reflects the downgrade so the downstream route sees it. + expect(req.user.tier).toBe('free'); + expect(req.user.grace_period_until).toBeNull(); + }); + + test('expired but already free → still scrubs the grace timestamp', async () => { + // Edge case — Stripe set grace, then user got downgraded by some + // other path. We should still null the timestamp so the row is clean. + const past = new Date(Date.now() - 60_000).toISOString(); + const req = { user: { id: 'u2', tier: 'free', grace_period_until: past } }; + await runMiddleware(req); + const usersUpdate = mockSupabaseUpdates.find((u) => u.table === 'users'); + expect(usersUpdate.patch.grace_period_until).toBeNull(); + expect(req.user.grace_period_until).toBeNull(); + }); + + test('malformed grace timestamp → treated as no grace (no downgrade)', async () => { + const req = { user: { id: 'u3', tier: 'analyst', grace_period_until: 'not-a-date' } }; + await runMiddleware(req); + expect(mockSupabaseUpdates).toHaveLength(0); + expect(req.user.tier).toBe('analyst'); + }); + + test('exactly-at-boundary timestamp is treated as expired', async () => { + // The middleware does `> Date.now()`; an exact match counts as + // expired. We test that policy stays explicit. + const req = { user: { id: 'u4', tier: 'analyst', grace_period_until: new Date(Date.now() - 1).toISOString() } }; + await runMiddleware(req); + expect(mockSupabaseUpdates.length).toBeGreaterThan(0); + expect(req.user.tier).toBe('free'); + }); +}); diff --git a/tests/unit/soccerFeatureExtractorCascade.test.js b/tests/unit/soccerFeatureExtractorCascade.test.js new file mode 100644 index 0000000..e4c9fee --- /dev/null +++ b/tests/unit/soccerFeatureExtractorCascade.test.js @@ -0,0 +1,122 @@ +// Source-cascade tests for soccerFeatureExtractor (Session 9). The +// pre-existing soccerFeatureExtractor.test.js covers the legacy +// football-data path; this suite verifies that: +// - api-football data wins when the prefetch alias exists +// - footapi wins when api-football is missing but footapi alias exists +// - football-data is still served when only the legacy key is set +// - The `meta.sources` map attributes correctly per lookup +// +// We mock cacheGet to inspect which key the cascade asked for. + +const mockCacheStore = new Map(); +jest.mock('../../src/utils/redis', () => ({ + cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null), + cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; }, + cacheDel: async (k) => { mockCacheStore.delete(k); return true; }, + isDegraded: () => false, +})); + +const { normalizeName } = require('../../src/utils/normalize'); +const extractor = require('../../src/services/intelligence/soccerFeatureExtractor'); + +beforeEach(() => { mockCacheStore.clear(); }); + +describe('soccerFeatureExtractor — source cascade (Session 9)', () => { + test('api-football wins when its alias is populated', async () => { + const n = normalizeName('Lionel Messi'); + // Only the apifootball alias is populated — others should not be consulted. + mockCacheStore.set(`apifootball:player_by_name:${n}`, { + team: 'Argentina', goals_per_90: 0.92, minutes_per_game: 88, + }); + const r = await extractor.extractSoccerFeatures({ + player: 'Lionel Messi', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.features.goals_per_90).toBe(0.92); + expect(r.meta.sources.player).toBe('api-football'); + }); + + test('footapi wins when api-football is empty but footapi is populated', async () => { + const n = normalizeName('Harry Kane'); + mockCacheStore.set(`footapi:player_by_name:${n}`, { + team: 'England', goals_per_90: 0.81, + }); + const r = await extractor.extractSoccerFeatures({ + player: 'Harry Kane', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.features.goals_per_90).toBe(0.81); + expect(r.meta.sources.player).toBe('footapi'); + }); + + test('football-data legacy key is the final fallback', async () => { + const n = normalizeName('Bukayo Saka'); + mockCacheStore.set(`soccer:player:${n}`, { + team: 'England', goals_per_90: 0.4, + }); + const r = await extractor.extractSoccerFeatures({ + player: 'Bukayo Saka', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.features.goals_per_90).toBe(0.4); + expect(r.meta.sources.player).toBe('football-data'); + }); + + test('all-miss case → null source + errors populated', async () => { + const r = await extractor.extractSoccerFeatures({ + player: 'Unknown', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.meta.sources.player).toBeNull(); + expect(r.meta.errors).toContain('player_not_found_in_cache'); + }); + + test('nextMatch cascade — api-football preferred', async () => { + const n = normalizeName('Vinicius Junior'); + mockCacheStore.set(`apifootball:player_by_name:${n}`, { team: 'Brazil' }); + mockCacheStore.set('apifootball:nextmatch:Brazil', { + opponent: 'Argentina', venue: 'MetLife Stadium', isHome: true, referee: 'A. Taylor', + }); + mockCacheStore.set('soccer:nextmatch:Brazil', { + opponent: 'STALE', venue: 'old', isHome: false, referee: 'STALE', + }); + const r = await extractor.extractSoccerFeatures({ + player: 'Vinicius Junior', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.meta.opponentAbbr).toBe('Argentina'); // api-football won + expect(r.meta.sources.nextMatch).toBe('api-football'); + }); + + test('referee cascade falls through to legacy key when richer sources empty', async () => { + const n = normalizeName('Anyone'); + mockCacheStore.set(`apifootball:player_by_name:${n}`, { team: 'X' }); + mockCacheStore.set('apifootball:nextmatch:X', { + opponent: 'Y', venue: 'V', isHome: true, referee: 'Bjorn', + }); + mockCacheStore.set('soccer:referee:Bjorn', { + cards_per_game: 4.2, penalties_per_game: 0.3, + }); + const r = await extractor.extractSoccerFeatures({ + player: 'Anyone', stat_type: 'cards', line: 0.5, direction: 'over', + }); + expect(r.features.referee_cards_per_game).toBe(4.2); + expect(r.meta.sources.referee).toBe('football-data'); + }); + + test('multiple sources active → independent attribution per lookup', async () => { + const n = normalizeName('Mixed'); + mockCacheStore.set(`apifootball:player_by_name:${n}`, { team: 'France', goals_per_90: 1.1 }); + // Match data only in legacy. + mockCacheStore.set('soccer:nextmatch:France', { + opponent: 'Italy', venue: 'AT&T Stadium', isHome: false, referee: 'X', + }); + // Referee only in footapi. + mockCacheStore.set('footapi:referee_by_name:X', { cards_per_game: 5.5 }); + + const r = await extractor.extractSoccerFeatures({ + player: 'Mixed', stat_type: 'goals', line: 0.5, direction: 'over', + }); + expect(r.meta.sources).toEqual({ + player: 'api-football', + nextMatch: 'football-data', + lastFixture: null, + referee: 'footapi', + }); + }); +}); diff --git a/tests/unit/tank01MlbAdapter.test.js b/tests/unit/tank01MlbAdapter.test.js new file mode 100644 index 0000000..57fb003 --- /dev/null +++ b/tests/unit/tank01MlbAdapter.test.js @@ -0,0 +1,146 @@ +const mockAxiosGet = jest.fn(); +jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) })); + +const mockCacheStore = new Map(); +const mockCacheTtls = new Map(); +jest.mock('../../src/utils/redis', () => ({ + cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null), + cacheSet: async (k, v, ttl) => { mockCacheStore.set(k, v); mockCacheTtls.set(k, ttl); return true; }, + cacheDel: async (k) => { mockCacheStore.delete(k); return true; }, + isDegraded: () => false, +})); + +const adapter = require('../../src/services/adapters/tank01MlbAdapter'); + +beforeEach(() => { + mockAxiosGet.mockReset(); + mockCacheStore.clear(); + mockCacheTtls.clear(); +}); + +describe('tank01MlbAdapter', () => { + describe('graceful degradation (no RAPID_API_KEY)', () => { + const original = process.env.RAPID_API_KEY; + beforeAll(() => { delete process.env.RAPID_API_KEY; }); + afterAll(() => { if (original !== undefined) process.env.RAPID_API_KEY = original; }); + + test('hasApiKey false', () => { expect(adapter.hasApiKey()).toBe(false); }); + + test('all endpoints return null without touching axios', async () => { + expect(await adapter.getMLBBoxScore('20260611_ATL_NYM')).toBeNull(); + expect(await adapter.getMLBBatterVsPitcher('B1', 'P1')).toBeNull(); + expect(await adapter.getMLBDailyScoreboard('20260611')).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + }); + + describe('with key configured', () => { + beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; }); + + test('RapidAPI host header is the MLB-specific Tank01 host', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } }); + await adapter.getMLBDailyScoreboard('20260611'); + const [url, opts] = mockAxiosGet.mock.calls[0]; + expect(url).toMatch(/^https:\/\/tank01-mlb-live-in-game-real-time-statistics\.p\.rapidapi\.com/); + expect(opts.headers['x-rapidapi-host']).toBe('tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com'); + }); + + test('TANK01_MLB_HOST override is honored', async () => { + const original = process.env.TANK01_MLB_HOST; + process.env.TANK01_MLB_HOST = 'alt-mlb.rapidapi.com'; + mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } }); + await adapter.getMLBDailyScoreboard('20260611'); + const [url] = mockAxiosGet.mock.calls[0]; + expect(url).toMatch(/^https:\/\/alt-mlb\.rapidapi\.com/); + if (original !== undefined) process.env.TANK01_MLB_HOST = original; + else delete process.env.TANK01_MLB_HOST; + }); + + test('getMLBBoxScore tags batters and pitchers with role', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + body: { + gameStatus: 'Final', + playerStats: { + batting: { 'B1': { longName: 'Ronald Acuña', teamAbv: 'ATL' } }, + pitching: { 'P1': { longName: 'Spencer Strider', teamAbv: 'ATL' } }, + }, + }, + }, + }); + const stats = await adapter.getMLBBoxScore('GAME-1'); + expect(stats.find((s) => s.role === 'batter').name).toBe('Ronald Acuña'); + expect(stats.find((s) => s.role === 'pitcher').name).toBe('Spencer Strider'); + // Final → 24h TTL. + expect(mockCacheTtls.get('tank01:mlb:boxscore:GAME-1')).toBe(adapter.__internals.TTL.boxScoreFinal); + }); + + test('In-progress game keeps 5-min TTL on the box score', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { body: { gameStatus: 'InProgress', playerStats: { batting: {}, pitching: {} } } }, + }); + await adapter.getMLBBoxScore('GAME-2'); + expect(mockCacheTtls.get('tank01:mlb:boxscore:GAME-2')).toBe(adapter.__internals.TTL.boxScoreLive); + }); + + test('getMLBBatterVsPitcher projects single-object payload', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + body: { batterID: 'B1', pitcherID: 'P1', PA: 18, AB: 16, H: 5, HR: 1, RBI: 3, SO: 4, AVG: '.313', OPS: '.857' }, + }, + }); + const bvp = await adapter.getMLBBatterVsPitcher('B1', 'P1'); + expect(bvp).toMatchObject({ + batterId: 'B1', pitcherId: 'P1', + plateAppearances: 18, atBats: 16, hits: 5, homeRuns: 1, rbi: 3, strikeouts: 4, + avg: '.313', ops: '.857', + }); + }); + + test('getMLBBatterVsPitcher handles array of matchups', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + body: [ + { batterID: 'B1', pitcherID: 'P1', PA: 12, H: 3, SO: 5 }, + { batterID: 'B1', pitcherID: 'P1', PA: 6, H: 2, SO: 1 }, + ], + }, + }); + const bvp = await adapter.getMLBBatterVsPitcher('B1', 'P1'); + expect(Array.isArray(bvp)).toBe(true); + expect(bvp).toHaveLength(2); + expect(bvp[0].plateAppearances).toBe(12); + }); + + test('null IDs return null without touching axios', async () => { + expect(await adapter.getMLBBoxScore(null)).toBeNull(); + expect(await adapter.getMLBBatterVsPitcher(null, 'P1')).toBeNull(); + expect(await adapter.getMLBBatterVsPitcher('B1', null)).toBeNull(); + expect(await adapter.getMLBDailyScoreboard(null)).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + + test('getMLBDailyScoreboard projects both array + map shapes', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { body: { 'GAME-X': { gameID: 'GAME-X', home: 'NYY', away: 'BOS', homePts: 5, awayPts: 4, gameStatus: 'Final' } } }, + }); + const games = await adapter.getMLBDailyScoreboard('20260611'); + expect(games).toHaveLength(1); + expect(games[0]).toMatchObject({ gameId: 'GAME-X', homeTeam: 'NYY', awayTeam: 'BOS', homeScore: 5, awayScore: 4 }); + }); + + test('cache hit on repeat call', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { body: { batterID: 'B', PA: 1 } } }); + await adapter.getMLBBatterVsPitcher('B', 'P'); + await adapter.getMLBBatterVsPitcher('B', 'P'); + expect(mockAxiosGet).toHaveBeenCalledTimes(1); + }); + + test('axios throw → stale fallback', async () => { + mockCacheStore.set('tank01:mlb:scoreboard:20260611:stale', { body: [{ gameID: 'STALE' }] }); + mockAxiosGet.mockRejectedValueOnce(new Error('upstream 500')); + const games = await adapter.getMLBDailyScoreboard('20260611'); + expect(games[0].gameId).toBe('STALE'); + }); + }); +}); diff --git a/tests/unit/tank01NbaAdapter.test.js b/tests/unit/tank01NbaAdapter.test.js new file mode 100644 index 0000000..41e72aa --- /dev/null +++ b/tests/unit/tank01NbaAdapter.test.js @@ -0,0 +1,143 @@ +const mockAxiosGet = jest.fn(); +jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) })); + +const mockCacheStore = new Map(); +const mockCacheTtls = new Map(); +jest.mock('../../src/utils/redis', () => ({ + cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null), + cacheSet: async (k, v, ttl) => { mockCacheStore.set(k, v); mockCacheTtls.set(k, ttl); return true; }, + cacheDel: async (k) => { mockCacheStore.delete(k); return true; }, + isDegraded: () => false, +})); + +const adapter = require('../../src/services/adapters/tank01NbaAdapter'); + +beforeEach(() => { + mockAxiosGet.mockReset(); + mockCacheStore.clear(); + mockCacheTtls.clear(); +}); + +describe('tank01NbaAdapter', () => { + describe('graceful degradation (no RAPID_API_KEY)', () => { + const original = process.env.RAPID_API_KEY; + beforeAll(() => { delete process.env.RAPID_API_KEY; }); + afterAll(() => { if (original !== undefined) process.env.RAPID_API_KEY = original; }); + + test('hasApiKey false', () => { + expect(adapter.hasApiKey()).toBe(false); + }); + + test('all endpoints return null without touching axios', async () => { + expect(await adapter.getNBABoxScore('20260611_LAL_BOS')).toBeNull(); + expect(await adapter.getNBAGamesForDate('20260611')).toBeNull(); + expect(await adapter.getNBABettingOdds('20260611')).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + }); + + describe('with key configured', () => { + beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; }); + + test('RapidAPI auth headers wired correctly', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } }); + await adapter.getNBAGamesForDate('20260611'); + const [url, opts] = mockAxiosGet.mock.calls[0]; + expect(url).toMatch(/^https:\/\/tank01-fantasy-stats\.p\.rapidapi\.com/); + expect(opts.headers['x-rapidapi-key']).toBe('test-rapid-key'); + expect(opts.headers['x-rapidapi-host']).toBe('tank01-fantasy-stats.p.rapidapi.com'); + }); + + test('TANK01_NBA_HOST override is honored', async () => { + const original = process.env.TANK01_NBA_HOST; + process.env.TANK01_NBA_HOST = 'alt-tank01.rapidapi.com'; + mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } }); + await adapter.getNBAGamesForDate('20260611'); + const [url, opts] = mockAxiosGet.mock.calls[0]; + expect(url).toMatch(/^https:\/\/alt-tank01\.rapidapi\.com/); + expect(opts.headers['x-rapidapi-host']).toBe('alt-tank01.rapidapi.com'); + if (original !== undefined) process.env.TANK01_NBA_HOST = original; + else delete process.env.TANK01_NBA_HOST; + }); + + test('getNBABoxScore projects playerStats map into a flat list', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + body: { + gameStatus: 'InProgress', + playerStats: { + 'NBA-123': { longName: 'Jaylen Brown', teamAbv: 'BOS', mins: '34', pts: 27, reb: 5, ast: 4 }, + 'NBA-456': { longName: 'Jayson Tatum', teamAbv: 'BOS', mins: '36', pts: 31, reb: 8, ast: 6, tptfgm: 5 }, + }, + }, + }, + }); + const stats = await adapter.getNBABoxScore('20260611_LAL_BOS'); + expect(stats).toHaveLength(2); + const tatum = stats.find((p) => p.name === 'Jayson Tatum'); + expect(tatum.team).toBe('BOS'); + expect(tatum.pts).toBe(31); + expect(tatum.threes).toBe(5); + expect(tatum._final).toBe(false); + }); + + test('Final game upgrades the cache TTL to 24h', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { body: { gameStatus: 'Final', playerStats: {} } }, + }); + await adapter.getNBABoxScore('GAME-1'); + const ttl = mockCacheTtls.get('tank01:nba:boxscore:GAME-1'); + expect(ttl).toBe(adapter.__internals.TTL.boxScoreFinal); + }); + + test('In-progress game stays on 5-min TTL', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { body: { gameStatus: 'InProgress', playerStats: {} } }, + }); + await adapter.getNBABoxScore('GAME-2'); + const ttl = mockCacheTtls.get('tank01:nba:boxscore:GAME-2'); + expect(ttl).toBe(adapter.__internals.TTL.boxScoreLive); + }); + + test('getNBAGamesForDate strips dashes from ISO dates', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } }); + await adapter.getNBAGamesForDate('2026-06-11'); + const [url] = mockAxiosGet.mock.calls[0]; + expect(url).toMatch(/gameDate=20260611/); + }); + + test('getNBAGamesForDate projects to stable shape', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { body: [ + { gameID: '20260611_BOS_LAL', home: 'LAL', away: 'BOS', gameTime: '7:30p ET', gameStatus: 'Final', homePts: 110, awayPts: 105 }, + ] }, + }); + const games = await adapter.getNBAGamesForDate('20260611'); + expect(games[0]).toMatchObject({ + gameId: '20260611_BOS_LAL', homeTeam: 'LAL', awayTeam: 'BOS', homeScore: 110, awayScore: 105, gameStatus: 'Final', + }); + }); + + test('null IDs return null without touching axios', async () => { + expect(await adapter.getNBABoxScore(null)).toBeNull(); + expect(await adapter.getNBAGamesForDate(null)).toBeNull(); + expect(await adapter.getNBABettingOdds(null)).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + + test('axios error → null + stale fallback', async () => { + mockCacheStore.set('tank01:nba:games:20260611:stale', { body: [{ gameID: 'STALE' }] }); + mockAxiosGet.mockRejectedValueOnce(new Error('upstream 503')); + const games = await adapter.getNBAGamesForDate('20260611'); + expect(games).toHaveLength(1); + expect(games[0].gameId).toBe('STALE'); + }); + + test('cache hit on repeat call (axios not re-invoked)', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } }); + await adapter.getNBAGamesForDate('20260611'); + await adapter.getNBAGamesForDate('20260611'); + expect(mockAxiosGet).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/web/public/sw.js b/web/public/sw.js index ade5825..52b2ad2 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -1,2 +1,2 @@ (()=>{"use strict";let e,t,a,s,r,n={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"serwist",runtime:"runtime",suffix:"u">typeof registration?registration.scope:""},i=e=>[n.prefix,e,n.suffix].filter(e=>e&&e.length>0).join("-"),c=e=>e||i(n.precache),o=e=>e||i(n.runtime);var l=class extends Error{details;constructor(e,t){super(((e,...t)=>{let a=e;return t.length>0&&(a+=` :: ${JSON.stringify(t)}`),a})(e,t)),this.name=e,this.details=t}};function h(e){return new Promise(t=>setTimeout(t,e))}let u=new Set;function d(e,t){let a=new URL(e);for(let e of t)a.searchParams.delete(e);return a.href}async function m(e,t,a,s){let r=d(t.url,a);if(t.url===r)return e.match(t,s);let n={...s,ignoreSearch:!0};for(let i of(await e.keys(t,n)))if(r===d(i.url,a))return e.match(i,s)}var f=class{promise;resolve;reject;constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}};let g=async()=>{for(let e of u)await e()},w="-precache-",p=async(e,t=w)=>{let a=(await self.caches.keys()).filter(a=>a.includes(t)&&a.includes(self.registration.scope)&&a!==e);return await Promise.all(a.map(e=>self.caches.delete(e))),a},y=(e,t)=>{let a=t();return e.waitUntil(a),a},_=(e,t)=>t.some(t=>e instanceof t),x=new WeakMap,b=new WeakMap,v=new WeakMap,E={get(e,t,a){if(e instanceof IDBTransaction){if("done"===t)return x.get(e);if("store"===t)return a.objectStoreNames[1]?void 0:a.objectStore(a.objectStoreNames[0])}return R(e[t])},set:(e,t,a)=>(e[t]=a,!0),has:(e,t)=>e instanceof IDBTransaction&&("done"===t||"store"===t)||t in e};function R(e){if(e instanceof IDBRequest){let t;return t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("success",r),e.removeEventListener("error",n)},r=()=>{t(R(e.result)),s()},n=()=>{a(e.error),s()};e.addEventListener("success",r),e.addEventListener("error",n)}),v.set(t,e),t}if(b.has(e))return b.get(e);let t=function(e){if("function"==typeof e)return(r||(r=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(e)?function(...t){return e.apply(q(this),t),R(this.request)}:function(...t){return R(e.apply(q(this),t))};return(e instanceof IDBTransaction&&function(e){if(x.has(e))return;let t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("complete",r),e.removeEventListener("error",n),e.removeEventListener("abort",n)},r=()=>{t(),s()},n=()=>{a(e.error||new DOMException("AbortError","AbortError")),s()};e.addEventListener("complete",r),e.addEventListener("error",n),e.addEventListener("abort",n)});x.set(e,t)}(e),_(e,s||(s=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])))?new Proxy(e,E):e}(e);return t!==e&&(b.set(e,t),v.set(t,e)),t}let q=e=>v.get(e);function S(e,t,{blocked:a,upgrade:s,blocking:r,terminated:n}={}){let i=indexedDB.open(e,t),c=R(i);return s&&i.addEventListener("upgradeneeded",e=>{s(R(i.result),e.oldVersion,e.newVersion,R(i.transaction),e)}),a&&i.addEventListener("blocked",e=>a(e.oldVersion,e.newVersion,e)),c.then(e=>{n&&e.addEventListener("close",()=>n()),r&&e.addEventListener("versionchange",e=>r(e.oldVersion,e.newVersion,e))}).catch(()=>{}),c}let D=["get","getKey","getAll","getAllKeys","count"],N=["put","add","delete","clear"],C=new Map;function T(e,t){if(!(e instanceof IDBDatabase&&!(t in e)&&"string"==typeof t))return;if(C.get(t))return C.get(t);let a=t.replace(/FromIndex$/,""),s=t!==a,r=N.includes(a);if(!(a in(s?IDBIndex:IDBObjectStore).prototype)||!(r||D.includes(a)))return;let n=async function(e,...t){let n=this.transaction(e,r?"readwrite":"readonly"),i=n.store;return s&&(i=i.index(t.shift())),(await Promise.all([i[a](...t),r&&n.done]))[0]};return C.set(t,n),n}E={...e=E,get:(t,a,s)=>T(t,a)||e.get(t,a,s),has:(t,a)=>!!T(t,a)||e.has(t,a)};let P=["continue","continuePrimaryKey","advance"],k={},A=new WeakMap,I=new WeakMap,U={get(e,t){if(!P.includes(t))return e[t];let a=k[t];return a||(a=k[t]=function(...e){A.set(this,I.get(this)[t](...e))}),a}};async function*L(...e){let t=this;if(t instanceof IDBCursor||(t=await t.openCursor(...e)),!t)return;let a=new Proxy(t,U);for(I.set(a,t),v.set(a,q(t));t;)yield a,t=await (A.get(a)||t.continue()),A.delete(a)}function F(e,t){return t===Symbol.asyncIterator&&_(e,[IDBIndex,IDBObjectStore,IDBCursor])||"iterate"===t&&_(e,[IDBIndex,IDBObjectStore])}E={...t=E,get:(e,a,s)=>F(e,a)?L:t.get(e,a,s),has:(e,a)=>F(e,a)||t.has(e,a)};let M=async(e,t)=>{let s=null;if(e.url&&(s=new URL(e.url).origin),s!==self.location.origin)throw new l("cross-origin-copy-response",{origin:s});let r=e.clone(),n={headers:new Headers(r.headers),status:r.status,statusText:r.statusText},i=t?t(n):n,c=!function(){if(void 0===a){let e=new Response("");if("body"in e)try{new Response(e.body),a=!0}catch{a=!1}a=!1}return a}()?await r.blob():r.body;return new Response(c,i)},O="requests",B="queueName";var K=class{_db=null;async addEntry(e){let t=(await this.getDb()).transaction(O,"readwrite",{durability:"relaxed"});await t.store.add(e),await t.done}async getFirstEntryId(){return(await (await this.getDb()).transaction(O).store.openCursor())?.value.id}async getAllEntriesByQueueName(e){return await (await this.getDb()).getAllFromIndex(O,B,IDBKeyRange.only(e))||[]}async getEntryCountByQueueName(e){return(await this.getDb()).countFromIndex(O,B,IDBKeyRange.only(e))}async deleteEntry(e){await (await this.getDb()).delete(O,e)}async getFirstEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"next")}async getLastEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"prev")}async getEndEntryFromIndex(e,t){return(await (await this.getDb()).transaction(O).store.index(B).openCursor(e,t))?.value}async getDb(){return this._db||(this._db=await S("serwist-background-sync",3,{upgrade:this._upgradeDb})),this._db}_upgradeDb(e,t){t>0&&t<3&&e.objectStoreNames.contains(O)&&e.deleteObjectStore(O),e.createObjectStore(O,{autoIncrement:!0,keyPath:"id"}).createIndex(B,B,{unique:!1})}},W=class{_queueName;_queueDb;constructor(e){this._queueName=e,this._queueDb=new K}async pushEntry(e){delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async unshiftEntry(e){let t=await this._queueDb.getFirstEntryId();t?e.id=t-1:delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async popEntry(){return this._removeEntry(await this._queueDb.getLastEntryByQueueName(this._queueName))}async shiftEntry(){return this._removeEntry(await this._queueDb.getFirstEntryByQueueName(this._queueName))}async getAll(){return await this._queueDb.getAllEntriesByQueueName(this._queueName)}async size(){return await this._queueDb.getEntryCountByQueueName(this._queueName)}async deleteEntry(e){await this._queueDb.deleteEntry(e)}async _removeEntry(e){return e&&await this.deleteEntry(e.id),e}};let j=["method","referrer","referrerPolicy","mode","credentials","cache","redirect","integrity","keepalive"];var $=class e{_requestData;static async fromRequest(t){let a={url:t.url,headers:{}};for(let e of("GET"!==t.method&&(a.body=await t.clone().arrayBuffer()),t.headers.forEach((e,t)=>{a.headers[t]=e}),j))void 0!==t[e]&&(a[e]=t[e]);return new e(a)}constructor(e){"navigate"===e.mode&&(e.mode="same-origin"),this._requestData=e}toObject(){let e=Object.assign({},this._requestData);return e.headers=Object.assign({},this._requestData.headers),e.body&&(e.body=e.body.slice(0)),e}toRequest(){return new Request(this._requestData.url,this._requestData)}clone(){return new e(this.toObject())}};let H="serwist-background-sync",V=new Set,G=e=>{let t={request:new $(e.requestData).toRequest(),timestamp:e.timestamp};return e.metadata&&(t.metadata=e.metadata),t};var Q=class{_name;_onSync;_maxRetentionTime;_queueStore;_forceSyncFallback;_syncInProgress=!1;_requestsAddedDuringSync=!1;constructor(e,{forceSyncFallback:t,onSync:a,maxRetentionTime:s}={}){if(V.has(e))throw new l("duplicate-queue-name",{name:e});V.add(e),this._name=e,this._onSync=a||this.replayRequests,this._maxRetentionTime=s||10080,this._forceSyncFallback=!!t,this._queueStore=new W(this._name),this._addSyncListener()}get name(){return this._name}async pushRequest(e){await this._addRequest(e,"push")}async unshiftRequest(e){await this._addRequest(e,"unshift")}async popRequest(){return this._removeRequest("pop")}async shiftRequest(){return this._removeRequest("shift")}async getAll(){let e=await this._queueStore.getAll(),t=Date.now(),a=[];for(let s of e){let e=60*this._maxRetentionTime*1e3;t-s.timestamp>e?await this._queueStore.deleteEntry(s.id):a.push(G(s))}return a}async size(){return await this._queueStore.size()}async _addRequest({request:e,metadata:t,timestamp:a=Date.now()},s){let r={requestData:(await $.fromRequest(e.clone())).toObject(),timestamp:a};switch(t&&(r.metadata=t),s){case"push":await this._queueStore.pushEntry(r);break;case"unshift":await this._queueStore.unshiftEntry(r)}this._syncInProgress?this._requestsAddedDuringSync=!0:await this.registerSync()}async _removeRequest(e){let t,a=Date.now();switch(e){case"pop":t=await this._queueStore.popEntry();break;case"shift":t=await this._queueStore.shiftEntry()}if(t){let s=60*this._maxRetentionTime*1e3;return a-t.timestamp>s?this._removeRequest(e):G(t)}}async replayRequests(){let e;for(;e=await this.shiftRequest();)try{await fetch(e.request.clone())}catch{throw await this.unshiftRequest(e),new l("queue-replay-failed",{name:this._name})}}async registerSync(){if("sync"in self.registration&&!this._forceSyncFallback)try{await self.registration.sync.register(`${H}:${this._name}`)}catch(e){}}_addSyncListener(){"sync"in self.registration&&!this._forceSyncFallback?self.addEventListener("sync",e=>{if(e.tag===`${H}:${this._name}`){let t=async()=>{let t;this._syncInProgress=!0;try{await this._onSync({queue:this})}catch(e){if(e instanceof Error)throw e}finally{this._requestsAddedDuringSync&&!(t&&!e.lastChance)&&await this.registerSync(),this._syncInProgress=!1,this._requestsAddedDuringSync=!1}};e.waitUntil(t())}}):this._onSync({queue:this})}static get _queueNames(){return V}},z=class{_queue;constructor(e,t){this._queue=new Q(e,t)}async fetchDidFail({request:e}){await this._queue.pushRequest({request:e})}};let Y={cacheWillUpdate:async({response:e})=>200===e.status||0===e.status?e:null};function J(e){return"string"==typeof e?new Request(e):e}var X=class{event;request;url;params;_cacheKeys={};_strategy;_handlerDeferred;_extendLifetimePromises;_plugins;_pluginStateMap;constructor(e,t){for(const a of(this.event=t.event,this.request=t.request,t.url&&(this.url=t.url,this.params=t.params),this._strategy=e,this._handlerDeferred=new f,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map,this._plugins))this._pluginStateMap.set(a,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){let{event:t}=this,a=J(e),s=await this.getPreloadResponse();if(s)return s;let r=this.hasCallback("fetchDidFail")?a.clone():null;try{for(let e of this.iterateCallbacks("requestWillFetch"))a=await e({request:a.clone(),event:t})}catch(e){if(e instanceof Error)throw new l("plugin-error-request-will-fetch",{thrownErrorMessage:e.message})}let n=a.clone();try{let e;for(let s of(e=await fetch(a,"navigate"===a.mode?void 0:this._strategy.fetchOptions),this.iterateCallbacks("fetchDidSucceed")))e=await s({event:t,request:n,response:e});return e}catch(e){throw r&&await this.runCallbacks("fetchDidFail",{error:e,event:t,originalRequest:r.clone(),request:n.clone()}),e}}async fetchAndCachePut(e){let t=await this.fetch(e),a=t.clone();return this.waitUntil(this.cachePut(e,a)),t}async cacheMatch(e){let t,a=J(e),{cacheName:s,matchOptions:r}=this._strategy,n=await this.getCacheKey(a,"read"),i={...r,cacheName:s};for(let e of(t=await caches.match(n,i),this.iterateCallbacks("cachedResponseWillBeUsed")))t=await e({cacheName:s,matchOptions:r,cachedResponse:t,request:n,event:this.event})||void 0;return t}async cachePut(e,t){let a=J(e);await h(0);let s=await this.getCacheKey(a,"write");if(!t)throw new l("cache-put-with-no-response",{url:new URL(String(s.url),location.href).href.replace(RegExp(`^${location.origin}`),"")});let r=await this._ensureResponseSafeToCache(t);if(!r)return!1;let{cacheName:n,matchOptions:i}=this._strategy,c=await self.caches.open(n),o=this.hasCallback("cacheDidUpdate"),u=o?await m(c,s.clone(),["__WB_REVISION__"],i):null;try{await c.put(s,o?r.clone():r)}catch(e){if(e instanceof Error)throw"QuotaExceededError"===e.name&&await g(),e}for(let e of this.iterateCallbacks("cacheDidUpdate"))await e({cacheName:n,oldResponse:u,newResponse:r.clone(),request:s,event:this.event});return!0}async getCacheKey(e,t){let a=`${e.url} | ${t}`;if(!this._cacheKeys[a]){let s=e;for(let e of this.iterateCallbacks("cacheKeyWillBeUsed"))s=J(await e({mode:t,request:s,event:this.event,params:this.params}));this._cacheKeys[a]=s}return this._cacheKeys[a]}hasCallback(e){for(let t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(let a of this.iterateCallbacks(e))await a(t)}*iterateCallbacks(e){for(let t of this._strategy.plugins)if("function"==typeof t[e]){let a=this._pluginStateMap.get(t),s=s=>{let r={...s,state:a};return t[e](r)};yield s}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){let e;for(;e=this._extendLifetimePromises.shift();)await e}destroy(){this._handlerDeferred.resolve(null)}async getPreloadResponse(){if(this.event instanceof FetchEvent&&"navigate"===this.event.request.mode&&"preloadResponse"in this.event)try{let e=await this.event.preloadResponse;if(e)return e}catch(e){return}}async _ensureResponseSafeToCache(e){let t=e,a=!1;for(let e of this.iterateCallbacks("cacheWillUpdate"))if(t=await e({request:this.request,response:t,event:this.event})||void 0,a=!0,!t)break;return!a&&t&&200!==t.status&&(t=void 0),t}},Z=class{cacheName;plugins;fetchOptions;matchOptions;constructor(e={}){this.cacheName=o(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){let[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});let t=e.event,a="string"==typeof e.request?new Request(e.request):e.request,s=new X(this,e.url?{event:t,request:a,url:e.url,params:e.params}:{event:t,request:a}),r=this._getResponse(s,a,t);return[r,this._awaitComplete(r,s,a,t)]}async _getResponse(e,t,a){let s;await e.runCallbacks("handlerWillStart",{event:a,request:t});try{if(s=await this._handle(t,e),void 0===s||"error"===s.type)throw new l("no-response",{url:t.url})}catch(r){if(r instanceof Error){for(let n of e.iterateCallbacks("handlerDidError"))if(void 0!==(s=await n({error:r,event:a,request:t})))break}if(!s)throw r}for(let r of e.iterateCallbacks("handlerWillRespond"))s=await r({event:a,request:t,response:s});return s}async _awaitComplete(e,t,a,s){let r,n;try{r=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:s,request:a,response:r}),await t.doneWaiting()}catch(e){e instanceof Error&&(n=e)}if(await t.runCallbacks("handlerDidComplete",{event:s,request:a,response:r,error:n}),t.destroy(),n)throw n}},ee=class extends Z{_networkTimeoutSeconds;constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(Y),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,s=[],r=[];if(this._networkTimeoutSeconds){let{id:n,promise:i}=this._getTimeoutPromise({request:e,logs:s,handler:t});a=n,r.push(i)}let n=this._getNetworkPromise({timeoutId:a,request:e,logs:s,handler:t});r.push(n);let i=await t.waitUntil((async()=>await t.waitUntil(Promise.race(r))||await n)());if(!i)throw new l("no-response",{url:e.url});return i}_getTimeoutPromise({request:e,logs:t,handler:a}){let s;return{promise:new Promise(t=>{s=setTimeout(async()=>{t(await a.cacheMatch(e))},1e3*this._networkTimeoutSeconds)}),id:s}}async _getNetworkPromise({timeoutId:e,request:t,logs:a,handler:s}){let r,n;try{n=await s.fetchAndCachePut(t)}catch(e){e instanceof Error&&(r=e)}return e&&clearTimeout(e),(r||!n)&&(n=await s.cacheMatch(t)),n}},et=class extends Z{_networkTimeoutSeconds;constructor(e={}){super(e),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,s;try{let a=[t.fetch(e)];if(this._networkTimeoutSeconds){let e=h(1e3*this._networkTimeoutSeconds);a.push(e)}if(!(s=await Promise.race(a)))throw Error(`Timed out the network response after ${this._networkTimeoutSeconds} seconds.`)}catch(e){e instanceof Error&&(a=e)}if(!s)throw new l("no-response",{url:e.url,error:a});return s}};let ea=e=>e&&"object"==typeof e?e:{handle:e};var es=class{handler;match;method;catchHandler;constructor(e,t,a="GET"){this.handler=ea(t),this.match=e,this.method=a}setCatchHandler(e){this.catchHandler=ea(e)}},er=class e extends Z{_fallbackToNetwork;static defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:e})=>!e||e.status>=400?null:e};static copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:e})=>e.redirected?await M(e):e};constructor(t={}){t.cacheName=c(t.cacheName),super(t),this._fallbackToNetwork=!1!==t.fallbackToNetwork,this.plugins.push(e.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){let a=await t.getPreloadResponse();if(a)return a;let s=await t.cacheMatch(e);return s||(t.event&&"install"===t.event.type?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let a,s=t.params||{};if(this._fallbackToNetwork){let r=s.integrity,n=e.integrity,i=!n||n===r;a=await t.fetch(new Request(e,{integrity:"no-cors"!==e.mode?n||r:void 0})),r&&i&&"no-cors"!==e.mode&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,a.clone()))}else throw new l("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return a}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();let a=await t.fetch(e);if(!await t.cachePut(e,a.clone()))throw new l("bad-precaching-response",{url:e.url,status:a.status});return a}_useDefaultCacheabilityPluginIfNeeded(){let t=null,a=0;for(let[s,r]of this.plugins.entries())r!==e.copyRedirectedCacheableResponsesPlugin&&(r===e.defaultPrecacheCacheabilityPlugin&&(t=s),r.cacheWillUpdate&&a++);0===a?this.plugins.push(e.defaultPrecacheCacheabilityPlugin):a>1&&null!==t&&this.plugins.splice(t,1)}},en=class extends es{_allowlist;_denylist;constructor(e,{allowlist:t=[/./],denylist:a=[]}={}){super(e=>this._match(e),e),this._allowlist=t,this._denylist=a}_match({url:e,request:t}){if(t&&"navigate"!==t.mode)return!1;let a=e.pathname+e.search;for(let e of this._denylist)if(e.test(a))return!1;return!!this._allowlist.some(e=>e.test(a))}},ei=class extends es{constructor(e,t,a){super(({url:t})=>{let a=e.exec(t.href);if(a)return t.origin!==location.origin&&0!==a.index?void 0:a.slice(1)},t,a)}};let ec=e=>{if(!e)throw new l("add-to-cache-list-unexpected-type",{entry:e});if("string"==typeof e){let t=new URL(e,location.href);return{cacheKey:t.href,url:t.href}}let{revision:t,url:a}=e;if(!a)throw new l("add-to-cache-list-unexpected-type",{entry:e});if(!t){let e=new URL(a,location.href);return{cacheKey:e.href,url:e.href}}let s=new URL(a,location.href),r=new URL(a,location.href);return s.searchParams.set("__WB_REVISION__",t),{cacheKey:s.href,url:r.href}};var eo=class{updatedURLs=[];notUpdatedURLs=[];handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)};cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:a})=>{if("install"===e.type&&t?.originalRequest&&t.originalRequest instanceof Request){let e=t.originalRequest.url;a?this.notUpdatedURLs.push(e):this.updatedURLs.push(e)}return a}};let el=async(e,t,a)=>{let s=t.map((e,t)=>({index:t,item:e})),r=async e=>{let t=[];for(;;){let r=s.pop();if(!r)return e(t);let n=await a(r.item);t.push({result:n,index:r.index})}},n=Array.from({length:e},()=>new Promise(r));return(await Promise.all(n)).flat().sort((e,t)=>e.indexe.result)};"u">typeof navigator&&/^((?!chrome|android).)*safari/i.test(navigator.userAgent);let eh="cache-entries",eu=e=>{let t=new URL(e,location.href);return t.hash="",t.href};var ed=class{_cacheName;_db=null;constructor(e){this._cacheName=e}_getId(e){return`${this._cacheName}|${eu(e)}`}_upgradeDb(e){let t=e.createObjectStore(eh,{keyPath:"id"});t.createIndex("cacheName","cacheName",{unique:!1}),t.createIndex("timestamp","timestamp",{unique:!1})}_upgradeDbAndDeleteOldDbs(e){this._upgradeDb(e),this._cacheName&&function(e,{blocked:t}={}){let a=indexedDB.deleteDatabase(e);t&&a.addEventListener("blocked",e=>t(e.oldVersion,e)),R(a).then(()=>void 0)}(this._cacheName)}async setTimestamp(e,t){e=eu(e);let a={id:this._getId(e),cacheName:this._cacheName,url:e,timestamp:t},s=(await this.getDb()).transaction(eh,"readwrite",{durability:"relaxed"});await s.store.put(a),await s.done}async getTimestamp(e){return(await (await this.getDb()).get(eh,this._getId(e)))?.timestamp}async expireEntries(e,t){let a=await (await this.getDb()).transaction(eh,"readwrite").store.index("timestamp").openCursor(null,"prev"),s=[],r=0;for(;a;){let n=a.value;n.cacheName===this._cacheName&&(e&&n.timestamp=t?(a.delete(),s.push(n.url)):r++),a=await a.continue()}return s}async getDb(){return this._db||(this._db=await S("serwist-expiration",1,{upgrade:this._upgradeDbAndDeleteOldDbs.bind(this)})),this._db}},em=class{_isRunning=!1;_rerunRequested=!1;_maxEntries;_maxAgeSeconds;_matchOptions;_cacheName;_timestampModel;constructor(e,t={}){this._maxEntries=t.maxEntries,this._maxAgeSeconds=t.maxAgeSeconds,this._matchOptions=t.matchOptions,this._cacheName=e,this._timestampModel=new ed(e)}async expireEntries(){if(this._isRunning){this._rerunRequested=!0;return}this._isRunning=!0;let e=this._maxAgeSeconds?Date.now()-1e3*this._maxAgeSeconds:0,t=await this._timestampModel.expireEntries(e,this._maxEntries),a=await self.caches.open(this._cacheName);for(let e of t)await a.delete(e,this._matchOptions);this._isRunning=!1,this._rerunRequested&&(this._rerunRequested=!1,this.expireEntries())}async updateTimestamp(e){await this._timestampModel.setTimestamp(e,Date.now())}async isURLExpired(e){if(!this._maxAgeSeconds)return!1;let t=await this._timestampModel.getTimestamp(e),a=Date.now()-1e3*this._maxAgeSeconds;return void 0===t||t{u.add(e)})(()=>this.deleteCacheAndMetadata())}_getCacheExpiration(e){if(e===o())throw new l("expire-custom-caches-only");let t=this._cacheExpirations.get(e);return t||(t=new em(e,this._config),this._cacheExpirations.set(e,t)),t}cachedResponseWillBeUsed({event:e,cacheName:t,request:a,cachedResponse:s}){if(!s)return null;let r=this._isResponseDateFresh(s),n=this._getCacheExpiration(t),i="last-used"===this._config.maxAgeFrom,c=(async()=>{i&&await n.updateTimestamp(a.url),await n.expireEntries()})();try{e.waitUntil(c)}catch{}return r?s:null}_isResponseDateFresh(e){if("last-used"===this._config.maxAgeFrom)return!0;let t=Date.now();if(!this._config.maxAgeSeconds)return!0;let a=this._getDateHeaderTimestamp(e);return null===a||a>=t-1e3*this._config.maxAgeSeconds}_getDateHeaderTimestamp(e){if(!e.headers.has("date"))return null;let t=new Date(e.headers.get("date")).getTime();return Number.isNaN(t)?null:t}async cacheDidUpdate({cacheName:e,request:t}){let a=this._getCacheExpiration(e);await a.updateTimestamp(t.url),await a.expireEntries()}async deleteCacheAndMetadata(){for(let[e,t]of this._cacheExpirations)await self.caches.delete(e),await t.delete();this._cacheExpirations=new Map}};let eg=/^\/(\w+\/)?collect/,ew=({serwist:e,cacheName:t,...a})=>{let s,r,c=t||i(n.googleAnalytics),o=new z("serwist-google-analytics",{maxRetentionTime:2880,onSync:async({queue:e})=>{let t;for(;t=await e.shiftRequest();){let{request:s,timestamp:r}=t,n=new URL(s.url);try{let e="POST"===s.method?new URLSearchParams(await s.clone().text()):n.searchParams,t=r-(Number(e.get("qt"))||0),i=Date.now()-t;if(e.set("qt",String(i)),a.parameterOverrides)for(let t of Object.keys(a.parameterOverrides)){let s=a.parameterOverrides[t];e.set(t,s)}"function"==typeof a.hitFilter&&a.hitFilter.call(null,e),await fetch(new Request(n.origin+n.pathname,{body:e.toString(),method:"POST",mode:"cors",credentials:"omit",headers:{"Content-Type":"text/plain"}}))}catch(a){throw await e.unshiftRequest(t),a}}}});for(let t of[new es(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtm.js"===e.pathname,new ee({cacheName:c}),"GET"),new es(({url:e})=>"www.google-analytics.com"===e.hostname&&"/analytics.js"===e.pathname,new ee({cacheName:c}),"GET"),new es(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtag/js"===e.pathname,new ee({cacheName:c}),"GET"),new es(s=({url:e})=>"www.google-analytics.com"===e.hostname&&eg.test(e.pathname),r=new et({plugins:[o]}),"GET"),new es(s,r,"POST")])e.registerRoute(t)};var ep=class{_fallbackUrls;_serwist;constructor({fallbackUrls:e,serwist:t}){this._fallbackUrls=e,this._serwist=t}async handlerDidError(e){for(let t of this._fallbackUrls)if("string"==typeof t){let e=await this._serwist.matchPrecache(t);if(void 0!==e)return e}else if(t.matcher(e)){let e=await this._serwist.matchPrecache(t.url);if(void 0!==e)return e}}};let ey=async(e,t)=>{try{if(206===t.status)return t;let a=e.headers.get("range");if(!a)throw new l("no-range-header");let s=(e=>{let t=e.trim().toLowerCase();if(!t.startsWith("bytes="))throw new l("unit-must-be-bytes",{normalizedRangeHeader:t});if(t.includes(","))throw new l("single-range-only",{normalizedRangeHeader:t});let a=/(\d*)-(\d*)/.exec(t);if(!a||!(a[1]||a[2]))throw new l("invalid-range-values",{normalizedRangeHeader:t});return{start:""===a[1]?void 0:Number(a[1]),end:""===a[2]?void 0:Number(a[2])}})(a),r=await t.blob(),n=((e,t,a)=>{let s,r,n=e.size;if(a&&a>n||t&&t<0)throw new l("range-not-satisfiable",{size:n,end:a,start:t});return void 0!==t&&void 0!==a?(s=t,r=a+1):void 0!==t&&void 0===a?(s=t,r=n):void 0!==a&&void 0===t&&(s=n-a,r=n),{start:s,end:r}})(r,s.start,s.end),i=r.slice(n.start,n.end),c=i.size,o=new Response(i,{status:206,statusText:"Partial Content",headers:t.headers});return o.headers.set("Content-Length",String(c)),o.headers.set("Content-Range",`bytes ${n.start}-${n.end-1}/${r.size}`),o}catch(e){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}};var e_=class{cachedResponseWillBeUsed=async({request:e,cachedResponse:t})=>t&&e.headers.has("range")?await ey(e,t):t},ex=class extends Z{async _handle(e,t){let a,s=await t.cacheMatch(e);if(s);else try{s=await t.fetchAndCachePut(e)}catch(e){e instanceof Error&&(a=e)}if(!s)throw new l("no-response",{url:e.url,error:a});return s}},eb=class extends Z{constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(Y)}async _handle(e,t){let a,s=t.fetchAndCachePut(e).catch(()=>{});t.waitUntil(s);let r=await t.cacheMatch(e);if(r);else try{r=await s}catch(e){e instanceof Error&&(a=e)}if(!r)throw new l("no-response",{url:e.url,error:a});return r}},ev=class extends es{constructor(e,t){super(({request:a})=>{let s=e.getUrlsToPrecacheKeys();for(let r of function*(e,{directoryIndex:t="index.html",ignoreURLParametersMatching:a=[/^utm_/,/^fbclid$/],cleanURLs:s=!0,urlManipulation:r}={}){let n=new URL(e,location.href);n.hash="",yield n.href;let i=((e,t=[])=>{for(let a of[...e.searchParams.keys()])t.some(e=>e.test(a))&&e.searchParams.delete(a);return e})(n,a);if(yield i.href,t&&i.pathname.endsWith("/")){let e=new URL(i.href);e.pathname+=t,yield e.href}if(s){let e=new URL(i.href);e.pathname+=".html",yield e.href}if(r)for(let e of r({url:n}))yield e.href}(a.url,t)){let t=s.get(r);if(t)return{cacheKey:t,integrity:e.getIntegrityForPrecacheKey(t)}}},e.precacheStrategy)}},eE=class{_precacheController;constructor({precacheController:e}){this._precacheController=e}cacheKeyWillBeUsed=async({request:e,params:t})=>{let a=t?.cacheKey||this._precacheController.getPrecacheKeyForUrl(e.url);return a?new Request(a,{headers:e.headers}):e}},eR=class{_urlsToCacheKeys=new Map;_urlsToCacheModes=new Map;_cacheKeysToIntegrities=new Map;_concurrentPrecaching;_precacheStrategy;_routes;_defaultHandlerMap;_catchHandler;_requestRules;constructor({precacheEntries:e,precacheOptions:t,skipWaiting:a=!1,importScripts:s,navigationPreload:r=!1,cacheId:i,clientsClaim:o=!1,runtimeCaching:l,offlineAnalyticsConfig:h,disableDevLogs:u=!1,fallbacks:d,requestRules:m}={}){const{precacheStrategyOptions:f,precacheRouteOptions:g,precacheMiscOptions:w}=((e,t={})=>{let{cacheName:a,plugins:s=[],fetchOptions:r,matchOptions:n,fallbackToNetwork:i,directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u,cleanupOutdatedCaches:d,concurrency:m=10,navigateFallback:f,navigateFallbackAllowlist:g,navigateFallbackDenylist:w}=t??{};return{precacheStrategyOptions:{cacheName:c(a),plugins:[...s,new eE({precacheController:e})],fetchOptions:r,matchOptions:n,fallbackToNetwork:i},precacheRouteOptions:{directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u},precacheMiscOptions:{cleanupOutdatedCaches:d,concurrency:m,navigateFallback:f,navigateFallbackAllowlist:g,navigateFallbackDenylist:w}}})(this,t);if(this._concurrentPrecaching=w.concurrency,this._precacheStrategy=new er(f),this._routes=new Map,this._defaultHandlerMap=new Map,this._requestRules=m,this.handleInstall=this.handleInstall.bind(this),this.handleActivate=this.handleActivate.bind(this),this.handleFetch=this.handleFetch.bind(this),this.handleCache=this.handleCache.bind(this),s&&s.length>0&&self.importScripts(...s),r&&self.registration?.navigationPreload&&self.addEventListener("activate",e=>{e.waitUntil(self.registration.navigationPreload.enable().then(()=>{}))}),void 0!==i&&(e=>{var t=e;for(let e of Object.keys(n))(e=>{let a=t[e];"string"==typeof a&&(n[e]=a)})(e)})({prefix:i}),a?self.skipWaiting():self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),o&&self.addEventListener("activate",()=>self.clients.claim()),e&&e.length>0&&this.addToPrecacheList(e),w.cleanupOutdatedCaches&&(e=>{self.addEventListener("activate",t=>{t.waitUntil(p(c(e)).then(e=>{}))})})(f.cacheName),this.registerRoute(new ev(this,g)),w.navigateFallback&&this.registerRoute(new en(this.createHandlerBoundToUrl(w.navigateFallback),{allowlist:w.navigateFallbackAllowlist,denylist:w.navigateFallbackDenylist})),void 0!==h&&("boolean"==typeof h?h&&ew({serwist:this}):ew({...h,serwist:this})),void 0!==l){if(void 0!==d){const e=new ep({fallbackUrls:d.entries,serwist:this});l.forEach(t=>{t.handler instanceof Z&&!t.handler.plugins.some(e=>"handlerDidError"in e)&&t.handler.plugins.push(e)})}for(const e of l)this.registerCapture(e.matcher,e.handler,e.method)}u&&(self.__WB_DISABLE_DEV_LOGS=!0)}get precacheStrategy(){return this._precacheStrategy}get routes(){return this._routes}addEventListeners(){self.addEventListener("install",this.handleInstall),self.addEventListener("activate",this.handleActivate),self.addEventListener("fetch",this.handleFetch),self.addEventListener("message",this.handleCache)}addToPrecacheList(e){let t=[];for(let a of e){"string"==typeof a?t.push(a):a&&!a.integrity&&void 0===a.revision&&t.push(a.url);let{cacheKey:e,url:s}=ec(a),r="string"!=typeof a&&a.revision?"reload":"default";if(this._urlsToCacheKeys.has(s)&&this._urlsToCacheKeys.get(s)!==e)throw new l("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(s),secondEntry:e});if("string"!=typeof a&&a.integrity){if(this._cacheKeysToIntegrities.has(e)&&this._cacheKeysToIntegrities.get(e)!==a.integrity)throw new l("add-to-cache-list-conflicting-integrities",{url:s});this._cacheKeysToIntegrities.set(e,a.integrity)}this._urlsToCacheKeys.set(s,e),this._urlsToCacheModes.set(s,r)}t.length>0&&console.warn(`Serwist is precaching URLs without revision info: ${t.join(", ")} -This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),y(e,async()=>{let t=new eo;this.precacheStrategy.plugins.push(t),await el(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let s=this._cacheKeysToIntegrities.get(a),r=this._urlsToCacheModes.get(t),n=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:n,url:new URL(n.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:s}=t;return{updatedURLs:a,notUpdatedURLs:s}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return y(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),s=[];for(let r of t)a.has(r.url)||(await e.delete(r),s.push(r.url));return{deletedCacheRequests:s}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,ea(e))}setCatchHandler(e){this._catchHandler=ea(e)}registerCapture(e,t,a){let s=((e,t,a)=>{if("string"==typeof e){let s=new URL(e,location.href);return new es(({url:e})=>e.href===s.href,t,a)}if(e instanceof RegExp)return new ei(e,t,a);if("function"==typeof e)return new es(e,t,a);if(e instanceof es)return e;throw new l("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})})(e,t,a);return this.registerRoute(s),s}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new l("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;let r=s.origin===location.origin,{params:n,route:i}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=i?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:s,request:e,event:t,params:n})}catch(e){a=Promise.reject(e)}let l=i?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:s,request:e,event:t,params:n})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:s}){for(let r of this._routes.get(a.method)||[]){let n,i=r.match({url:e,sameOrigin:t,request:a,event:s});if(i)return Array.isArray(n=i)&&0===n.length||i.constructor===Object&&0===Object.keys(i).length?n=void 0:"boolean"==typeof i&&(n=void 0),{route:r,params:n}}return{}}};let eq=[{matcher:/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,handler:new ex({cacheName:"google-fonts-webfonts",plugins:[new ef({maxEntries:4,maxAgeSeconds:31536e3,maxAgeFrom:"last-used"})]})},{matcher:/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,handler:new eb({cacheName:"google-fonts-stylesheets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,handler:new eb({cacheName:"static-font-assets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,handler:new eb({cacheName:"static-image-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:2592e3,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/static.+\.js$/i,handler:new ex({cacheName:"next-static-js-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/image\?url=.+$/i,handler:new eb({cacheName:"next-image",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:mp3|wav|ogg)$/i,handler:new ex({cacheName:"static-audio-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:mp4|webm)$/i,handler:new ex({cacheName:"static-video-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:js)$/i,handler:new eb({cacheName:"static-js-assets",plugins:[new ef({maxEntries:48,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:css|less)$/i,handler:new eb({cacheName:"static-style-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/data\/.+\/.+\.json$/i,handler:new ee({cacheName:"next-data",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:json|xml|csv)$/i,handler:new ee({cacheName:"static-data-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/api\/auth\/.*/,handler:new et({networkTimeoutSeconds:10})},{matcher:({sameOrigin:e,url:{pathname:t}})=>e&&t.startsWith("/api/"),method:"GET",handler:new ee({cacheName:"apis",plugins:[new ef({maxEntries:16,maxAgeSeconds:86400,maxAgeFrom:"last-used"})],networkTimeoutSeconds:10})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc-prefetch",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>e.headers.get("Content-Type")?.includes("text/html")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({url:{pathname:e},sameOrigin:t})=>t&&!e.startsWith("/api/"),handler:new ee({cacheName:"others",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({sameOrigin:e})=>!e,handler:new ee({cacheName:"cross-origin",plugins:[new ef({maxEntries:32,maxAgeSeconds:3600})],networkTimeoutSeconds:10})},{matcher:/.*/i,method:"GET",handler:new et}];new eR({precacheEntries:[{'revision':'5df28436444beef2d99fc7f9b548f465','url':'/_next/static/H_uIQ1fOevaOJbnuN1cyz/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/H_uIQ1fOevaOJbnuN1cyz/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/1958-a1cd681eb2c1a83e.js'},{'revision':null,'url':'/_next/static/chunks/4bd1b696-c2f6e0877b6c10aa.js'},{'revision':null,'url':'/_next/static/chunks/5838-7ac6ecc648315323.js'},{'revision':null,'url':'/_next/static/chunks/6810-8b074082a0b51859.js'},{'revision':null,'url':'/_next/static/chunks/7938-3aca95fbb5e36779.js'},{'revision':null,'url':'/_next/static/chunks/8500-3953cc33eeceb44e.js'},{'revision':null,'url':'/_next/static/chunks/9da6db1e-54d62c4d2564a0ac.js'},{'revision':null,'url':'/_next/static/chunks/app/_global-error/page-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/checkout/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/props/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/tonight/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/intelligence/feed/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/accuracy/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/soccer/%5Bleague%5D/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/add-leg/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/grade/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/players/search/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/live/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/most-parlayed/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/top-graded/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/scan/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/parlays-graded/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/public/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/profile/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/recent-scans/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/scans/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/waitlist/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/webhook/nexapay/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/callback/page-f6253334ccab6a26.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/%5Bslug%5D/page-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/page-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/dashboard/page-107967f64e4a489c.js'},{'revision':null,'url':'/_next/static/chunks/app/forgot-password/page-012a02e7c1c1bf4c.js'},{'revision':null,'url':'/_next/static/chunks/app/game/%5Bid%5D/page-2c1eb07021c4e596.js'},{'revision':null,'url':'/_next/static/chunks/app/intelligence/page-67a1ff5cb5db4b20.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-3b07af6d04b4581b.js'},{'revision':null,'url':'/_next/static/chunks/app/ledger/page-b2f371ae0e2dc445.js'},{'revision':null,'url':'/_next/static/chunks/app/login/page-e0e44e4f9dfb8d77.js'},{'revision':null,'url':'/_next/static/chunks/app/marketplace/page-ce0df41afd789a48.js'},{'revision':null,'url':'/_next/static/chunks/app/not-found-3d8359a06d3506dc.js'},{'revision':null,'url':'/_next/static/chunks/app/page-38a2bfff1f1f1e74.js'},{'revision':null,'url':'/_next/static/chunks/app/privacy/page-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/profile/page-589cb3a364727c3b.js'},{'revision':null,'url':'/_next/static/chunks/app/responsible-gambling/page-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/scan/page-ee1e7545074ffd2a.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/security/page-6728e10517c834af.js'},{'revision':null,'url':'/_next/static/chunks/app/signup/page-06c72711414c663d.js'},{'revision':null,'url':'/_next/static/chunks/app/soccer/page-17f39664cea5f0ca.js'},{'revision':null,'url':'/_next/static/chunks/app/terms/page-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/tracker/page-c75c1f66c42eac58.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/cancel/page-3d8359a06d3506dc.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/desk/page-416ebb86e698df18.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/success/page-3057bec4679f1ad3.js'},{'revision':null,'url':'/_next/static/chunks/app/verify/page-b5ca90220207b0ae.js'},{'revision':null,'url':'/_next/static/chunks/app/welcome/page-ff77b94f609b0d52.js'},{'revision':null,'url':'/_next/static/chunks/framework-29591c88a1db84ab.js'},{'revision':null,'url':'/_next/static/chunks/main-ad5ed0494576592d.js'},{'revision':null,'url':'/_next/static/chunks/main-app-f54c179578b140aa.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/app-error-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/forbidden-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/global-error-d5beecc31181c673.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/unauthorized-17ca8364c0815cb4.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-9660626b6ab2c75c.js'},{'revision':null,'url':'/_next/static/css/9a23f77e58f3bd56.css'},{'revision':'d7bc0f63f9618b8b700d028d7d242de8','url':'/apple-touch-icon.png'},{'revision':'5616f81ec5f8a9f726a17fb7ac66a54a','url':'/favicon-16.png'},{'revision':'1fc285cfb0116a0523eb34c779a7d282','url':'/favicon-32.png'},{'revision':'583b034f36839a8af92841771eef38b6','url':'/favicon.ico'},{'revision':'b15795128b3691e5703d0ce14ce10827','url':'/favicon.png'},{'revision':'44e9d71551be13574ac2e10063d03aa9','url':'/favicon.svg'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icon-512.png'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icons/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-512.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-maskable-512.png'},{'revision':'27007d181514e7fe4213035736a8300e','url':'/manifest.json'},{'revision':'9f61cf51298661d5c57a782534216240','url':'/og-image.png'},{'revision':'7c759c20b35840eacf9344692cb51061','url':'/og-image.svg'},{'revision':'9327802333275f11e68c3f19f20c160d','url':'/widget.js'}],skipWaiting:!0,clientsClaim:!0,navigationPreload:!0,runtimeCaching:eq}).addEventListeners(),self.addEventListener("push",e=>{let t;if(!e.data)return;try{t=e.data.json()}catch{t={title:"VYNDR",body:e.data.text()}}let{title:a="VYNDR",body:s="",icon:r="/icons/icon-192.png",url:n="/"}=t;e.waitUntil(self.registration.showNotification(a,{body:s,icon:r,badge:"/icons/icon-192.png",data:{url:n}}))}),self.addEventListener("notificationclick",e=>{e.notification.close();let t=e.notification.data?.url??"/";e.waitUntil(self.clients.matchAll({type:"window",includeUncontrolled:!0}).then(e=>{let a=e.find(e=>e.url.endsWith(t));return a?a.focus():self.clients.openWindow(t)}))})})(); \ No newline at end of file +This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),y(e,async()=>{let t=new eo;this.precacheStrategy.plugins.push(t),await el(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let s=this._cacheKeysToIntegrities.get(a),r=this._urlsToCacheModes.get(t),n=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:n,url:new URL(n.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:s}=t;return{updatedURLs:a,notUpdatedURLs:s}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return y(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),s=[];for(let r of t)a.has(r.url)||(await e.delete(r),s.push(r.url));return{deletedCacheRequests:s}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,ea(e))}setCatchHandler(e){this._catchHandler=ea(e)}registerCapture(e,t,a){let s=((e,t,a)=>{if("string"==typeof e){let s=new URL(e,location.href);return new es(({url:e})=>e.href===s.href,t,a)}if(e instanceof RegExp)return new ei(e,t,a);if("function"==typeof e)return new es(e,t,a);if(e instanceof es)return e;throw new l("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})})(e,t,a);return this.registerRoute(s),s}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new l("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;let r=s.origin===location.origin,{params:n,route:i}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=i?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:s,request:e,event:t,params:n})}catch(e){a=Promise.reject(e)}let l=i?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:s,request:e,event:t,params:n})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:s}){for(let r of this._routes.get(a.method)||[]){let n,i=r.match({url:e,sameOrigin:t,request:a,event:s});if(i)return Array.isArray(n=i)&&0===n.length||i.constructor===Object&&0===Object.keys(i).length?n=void 0:"boolean"==typeof i&&(n=void 0),{route:r,params:n}}return{}}};let eq=[{matcher:/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,handler:new ex({cacheName:"google-fonts-webfonts",plugins:[new ef({maxEntries:4,maxAgeSeconds:31536e3,maxAgeFrom:"last-used"})]})},{matcher:/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,handler:new eb({cacheName:"google-fonts-stylesheets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,handler:new eb({cacheName:"static-font-assets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,handler:new eb({cacheName:"static-image-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:2592e3,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/static.+\.js$/i,handler:new ex({cacheName:"next-static-js-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/image\?url=.+$/i,handler:new eb({cacheName:"next-image",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:mp3|wav|ogg)$/i,handler:new ex({cacheName:"static-audio-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:mp4|webm)$/i,handler:new ex({cacheName:"static-video-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:js)$/i,handler:new eb({cacheName:"static-js-assets",plugins:[new ef({maxEntries:48,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:css|less)$/i,handler:new eb({cacheName:"static-style-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/data\/.+\/.+\.json$/i,handler:new ee({cacheName:"next-data",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:json|xml|csv)$/i,handler:new ee({cacheName:"static-data-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/api\/auth\/.*/,handler:new et({networkTimeoutSeconds:10})},{matcher:({sameOrigin:e,url:{pathname:t}})=>e&&t.startsWith("/api/"),method:"GET",handler:new ee({cacheName:"apis",plugins:[new ef({maxEntries:16,maxAgeSeconds:86400,maxAgeFrom:"last-used"})],networkTimeoutSeconds:10})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc-prefetch",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>e.headers.get("Content-Type")?.includes("text/html")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({url:{pathname:e},sameOrigin:t})=>t&&!e.startsWith("/api/"),handler:new ee({cacheName:"others",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({sameOrigin:e})=>!e,handler:new ee({cacheName:"cross-origin",plugins:[new ef({maxEntries:32,maxAgeSeconds:3600})],networkTimeoutSeconds:10})},{matcher:/.*/i,method:"GET",handler:new et}];new eR({precacheEntries:[{'revision':null,'url':'/_next/static/chunks/1958-a1cd681eb2c1a83e.js'},{'revision':null,'url':'/_next/static/chunks/4bd1b696-c2f6e0877b6c10aa.js'},{'revision':null,'url':'/_next/static/chunks/5838-7ac6ecc648315323.js'},{'revision':null,'url':'/_next/static/chunks/6810-8b074082a0b51859.js'},{'revision':null,'url':'/_next/static/chunks/8500-3953cc33eeceb44e.js'},{'revision':null,'url':'/_next/static/chunks/9048-05afa5c60c3f117a.js'},{'revision':null,'url':'/_next/static/chunks/9da6db1e-54d62c4d2564a0ac.js'},{'revision':null,'url':'/_next/static/chunks/app/_global-error/page-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/checkout/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/props/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/tonight/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/intelligence/feed/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/accuracy/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/soccer/%5Bleague%5D/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/add-leg/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/grade/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/players/search/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/live/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/most-parlayed/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/top-graded/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/scan/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/parlays-graded/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/public/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/profile/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/recent-scans/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/scans/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/waitlist/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/webhook/nexapay/route-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/callback/page-f6253334ccab6a26.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/%5Bslug%5D/page-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/page-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/dashboard/page-107967f64e4a489c.js'},{'revision':null,'url':'/_next/static/chunks/app/forgot-password/page-012a02e7c1c1bf4c.js'},{'revision':null,'url':'/_next/static/chunks/app/game/%5Bid%5D/page-2c1eb07021c4e596.js'},{'revision':null,'url':'/_next/static/chunks/app/intelligence/page-67a1ff5cb5db4b20.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-0664074f4773364b.js'},{'revision':null,'url':'/_next/static/chunks/app/ledger/page-b2f371ae0e2dc445.js'},{'revision':null,'url':'/_next/static/chunks/app/login/page-e0e44e4f9dfb8d77.js'},{'revision':null,'url':'/_next/static/chunks/app/marketplace/page-ce0df41afd789a48.js'},{'revision':null,'url':'/_next/static/chunks/app/not-found-3d8359a06d3506dc.js'},{'revision':null,'url':'/_next/static/chunks/app/page-f57ab420b87965db.js'},{'revision':null,'url':'/_next/static/chunks/app/pricing/page-26ab42eb33b18416.js'},{'revision':null,'url':'/_next/static/chunks/app/privacy/page-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/profile/page-589cb3a364727c3b.js'},{'revision':null,'url':'/_next/static/chunks/app/responsible-gambling/page-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/scan/page-ee1e7545074ffd2a.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/security/page-6728e10517c834af.js'},{'revision':null,'url':'/_next/static/chunks/app/signup/page-06c72711414c663d.js'},{'revision':null,'url':'/_next/static/chunks/app/soccer/page-17f39664cea5f0ca.js'},{'revision':null,'url':'/_next/static/chunks/app/terms/page-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/app/tracker/page-c75c1f66c42eac58.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/cancel/page-3d8359a06d3506dc.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/desk/page-416ebb86e698df18.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/success/page-3057bec4679f1ad3.js'},{'revision':null,'url':'/_next/static/chunks/app/verify/page-b5ca90220207b0ae.js'},{'revision':null,'url':'/_next/static/chunks/app/welcome/page-ff77b94f609b0d52.js'},{'revision':null,'url':'/_next/static/chunks/framework-29591c88a1db84ab.js'},{'revision':null,'url':'/_next/static/chunks/main-ad5ed0494576592d.js'},{'revision':null,'url':'/_next/static/chunks/main-app-f54c179578b140aa.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/app-error-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/forbidden-17ca8364c0815cb4.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/global-error-d5beecc31181c673.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/unauthorized-17ca8364c0815cb4.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-9660626b6ab2c75c.js'},{'revision':null,'url':'/_next/static/css/424869ae6d53eea0.css'},{'revision':'351b8d8f1191d818da0829e300463beb','url':'/_next/static/wwWA3m8FwiwmMiltFfuqN/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/wwWA3m8FwiwmMiltFfuqN/_ssgManifest.js'},{'revision':'d7bc0f63f9618b8b700d028d7d242de8','url':'/apple-touch-icon.png'},{'revision':'5616f81ec5f8a9f726a17fb7ac66a54a','url':'/favicon-16.png'},{'revision':'1fc285cfb0116a0523eb34c779a7d282','url':'/favicon-32.png'},{'revision':'583b034f36839a8af92841771eef38b6','url':'/favicon.ico'},{'revision':'b15795128b3691e5703d0ce14ce10827','url':'/favicon.png'},{'revision':'44e9d71551be13574ac2e10063d03aa9','url':'/favicon.svg'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icon-512.png'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icons/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-512.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-maskable-512.png'},{'revision':'27007d181514e7fe4213035736a8300e','url':'/manifest.json'},{'revision':'9f61cf51298661d5c57a782534216240','url':'/og-image.png'},{'revision':'7c759c20b35840eacf9344692cb51061','url':'/og-image.svg'},{'revision':'9327802333275f11e68c3f19f20c160d','url':'/widget.js'}],skipWaiting:!0,clientsClaim:!0,navigationPreload:!0,runtimeCaching:eq}).addEventListeners(),self.addEventListener("push",e=>{let t;if(!e.data)return;try{t=e.data.json()}catch{t={title:"VYNDR",body:e.data.text()}}let{title:a="VYNDR",body:s="",icon:r="/icons/icon-192.png",url:n="/"}=t;e.waitUntil(self.registration.showNotification(a,{body:s,icon:r,badge:"/icons/icon-192.png",data:{url:n}}))}),self.addEventListener("notificationclick",e=>{e.notification.close();let t=e.notification.data?.url??"/";e.waitUntil(self.clients.matchAll({type:"window",includeUncontrolled:!0}).then(e=>{let a=e.find(e=>e.url.endsWith(t));return a?a.focus():self.clients.openWindow(t)}))})})(); \ No newline at end of file diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 1ad37ca..bcd6bc0 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -10,6 +10,7 @@ import InstallPrompt from '@/components/InstallPrompt'; import PushPrompt from '@/components/PushPrompt'; import MFAPrompt from '@/components/MFAPrompt'; import MFAChallenge from '@/components/MFAChallenge'; +import CookieConsent from '@/components/CookieConsent'; import './globals.css'; export const metadata: Metadata = { @@ -19,7 +20,7 @@ export const metadata: Metadata = { template: '%s · VYNDR', }, description: - "Grade NBA, MLB, and WNBA props with intelligence the books don't want you to have. Built in Detroit.", + "Grade NBA, MLB, WNBA, and soccer props with intelligence the books don't want you to have. World Cup 2026 intelligence: xG regression, altitude, referee, penalty taker. Built in Detroit.", applicationName: 'VYNDR', authors: [{ name: 'VYNDR', url: 'https://vyndr.app' }], manifest: '/manifest.json', @@ -28,6 +29,9 @@ export const metadata: Metadata = { 'NBA prop bet analysis', 'MLB prop intelligence', 'WNBA prop grading', + 'soccer prop intelligence', + 'World Cup 2026 props', + 'xG regression analysis', 'parlay correlation analysis', 'prop betting tools', ], @@ -104,6 +108,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + diff --git a/web/src/app/pricing/page.tsx b/web/src/app/pricing/page.tsx new file mode 100644 index 0000000..5899870 --- /dev/null +++ b/web/src/app/pricing/page.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from 'next'; +import Pricing from '@/components/Pricing'; + +export const metadata: Metadata = { + title: 'Pricing — VYNDR', + description: 'Founder pricing locks for life. $14.99/mo Analyst, $44.99/mo Desk. First 100 seats only.', + openGraph: { + title: 'VYNDR — Founder Pricing', + description: 'Sports prop intelligence. Beta pricing locks for life. First 100 seats.', + type: 'website', + url: 'https://vyndr.app/pricing', + }, + twitter: { + card: 'summary_large_image', + title: 'VYNDR — Founder Pricing', + description: 'Sports prop intelligence. Beta pricing locks for life.', + }, +}; + +/** + * Dedicated /pricing route. The Pricing component is also embedded on + * the landing page under the `#pricing` anchor; this page exists so: + * - The renewal email at `web/src/services/email.ts` (which links + * to `/pricing`) lands on something real instead of 404'ing. + * - Nav / CTA links can hand users a single stable URL whether + * they're already authenticated or not. + * - SEO crawlers see pricing on a canonical URL, not deep in the + * landing-page anchor. + * + * The component itself is fully client-side rendered (it owns + * checkout state + AuthContext access), so this page wraps it in a + * minimal scroll-restoration-friendly shell. + */ +export default function PricingPage() { + return ( +
+ +
+ ); +} diff --git a/web/src/app/privacy/page.tsx b/web/src/app/privacy/page.tsx index 7904dd1..24d7d05 100644 --- a/web/src/app/privacy/page.tsx +++ b/web/src/app/privacy/page.tsx @@ -18,7 +18,7 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [ body: [ 'Account data: email, password hash, age confirmation, signup timestamp.', 'Usage data: reads you run (player, stat, line, sport, grade), parlays you build, page views.', - 'Payment data: NexaPay processes all card data — we never see or store your card. We retain a NexaPay customer ID and your subscription status.', + 'Payment data: Stripe processes all card data — we never see or store your card number, CVC, or any other payment instrument detail. We retain a Stripe customer ID, Stripe subscription ID, and your subscription status (active, canceled, grace period, expired) so we can gate paid features and respond to renewal/cancellation events from Stripe webhooks.', 'Device data: IP address, browser type, basic device info (for fraud prevention and analytics).', ], }, @@ -54,6 +54,16 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [ 'Analytics: PostHog (anonymized IPs, no third-party trackers). You can opt out by setting your browser to "Do Not Track" or contacting us.', ], }, + { + title: 'Sub-processors', + body: [ + 'We rely on a small set of third-party processors. They handle data on our behalf under their own privacy commitments; we do not give them permission to use your data for their own purposes.', + 'Stripe — payment processing and subscription management. Receives: name (if you provide), email, billing address (if you provide), and the card details you enter on their hosted checkout page. We never see your card number.', + 'Supabase — authentication, database, and file storage. Receives: everything in the "Data we collect" section above. Supabase is our primary backend.', + 'PostHog — product analytics. Receives: anonymized event data (page views, button clicks). IPs are anonymized before storage.', + 'Resend — transactional email (account confirmations, payment receipts, renewal reminders). Receives: your email address and the message contents.', + ], + }, { title: 'Notifications', body: [ diff --git a/web/src/app/terms/page.tsx b/web/src/app/terms/page.tsx index ca53576..e8936de 100644 --- a/web/src/app/terms/page.tsx +++ b/web/src/app/terms/page.tsx @@ -29,7 +29,7 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [ { title: 'Subscription terms', body: [ - 'Paid tiers (Analyst, Desk) are billed monthly through NexaPay. You may cancel at any time from your profile page. Cancellation takes effect at the end of the current billing period. We do not refund for partial months.', + 'Paid tiers (Analyst, Desk) are billed monthly through Stripe, our payment processor. You may cancel at any time from your profile page. Cancellation takes effect at the end of the current billing period. We do not refund for partial months. If a payment fails, we honor a 48-hour grace period before reverting your account to the free tier.', 'Founder pricing ($14.99/mo Analyst, $44.99/mo Desk) is locked for the lifetime of your continuous subscription. Lapsed subscriptions revert to standard pricing ($24.99 Analyst, $49.99 Desk) on re-subscription. After the first 100 founder seats are taken, new subscribers pay standard pricing.', 'We may change regular pricing with 30 days notice. Founder pricing is locked.', ], @@ -38,6 +38,7 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [ title: 'Acceptable use', body: [ 'Do not scrape, reverse engineer, or attempt to replicate the grading engine. Do not resell reads, share account credentials, or attempt to circumvent the read limit. Do not use the service to abuse, harass, or defraud others.', + 'VYNDR does NOT offer API access at any tier. The grading engine is consumer-only — we do not provide programmatic access to grades, factor weights, model outputs, or any other engine surface, regardless of plan or pricing. Requests for API access will be declined.', ], }, { diff --git a/web/src/components/CookieConsent.tsx b/web/src/components/CookieConsent.tsx new file mode 100644 index 0000000..81bc207 --- /dev/null +++ b/web/src/components/CookieConsent.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; + +const STORAGE_KEY = 'vyndr_cookie_consent'; + +/** + * Cookie consent — thin bottom bar shown on first visit. Single line, + * dark, dismissable. "Accept" writes a flag to localStorage so the + * banner never appears again on this device. + * + * SSR-safe: we render nothing until the client mounts and the + * localStorage check completes. That prevents a hydration mismatch + * (server has no `window.localStorage`, so it can't know the user's + * prior choice) and avoids the brief banner flash on every refresh + * for users who already accepted. + * + * GDPR posture: VYNDR's cookies are essential (auth + read counter) + * plus analytics (anonymized PostHog). We disclose; we don't pre-tick + * checkboxes for non-essential analytics. The "Accept" button only + * acknowledges that you saw the disclosure. + */ +export default function CookieConsent() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + try { + if (window.localStorage.getItem(STORAGE_KEY) !== 'accepted') { + setVisible(true); + } + } catch { + // Storage may be unavailable in private mode — fail closed: show + // the banner. Cheaper than tracking sessions for these users. + setVisible(true); + } + }, []); + + function accept() { + try { + window.localStorage.setItem(STORAGE_KEY, 'accepted'); + } catch { + /* private mode — banner will reappear next visit; acceptable. */ + } + setVisible(false); + } + + if (!visible) return null; + + return ( +
+
+ + We use cookies for authentication and anonymized analytics.{' '} + + Privacy policy + + . + + +
+
+ ); +}