--- name: shark-game-development description: Build and maintain the Shark Attack Fantasy League at shark.iamgmb.com — FastAPI backend, pure JS frontend, Leaflet map, AI news scraper, JWT auth, snake draft engine, Web Push notifications, iOS PWA. version: 1.4.0 author: Sho'Nuff tags: [gaming, fantasy-sports, shark, push-notifications, pwa, fastapi, leaflet, scoring-engine, web-push, map] platforms: [linux] --- # Shark Attack Fantasy League Fantasy sports game at `shark.iamgmb.com`. Linked references: references/shark-game-recovery-manual.md -- offline recovery manual. references/password-reset-email-invite-logout.md -- Password reset, logout fix, and email invite feature (Jul 2026). references/trademark-and-domain-research.md -- Trademark conflict research for Feeding Frenzy. references/unified-bottom-nav-pattern.md -- Persistent bottom tab bar. references/family-waters-and-join-fix.md -- Family Waters +30 bonus and Join League 500 error fix. references/safety-tips-section.md -- AI sarcastic safety tips. references/ios-pwa-push-testing.md -- PWA push notification testing on iOS. references/shark-dad-jokes-section.md -- Shark dad jokes on the How to Play page. references/draft-reminder-emails.md -- Draft reminder email implementation. ## Architecture ``` shark.iamgmb.com ├── FastAPI backend (port 8083, systemd: shark-game.service) │ ├── /root/shark-game/backend/server.py │ └── /root/shark-game/backend/game.db (SQLite) ├── Static frontend: /var/www/shark-game/ │ ├── index.html — Landing/register/login page │ ├── draft-room.html — Draft board, map, tabs (Scoring/Standings/My Team/League/Settings/Help/Learn) │ ├── league.html — League settings, members, invite code, push prefs │ ├── settings.html — Notification prefs, quiet hours, profile │ ├── help.html — PWA setup guide, troubleshooting │ ├── how-to-play.html — Full rules, scoring table, draft explainer, shark dad jokes │ ├── ideas.html — Idea submission form with mailto fallback │ ├── data/ideas.json — JSON storage for submitted ideas │ └── sw.js — Service worker for push notifications ├── Scraper: /root/shark-game/scraper/scrape.py (cron every 1h) └── Caddy: reverse proxy shark.iamgmb.com → 127.0.0.1:8083 ``` ## Game Rules - 32 coastal regions with ISAF historical data - Snake draft: round 1 1->N, round 2 N->1, round 3 1->N ... - Scoring: Sighting +2pts, Bite +5pts, Fatality +10pts - AI news scraper (Firecrawl + admin-ai LLM) classifies events hourly - In leagues of odd player counts, the lowest-performing regions are auto-removed for even distribution ## Backend ### Tech stack - FastAPI with JWT auth (bcrypt for passwords, PyJWT for tokens) - SQLite (game.db) via sqlite3 module - pywebpush for Web Push notifications - Systemd service at /etc/systemd/system/shark-game.service - Venv at /root/shark-game/backend/venv/ ### Key endpoints | Method | Path | Purpose | |--------|------|---------| | POST | /api/register | Create account | | POST | /api/login | Get JWT token | | GET | /api/regions | List 32 regions with lat/lng/stats | | POST | /api/leagues | Create league (max_players default 6, range 2-10) | | POST | /api/leagues/{id}/join | Join league | | GET | /api/leagues/{id} | League details, draft state | | PATCH | /api/leagues/{id}/settings | Set draft_start_at, season settings (commissioner only) | | POST | /api/leagues/{id}/draft/start | Commissioner starts draft | | POST | /api/leagues/{id}/draft/pick | Make a draft pick (auto-sends push) | | GET | /api/leagues/{id}/draft/board | Current draft board | | POST | /api/leagues/{id}/invite | Send email invite to player (commissioner only) | | GET | /api/leagues/{id}/leaderboard | Points leaderboard | | POST | /api/leagues/{id}/start-draft | Commissioner starts snake draft | | POST | /api/auth/forgot-password | Send password reset email (no auth) | | POST | /api/auth/reset-password | Validate token + set new password (no auth) | | POST | /api/score-events | Scraper inserts score event (auth via API key in X-API-Key header) | | POST | /api/push/subscribe | Save push subscription (JWT required) | | POST | /api/push/subscribe-noauth | Save push sub without login (for test pages) | | POST | /api/push/unsubscribe | Remove push subscription | | GET | /api/push/vapid-public-key | Return VAPID public key | | POST | /api/push/test | Send test push to all subscribers | | GET | /api/push/preferences | Get notification prefs | | PATCH | /api/push/preferences | Update notification prefs | | POST | /api/ideas/submit | Submit game idea (saved to ideas.json) | ### Push notification system VAPID keys stored as module-level variables in server.py: ```python VAPID_PRIVATE_KEY = "MIIEvQIBADANBgkqhkiG9w0BAQ... (truncated)" VAPID_PUBLIC_KEY = "BDOkjGQg_FFXALuG0qXl2PtXskrYKMF0nf5mmWd-zTMnnMdY0bRmK3lVoYLia2jxu_pWHlnIuTFylCR4pMPvllI" VAPID_CLAIMS = {"sub": "mailto:g@germainebrown.com"} ``` **Automatic push on scoring** — when a score event is created (sighting/bite/fatality), `send_push_notification()` is called for the affected user. The function: 1. Queries `push_subscriptions` for the user 2. Checks `notifications_enabled` user preference 3. Checks notification type preference (sightings/bites/fatalities/draft) if `event_type` is provided 4. Checks `notify_my_regions_only` — if enabled and `region_id` is provided, skips users who haven't drafted that region 5. Checks quiet hours window (using `America/New_York` timezone — ET, not UTC. Fixed Jul 2026: was using `datetime.utcnow()` which broke the 9PM-6AM default for non-UTC servers.) 6. Sends via `pywebpush.webpush()` with VAPID auth **Test push endpoint** — `POST /api/push/test` sends to all subscriptions (no auth required). Useful for verifying PWA integration. ### Database schema (actual — from game.db, Jul 2026) ```sql CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, phone TEXT, display_name TEXT NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), is_admin INTEGER DEFAULT 0, notifications_enabled INTEGER DEFAULT 1, quiet_hours_start TEXT DEFAULT '21:00', quiet_hours_end TEXT DEFAULT '06:00', quiet_hours_enabled INTEGER DEFAULT 1, -- opt-out (default on) since Jul 2026 notify_sightings INTEGER DEFAULT 1, notify_bites INTEGER DEFAULT 1, notify_fatalities INTEGER DEFAULT 1, notify_draft INTEGER DEFAULT 1, notify_my_regions_only INTEGER DEFAULT 0, family_waters_bonus INTEGER DEFAULT 0, family_waters_count INTEGER DEFAULT 0, reset_token TEXT, reset_token_expires TEXT ); CREATE TABLE leagues ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, invite_code TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','active','closed')), season_start TEXT NOT NULL, season_end TEXT, created_by INTEGER NOT NULL REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), max_players INTEGER NOT NULL DEFAULT 6, draft_start_at TEXT ); CREATE TABLE league_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, league_id INTEGER NOT NULL REFERENCES leagues(id), user_id INTEGER NOT NULL REFERENCES users(id), draft_order INTEGER, is_commissioner INTEGER NOT NULL DEFAULT 0, UNIQUE(league_id, user_id) ); CREATE TABLE regions ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, emoji TEXT NOT NULL, description TEXT, prev_year_sightings INTEGER NOT NULL DEFAULT 0, prev_year_bites INTEGER NOT NULL DEFAULT 0, prev_year_fatalities INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE draft_picks ( id INTEGER PRIMARY KEY AUTOINCREMENT, league_id INTEGER NOT NULL REFERENCES leagues(id), user_id INTEGER NOT NULL REFERENCES users(id), region_id INTEGER NOT NULL REFERENCES regions(id), round INTEGER NOT NULL, pick_number INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(league_id, region_id) ); CREATE TABLE scores ( id INTEGER PRIMARY KEY AUTOINCREMENT, region_id INTEGER NOT NULL REFERENCES regions(id), event_type TEXT NOT NULL CHECK(event_type IN ('sighting','bite','fatality')), description TEXT, source_url TEXT, points INTEGER NOT NULL DEFAULT 1, event_date TEXT NOT NULL, verified INTEGER NOT NULL DEFAULT 0, is_personal INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE user_scores ( user_id INTEGER NOT NULL, league_id INTEGER NOT NULL, total_points INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (user_id, league_id) ); CREATE TABLE league_excluded_regions ( id INTEGER PRIMARY KEY AUTOINCREMENT, league_id INTEGER NOT NULL REFERENCES leagues(id), region_id INTEGER NOT NULL REFERENCES regions(id), UNIQUE(league_id, region_id) ); CREATE TABLE push_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, endpoint TEXT NOT NULL UNIQUE, p256dh TEXT NOT NULL, auth TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) ); ``` ## Push Notifications ### Architecture The push notification system has 4 parts: 1. **VAPID keys** — generated once, stored in server.py. Public key exposed at GET /api/push/vapid-public-key 2. **Backend send function** — `send_push_notification(user_id, title, body, icon)` queries user's subscriptions and sends via pywebpush 3. **Service Worker** — `/var/www/shark-game/sw.js` listens for push events, shows native notification, opens draft-room.html on click 4. **Subscription UI** — banner in draft-room.html that prompts user to enable notifications ### iOS PWA requirement Web Push on iOS ONLY works when the page is opened from a Home Screen PWA (not Safari browser tab). If PushManager isn't available, the browser shows `FAIL: No PushManager`. The user must: 1. Tap Share icon → Add to Home Screen 2. Open from the Home Screen icon (fullscreen, no URL bar) 3. Enable notifications from there ### Service worker file location The SW file MUST be at the domain root (`/sw.js`) to intercept push events correctly. Caddy reverse-proxies `shark.iamgmb.com/sw.js` → served from `/var/www/shark-game/sw.js`. ### Common pitfalls - **`json` must be imported at the top of server.py** — pywebpush.send() calls json.dumps() for the notification payload. Without `import json`, push fails with `name 'json' is not defined`. - **VAPID private key in the correct venv** — systemd runs from `/root/shark-game/backend/venv/bin/python3`. Install pywebpush there: `/root/shark-game/backend/venv/bin/pip install pywebpush`. - **`{"sent": 0}` means the send failed** — the catch() was silently swallowing errors with `except: pass`. Always add print() logging: `print(f"Push failed: {e}", flush=True)`. - **user_id must be NULLable in the schema** — if the subscription table has `user_id INTEGER NOT NULL REFERENCES users(id)`, test subscriptions (no-auth endpoint) hit FOREIGN KEY constraint failures. Use `user_id INTEGER` (nullable) for the no-auth path. - **PushBanner must be unhidden in JS** — the HTML has `