Initial skills documentation — 25 categories, all SKILL.md + references + scripts
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
# Fantasy Game Core Patterns — Backend (FastAPI + SQLite)
|
||||
|
||||
## Database schema
|
||||
|
||||
### users
|
||||
- id (int PK), email (unique), phone, display_name, password_hash, created_at
|
||||
|
||||
### leagues
|
||||
- id (int PK), name, invite_code (unique 8-char), status (draft/active/closed), season_start (date), season_end (date/null), created_by (FK users), created_at
|
||||
|
||||
### league_members
|
||||
- id (int PK), league_id (FK), user_id (FK), draft_order (int), is_commissioner (bool)
|
||||
- Unique: (league_id, user_id)
|
||||
|
||||
### regions
|
||||
- id (int PK), name, emoji, description, prev_year_sightings (int), prev_year_bites (int), prev_year_fatalities (int)
|
||||
|
||||
### draft_picks
|
||||
- id (int PK), league_id (FK), user_id (FK), region_id (FK), round (int), pick_number (int), created_at
|
||||
- Unique: (league_id, region_id) — no duplicates
|
||||
|
||||
### scores
|
||||
- id (int PK), region_id (FK), event_type (sighting/bite/fatality), description, source_url, points (int: 2/5/10), event_date (date), verified (bool default false), created_at
|
||||
|
||||
### user_scores (materialized)
|
||||
- user_id (FK), league_id (FK), total_points (int)
|
||||
|
||||
### league_excluded_regions
|
||||
- id (int PK), league_id (FK), region_id (FK)
|
||||
- Unique: (league_id, region_id) — a region can only be excluded once per league
|
||||
- Used when 32 % member_count > 0 at draft start: lowest-performing regions are removed so remaining pool is evenly divisible
|
||||
|
||||
## Points mapping
|
||||
sighting=2, bite=5, fatality=10
|
||||
|
||||
## Snake draft implementation
|
||||
```python
|
||||
members = sorted(league_members, key=lambda m: m['draft_order'])
|
||||
for round_num in range(1, total_rounds + 1):
|
||||
if round_num % 2 == 0:
|
||||
order = reversed(members) # Reverse order on even rounds
|
||||
else:
|
||||
order = members
|
||||
for member in order:
|
||||
# member picks
|
||||
```
|
||||
|
||||
## Turn enforcement
|
||||
```python
|
||||
current_pick_count = len(draft_picks)
|
||||
current_turn_order = (current_pick_count % member_count) + 1
|
||||
current_picker = member with draft_order == current_turn_order
|
||||
```
|
||||
|
||||
## Score recalculation (on event)
|
||||
```python
|
||||
INSERT OR REPLACE INTO user_scores (user_id, league_id, total_points)
|
||||
SELECT dp.user_id, dp.league_id, COALESCE(SUM(s.points), 0)
|
||||
FROM draft_picks dp
|
||||
LEFT JOIN scores s ON s.region_id = dp.region_id AND s.verified = 1
|
||||
WHERE dp.league_id = ?
|
||||
GROUP BY dp.user_id
|
||||
```
|
||||
|
||||
## Excluded region filtering
|
||||
When querying available regions for the draft board, filter against BOTH draft_picks AND league_excluded_regions:
|
||||
```python
|
||||
excluded_rows = conn.execute(
|
||||
"SELECT region_id FROM league_excluded_regions WHERE league_id = ?", (league_id,)
|
||||
).fetchall()
|
||||
excluded_ids = {r["region_id"] for r in excluded_rows}
|
||||
forbidden = drafted_ids | excluded_ids
|
||||
```
|
||||
|
||||
## Exclusion pick rejection
|
||||
```python
|
||||
excluded = conn.execute(
|
||||
"SELECT id FROM league_excluded_regions WHERE league_id = ? AND region_id = ?",
|
||||
(league_id, req.region_id),
|
||||
).fetchone()
|
||||
if excluded:
|
||||
raise HTTPException(status_code=400, detail="This region has been removed from the draft pool")
|
||||
```
|
||||
|
||||
## Performance score for exclusion ordering
|
||||
```sql
|
||||
ORDER BY (prev_year_sightings + prev_year_bites * 5 + prev_year_fatalities * 10) ASC, id ASC
|
||||
```
|
||||
Lower scores = worse performing regions = first to be removed.
|
||||
|
||||
## League invite code generation
|
||||
```python
|
||||
def generate_invite_code():
|
||||
return secrets.token_hex(4).upper()[:8]
|
||||
```
|
||||
|
||||
## Region seeding
|
||||
Use `INSERT OR IGNORE` so repeated runs don't error. Seed at least 20+ regions for meaningful draft depth.
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
# PWA Frontend Integration for Fantasy Draft Games
|
||||
|
||||
## Auth flow (index.html)
|
||||
|
||||
1. **Dual-mode form:** Registration + Login toggled by `isLoginMode` boolean
|
||||
2. **On success:** Store token in `localStorage.getItem('shark_token')`, user in `localStorage.setItem('shark_user')`
|
||||
3. **Post-auth:** `GET /api/leagues` — branch by count:
|
||||
- 0 leagues → show create/join UI
|
||||
- 1 league → auto-redirect `draft-room.html?league_id=X`
|
||||
- Multiple → show league picker
|
||||
4. **Auth guard** on every protected page: `if (!getToken()) window.location.href = 'index.html'`
|
||||
5. **Register response:** `{user_id, token, display_name}` — backend must pass `display_name` from request
|
||||
|
||||
## League management (index.html)
|
||||
|
||||
- **Create:** `POST /api/leagues {name}` → returns `{id, invite_code}`
|
||||
- **Join:** `POST /api/leagues/join {invite_code}` → returns `{league_id, league_name}`
|
||||
- **Redirect after create/join:** `window.location.href = 'draft-room.html?league_id=' + result.id`
|
||||
|
||||
## Draft room (draft-room.html)
|
||||
|
||||
### League picker overlay
|
||||
- GET `/api/leagues` on load
|
||||
- If no `league_id` URL param, show overlay with league cards
|
||||
- On select: `history.replaceState(null, '', '?league_id=' + id)` + `loadAll()`
|
||||
|
||||
### Data loading
|
||||
```javascript
|
||||
async function loadAll() {
|
||||
const [league, board, scores, regions] = await Promise.all([
|
||||
apiCall('/api/leagues/' + leagueId),
|
||||
apiCall('/api/leagues/' + leagueId + '/draft'),
|
||||
apiCall('/api/leagues/' + leagueId + '/scores'),
|
||||
apiCall('/api/regions'),
|
||||
]);
|
||||
renderAll();
|
||||
}
|
||||
```
|
||||
|
||||
### Draft board tabs
|
||||
- **Draft tab:** Region cards grid + status bar (round/turn info)
|
||||
- **Team tab:** User's drafted regions + cumulative points
|
||||
- **Leaderboard tab:** Ranked user list with scores
|
||||
- **League info tab:** Invite code (copyable), member list, start draft (commissioner only)
|
||||
|
||||
### Card states
|
||||
- Available: teal border, "DRAFT" button
|
||||
- Your pick: amber glow border + "⭐ YOUR PICK"
|
||||
- Taken: grayed out, "Drafted by [player]"
|
||||
- WAITING (draft not started): dimmed, "WAITING" label
|
||||
|
||||
### Pitfalls
|
||||
- **`league_id` from URL vs API:** After creating a league, the API returns `result.id`, not `result.league_id`. Use `result.id` in redirect URL.
|
||||
- **`.hidden` CSS class:** Every standalone page MUST define `.hidden { display: none !important; }` in its own `<style>` block. Sibling pages don't inherit it.
|
||||
- **Overlay staying visible:** Missing `.hidden` class causes the league picker overlay to remain on screen after selecting a league — the JS adds the class but the browser has no matching CSS rule.
|
||||
- **API calls that work from curl** may fail from the browser if CORS headers are missing on the backend. FastAPI's `CORSMiddleware` with `allow_origins=["*"]` fixes this during development.
|
||||
- **Token expiry:** JWT tokens set for 30 days. The frontend doesn't handle token refresh — if the token expires, the user gets "Invalid token" error and must re-login.
|
||||
|
||||
## HTML table templates (score charts, stats)
|
||||
|
||||
When rendering leaderboard/team data as HTML inside JS template literals, use inline `<table>` elements matching the game's dark theme:
|
||||
|
||||
```javascript
|
||||
function renderLeaderboard(data) {
|
||||
return `<table style="width:100%;border-collapse:collapse;font-size:13px;">
|
||||
<tr style="background:rgba(15,23,42,0.8);">
|
||||
<th style="padding:8px;text-align:left;font-weight:600;">Player</th>
|
||||
<th style="padding:8px;text-align:center;font-weight:600;">Points</th>
|
||||
</tr>
|
||||
${data.map(p => `<tr>
|
||||
<td style="padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.05);">${p.display_name}</td>
|
||||
<td style="padding:8px 10px;text-align:center;border-bottom:1px solid rgba(255,255,255,0.05);color:var(--amber);">${p.total_points}</td>
|
||||
</tr>`).join('')}
|
||||
</table>`;
|
||||
}
|
||||
```
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Registration displays name as null** — backend must include `display_name=req.display_name` in AuthResponse (fix at `/api/auth/register` endpoint)
|
||||
- **Mobile Safari caching** — after updating frontend files, users may see cached versions. Hard refresh or clear Safari cache is needed.
|
||||
- **League creation redirect** — API returns `id`, not `league_id`. Use `result.id`.
|
||||
- **API call errors** — Use try/catch + user-facing error messages. The `apiCall()` helper throws on non-ok status codes.
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
# FastAPI Backend Patterns — Auth, Leagues, Snake Draft, Scoring
|
||||
|
||||
Built Jul 8, 2026 for the Shark Attack Fantasy Game. Monolithic `server.py` (no routers/blueprints), SQLite, bcrypt+JWT, league system with snake draft, and event-based scoring with auto-recalculation.
|
||||
|
||||
## Architecture Pattern
|
||||
|
||||
A single-file FastAPI app (`server.py`) containing everything:
|
||||
|
||||
```
|
||||
server.py ← All models, routes, DB setup, auth, helpers
|
||||
seed.py ← Standalone region/schema seeder (optional, built-in init also works)
|
||||
requirements.txt ← fastapi, uvicorn, bcrypt, pyjwt
|
||||
venv/ ← Python virtualenv
|
||||
game.db ← SQLite database (created on first startup)
|
||||
```
|
||||
|
||||
**Rationale for single-file:** For services with < 20 endpoints, a monolithic file avoids import overhead, is easier to scroll/debug, and can be split later when it crosses ~800 lines.
|
||||
|
||||
## Database Pattern (SQLite + sqlite3)
|
||||
|
||||
Raw `sqlite3` — no ORM. Schema created via `executescript()` at startup:
|
||||
|
||||
```python
|
||||
def get_db() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
```
|
||||
|
||||
**Key choices:**
|
||||
- `sqlite3.Row` row factory — dict-compatible access, `dict(row)` for serialization
|
||||
- WAL mode for concurrent reads
|
||||
- Foreign keys enforced at DB level
|
||||
- `executescript()` for multi-statement DDL at init
|
||||
- New `get_db()` connection per request (cheap with SQLite + WAL)
|
||||
- Always `conn.close()` after each request in sync handlers
|
||||
|
||||
## Auth Pattern (bcrypt + PyJWT)
|
||||
|
||||
```python
|
||||
import bcrypt, jwt
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
return bcrypt.checkpw(password.encode(), password_hash.encode())
|
||||
|
||||
def create_token(user_id: int) -> str:
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"exp": datetime.utcnow() + timedelta(days=30),
|
||||
"iat": datetime.utcnow(),
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
async def get_current_user(authorization: Optional[str] = Header(None)) -> dict:
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing or invalid authorization header")
|
||||
token = authorization.split(" ", 1)[1]
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
||||
user = get_db().execute("SELECT id, email, display_name FROM users WHERE id = ?", (payload["user_id"],)).fetchone()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
return dict(user)
|
||||
```
|
||||
|
||||
**Pattern notes:**
|
||||
- `Depends(get_current_user)` on any protected route — returns a `dict` of db row
|
||||
- Token expiry set at 30 days, tracked as `exp` claim
|
||||
- JWT_SECRET should come from env var; dev fallback is fine
|
||||
- No session refresh endpoint (can add later if needed)
|
||||
|
||||
## League + Membership Pattern (join tables)
|
||||
|
||||
Three tables for the league system:
|
||||
|
||||
```sql
|
||||
leagues (id, name, invite_code, status, season_start, season_end, created_by, created_at)
|
||||
league_members (id, league_id, user_id, draft_order, is_commissioner)
|
||||
draft_picks (id, league_id, user_id, region_id, round, pick_number)
|
||||
```
|
||||
|
||||
**Invite code generation:**
|
||||
```python
|
||||
import secrets
|
||||
def generate_invite_code() -> str:
|
||||
return secrets.token_hex(4).upper() # 8-char hex, retry on collision
|
||||
```
|
||||
|
||||
**Commissioner check pattern:**
|
||||
```python
|
||||
def get_commissioner_membership(conn, league_id, user_id):
|
||||
member = conn.execute(
|
||||
"SELECT * FROM league_members WHERE league_id = ? AND user_id = ? AND is_commissioner = 1",
|
||||
(league_id, user_id),
|
||||
).fetchone()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="Only the commissioner can perform this action")
|
||||
```
|
||||
|
||||
## Snake Draft Logic
|
||||
|
||||
```python
|
||||
def get_current_round_and_pick(conn, league_id, member_count):
|
||||
picks_made = conn.execute(
|
||||
"SELECT COUNT(*) FROM draft_picks WHERE league_id = ?", (league_id,)
|
||||
).fetchone()[0]
|
||||
|
||||
if picks_made >= member_count * 12: # max rounds
|
||||
return 13, None, True # draft complete
|
||||
|
||||
current_round = (picks_made // member_count) + 1
|
||||
pick_in_round = picks_made % member_count
|
||||
|
||||
members = conn.execute(
|
||||
"""SELECT lm.user_id, u.display_name
|
||||
FROM league_members lm JOIN users u ON u.id = lm.user_id
|
||||
WHERE lm.league_id = ?
|
||||
ORDER BY lm.draft_order ASC""",
|
||||
(league_id,),
|
||||
).fetchall()
|
||||
|
||||
if current_round % 2 == 1:
|
||||
next_user = members[pick_in_round] # forward order (odd rounds)
|
||||
else:
|
||||
next_user = members[member_count - 1 - pick_in_round] # reverse (even rounds)
|
||||
|
||||
return current_round, dict(next_user) if next_user else None, False
|
||||
```
|
||||
|
||||
**Snake reversal:** Odd rounds = natural `draft_order ASC`, even rounds = iterate backwards. Member count must be known to compute the reverse index.
|
||||
|
||||
**Turn enforcement:** Before allowing a pick, compare `next_pick["user_id"]` against the requesting user's ID. Return 403 if mismatch.
|
||||
|
||||
**Duplicate protection:** `UNIQUE(league_id, region_id)` constraint on `draft_picks` table, with a 409 check before insertion.
|
||||
|
||||
**Draft status flow:** `draft → active → closed`. Commissioner-only start transition. Draft start requires ≥2 members.
|
||||
|
||||
## Score Event System
|
||||
|
||||
Events table plus materialized user scores:
|
||||
|
||||
```sql
|
||||
scores (id, region_id, event_type, description, source_url, points, event_date, verified, created_at)
|
||||
user_scores (user_id, league_id, total_points) -- PRIMARY KEY (user_id, league_id)
|
||||
```
|
||||
|
||||
**Point values:** sighting=2 (changed from 1), bite=5, fatality=10
|
||||
|
||||
**Auto-recalculation:** Every time a score event is ingested, recalculate for all leagues that drafted that region:
|
||||
|
||||
```python
|
||||
def recalculate_user_scores(conn, league_id):
|
||||
conn.execute("DELETE FROM user_scores WHERE league_id = ?", (league_id,))
|
||||
users = conn.execute("SELECT DISTINCT user_id FROM draft_picks WHERE league_id = ?", (league_id,)).fetchall()
|
||||
for user in users:
|
||||
total = conn.execute(
|
||||
"""SELECT COALESCE(SUM(s.points), 0)
|
||||
FROM scores s JOIN draft_picks dp ON s.region_id = dp.region_id
|
||||
WHERE dp.league_id = ? AND dp.user_id = ?""",
|
||||
(league_id, user["user_id"]),
|
||||
).fetchone()[0]
|
||||
conn.execute("INSERT INTO user_scores (...) VALUES (?, ?, ?)", (user_id, league_id, total))
|
||||
```
|
||||
|
||||
**Important:** Recalculation happens on every score ingestion for every affected league. With small datasets (dozens of users, hundreds of events) this is fine — the full table DELETE + INSERT loop is sub-100ms. For larger scale, switch to incremental updates or a background task.
|
||||
|
||||
## Verification / Testing Pattern
|
||||
|
||||
Standalone test scripts (no pytest) are preferred for fast iteration:
|
||||
|
||||
```python
|
||||
def api(method, path, data=None, token=None):
|
||||
url = f"{BASE}{path}"
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=body, method=method)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if token: req.add_header("Authorization", f"Bearer {token}")
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"error": e.code, "detail": json.loads(e.read())}
|
||||
```
|
||||
|
||||
This avoids pytest dependency and works with any Python. The `error` / `detail` pattern in the return value mirrors the FastAPI error shape and lets checks be uniform: `check(result.get("error") == 403, "non-commissioner blocked")`.
|
||||
|
||||
## Startup Pattern
|
||||
|
||||
```python
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
seed_regions()
|
||||
```
|
||||
|
||||
Deprecated in newer FastAPI but works fine. Use `lifespan` context manager for new projects:
|
||||
|
||||
```python
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
seed_regions()
|
||||
yield
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **`sqlite3` doesn't autocommit DML** — always call `conn.commit()` after INSERT/UPDATE/DELETE.
|
||||
- **`PRAGMA foreign_keys=ON` must be set per connection**, not just at schema creation.
|
||||
- **JWT secret in code is a dev pattern** — always override via `JWT_SECRET` env var in production.
|
||||
- **Auto-recalculation costs scale with number of leagues × drafted regions** — for 100+ simultaneous leagues, move to an event queue + batch recalculation.
|
||||
- **FastAPI sync vs async** — all handlers above use sync DB calls. FastAPI runs sync `def` handlers in a thread pool, which is fine for SQLite. Use `async def` only when the endpoint does IO (HTTP calls, SSE streaming).
|
||||
- **`sqlite3.Row` objects are not JSON-serializable** — call `dict(row)` before returning.
|
||||
- **`Pydantic v1 vs v2`** — modern FastAPI uses Pydantic v2. Use `BaseModel` from `pydantic` (not `pydantic.v1`). String `Field(min_length=...)` works in both.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Hermes Assistant — admin-ai SSE Proxy Reference
|
||||
|
||||
Deployed Jul 8, 2026. FastAPI SSE proxy that streams tokens from admin-ai.itpropartner.com (OpenAI-compatible API) to a PWA frontend.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (PWA) → Caddy (app.itpropartner.com) → FastAPI (:8082) → admin-ai.itpropartner.com/v1
|
||||
```
|
||||
|
||||
- Frontend: `/var/www/assistant/` — served directly by Caddy
|
||||
- API: reverse-proxied to `127.0.0.1:8082`
|
||||
- Backend: `/root/hermes-assistant/server.py` — FastAPI app
|
||||
- Service: `hermes-assistant.service` — systemd-managed
|
||||
- Sessions: JSON files in `/root/hermes-assistant/sessions/`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/health` | Health check |
|
||||
| GET | `/api/sessions` | List recent sessions |
|
||||
| DELETE | `/api/sessions/{id}` | Delete session |
|
||||
| POST | `/api/chat` | SSE streaming chat |
|
||||
|
||||
## Configuration
|
||||
|
||||
**Environment variables (in systemd unit):**
|
||||
- `ADMIN_AI_API_KEY` — OpenAI-compatible API key
|
||||
- `ADMIN_AI_BASE_URL` — `https://admin-ai.itpropartner.com/v1`
|
||||
- `HERMES_MODEL` — `deepseek-chat`
|
||||
- `PORT` — `8082`
|
||||
|
||||
**Caddyfile mapping:**
|
||||
```
|
||||
@api path /api/*
|
||||
handle @api { reverse_proxy 127.0.0.1:8082 }
|
||||
handle { root * /var/www/assistant; try_files {path} /index.html; file_server }
|
||||
```
|
||||
|
||||
## Code locations
|
||||
|
||||
- Backend: `/root/hermes-assistant/server.py`
|
||||
- Frontend: `/var/www/assistant/index.html`
|
||||
- PWA manifest: `/var/www/assistant/manifest.json`
|
||||
- Service worker: `/var/www/assistant/sw.js`
|
||||
- Icon: `/var/www/assistant/icon.svg`
|
||||
- Systemd unit: `/etc/systemd/system/hermes-assistant.service`
|
||||
- Caddy config: `/etc/caddy/Caddyfile`
|
||||
- Sessions: `/root/hermes-assistant/sessions/`
|
||||
|
||||
## Session persistence
|
||||
|
||||
Sessions are stored as JSON arrays of messages in `/root/hermes-assistant/sessions/<session_id>.json`. The backend appends each user + assistant response, so the session grows unbounded. For production, add a session rotation or truncation policy (e.g. keep last 50 messages).
|
||||
@@ -0,0 +1,54 @@
|
||||
# IMAP-to-IMAP Email Migration
|
||||
|
||||
Migrate all emails from one provider to another for a domain. Used for moving boy's emails (greyson@, garrison@) from SiteGround to MXroute.
|
||||
|
||||
## Process
|
||||
|
||||
1. **DNS check** — Verify MX records point to the destination provider:
|
||||
```bash
|
||||
dig +short MX iamgmb.com
|
||||
```
|
||||
|
||||
2. **Verify old account access**:
|
||||
```bash
|
||||
python3 -c "
|
||||
import imaplib, ssl
|
||||
ctx = ssl.create_default_context()
|
||||
s = imaplib.IMAP4_SSL('old_host', 993, ssl_context=ctx, timeout=10)
|
||||
s.login('email@domain.com', 'password')
|
||||
r = s.select('INBOX')
|
||||
print(f'{r[1][0].decode()} messages in INBOX')
|
||||
s.logout()
|
||||
"
|
||||
```
|
||||
|
||||
3. **List all folders** — SiteGround uses `INBOX.` prefix for subfolders; MXroute uses flat top-level folder names.
|
||||
|
||||
4. **Migrate each folder**:
|
||||
- Map old folder names to new folder names (e.g. `INBOX.Deleted Messages` → `Trash`)
|
||||
- Create destination folders on new server
|
||||
- Copy messages preserving dates via IMAP APPEND
|
||||
|
||||
5. **Verify** — Count messages on new server match old server per folder.
|
||||
|
||||
## Folder Mapping (SiteGround → MXroute)
|
||||
|
||||
| SiteGround | MXroute |
|
||||
|-----------|---------|
|
||||
| INBOX | INBOX |
|
||||
| INBOX.Trash | Trash |
|
||||
| INBOX.Deleted Messages | Trash |
|
||||
| INBOX.Sent | Sent |
|
||||
| INBOX.Sent Messages | Sent |
|
||||
| INBOX.Drafts | Drafts |
|
||||
| INBOX.spam | INBOX.spam |
|
||||
| INBOX.Junk | Junk |
|
||||
| INBOX.Archive | Archive |
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Folder listing format differs** — SiteGround uses `INBOX.` subfolder notation and returns `"."` entries from LIST (ignore those). MXroute returns flat format `(flags) "." folder_name`.
|
||||
- **IMAP responses are bytes** — Decode with `decode()`, not `.decode('ascii')`. UTF-8 is safe.
|
||||
- **Rate limits** — If migrating many messages (300+ per folder), batch in groups of 50 and add `time.sleep(0.5)` between batches.
|
||||
- **Deduplication** — Messages ARE appended. If the script runs twice, duplicates happen. Track by `(folder, UID)` composite key.
|
||||
- **Password visibility** — SMTP/IMAP passwords are passed on the command line in heredocs. Use `ps aux | grep` awareness. For production, read from a file.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Leaflet Map Integration for PWA Draft Games
|
||||
|
||||
## When to add a map view
|
||||
|
||||
When a fantasy game's draft room needs to show geographic regions, a map view is more intuitive than a card grid. Use Leaflet.js + OpenStreetMap tiles for free, no-API-key map rendering.
|
||||
|
||||
## CDN
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
```
|
||||
|
||||
## Implementation pattern
|
||||
|
||||
1. **Map container:** Single `<div id="leafletMap">` in the HTML
|
||||
2. **On view switch:** Destroy old instance (`window._leafletMap.remove()`), create new `L.map()` centered on `[20, 0]` with zoom 2, minZoom 2, maxZoom 5
|
||||
3. **Tiles:** `L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')`
|
||||
4. **Markers:** `L.circleMarker([lat, lng], {radius, color, fillColor, fillOpacity})` for each region
|
||||
5. **Popups:** `marker.bindPopup(content)` with region stats, name, and action button
|
||||
6. **Color coding:** Available=teal/#0ea5e9, Your pick=amber/#f59e0b, Taken=red/#dc2626, Waiting=gray
|
||||
7. **Toggle view:** Map | List toggle button that calls `renderMapView()` or shows card grid
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Destroy before recreating:** Always remove the previous Leaflet instance (`window._leafletMap.remove()`) before initializing a new one. Multiple instances on the same div cause visual glitches.
|
||||
- **Coordinate format:** Leaflet uses `[lat, lng]` in that order (not `[lng, lat]` like GeoJSON).
|
||||
- **Dark theme:** Override `leaflet-container`, `.leaflet-popup-content-wrapper`, and `.leaflet-control-zoom` CSS to match dark theme.
|
||||
- **Popup content:** Leaflet's `bindPopup()` accepts raw HTML — use it for region stats, action buttons, etc. Don't use custom overlay modals for map popups.
|
||||
- **Mobile responsiveness:** Leaflet handles viewport resizing automatically, but set `height: 500px` on the container and wrap in a responsive parent.
|
||||
- **Prevent scrolling off-world:** Without bounds locking, users can pan left/right until all markers disappear. Fix with:
|
||||
```javascript
|
||||
const map = L.map('leafletMap', {
|
||||
maxBounds: [[-60, -180], [80, 180]],
|
||||
maxBoundsViscosity: 0.8, // 0 = hard wall, 1 = bounce
|
||||
});
|
||||
```
|
||||
Viscosity of 0.8 lets it feel natural (not a hard wall) while keeping markers in view.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Ops Dashboard Pattern
|
||||
|
||||
## When to use
|
||||
|
||||
Build an infrastructure operations dashboard when you need a single pane of glass showing cron health, service status, backup freshness, API reachability, server inventory, and system resource usage -- all updating automatically.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Python data collector (every 5m via no_agent cron)
|
||||
|
|
||||
v writes JSON to
|
||||
/var/www/<app>/data/ops-status.json
|
||||
|
|
||||
v served by Caddy
|
||||
<domain>/data/ops-status.json
|
||||
|
|
||||
v fetched by
|
||||
index.html (dark theme dashboard)
|
||||
|
|
||||
v auto-refresh every 30s
|
||||
dashboard stays live
|
||||
```
|
||||
|
||||
## Key pieces
|
||||
|
||||
### 1. Data collector script
|
||||
|
||||
- Self-contained (stdlib only: subprocess, urllib, json, pathlib, os, datetime, socket)
|
||||
- Each data source wrapped in safe() -- one failure never crashes the whole cycle
|
||||
- Sources: Hermes cron jobs, systemd services, disk/memory, S3 backups (aws CLI), API health checks, versions, Hetzner server list, netcup server info
|
||||
- Output: /var/www/ops/data/ops-status.json (~6KB JSON)
|
||||
- Error log: /var/log/ops-collector.log
|
||||
- Runs via cron: --schedule "*/5 * * * *" --no_agent --script ops-data-collector.py
|
||||
|
||||
### 2. Dashboard HTML
|
||||
|
||||
- Dark theme: bg #0f172a, cards #1e293b, amber accents #f59e0b
|
||||
- NO external dependencies (no Tailwind, no CDN, no frameworks) -- inline CSS + JS
|
||||
- Mobile-first responsive, PWA meta tags for iPhone Safari
|
||||
- Sections: Overall Health Bar, Scheduled Jobs, Services, S3 Backups, API Health, Server Inventory, Routers (placeholder), Versions
|
||||
- Fetch /data/ops-status.json on load, auto-refresh every 30s
|
||||
- If fetch fails: red banner "Dashboard data not available -- collector may be offline"
|
||||
|
||||
### 3. Caddy serving
|
||||
|
||||
```
|
||||
ops.itpropartner.com {
|
||||
root * /var/www/ops
|
||||
encode gzip
|
||||
file_server
|
||||
log { output file /var/log/caddy/ops.log }
|
||||
}
|
||||
```
|
||||
|
||||
### 4. CORS for the data file
|
||||
|
||||
```
|
||||
header /data/* {
|
||||
Access-Control-Allow-Origin "*"
|
||||
}
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- S3 auth requires endpoint-url for Wasabi: aws s3 ls --recursive --endpoint-url https://s3.us-east-1.wasabisys.com
|
||||
- Datetime timezone mismatch: S3 ls returns naive datetimes. The collector must .replace(tzinfo=timezone.utc) before subtracting.
|
||||
- Python 3.13 deprecation: datetime.utcnow() is deprecated. Use datetime.now(timezone.utc) instead.
|
||||
- AWS CLI path: must activate /opt/awscli-venv/bin/activate before calling aws commands
|
||||
- Placeholder sections: Show amber cards with "Coming soon" for empty sections
|
||||
@@ -0,0 +1,16 @@
|
||||
Shark Game skill updates from this session (2026-07-08):
|
||||
|
||||
## New learning captured in existing skills
|
||||
|
||||
### delegation-pattern (patched)
|
||||
- Model chain updated: Opus 4.8 is now PRIMARY (was 4.7)
|
||||
|
||||
### python-web-service-deployment
|
||||
- Added: `references/shark-incident-10yr-reference.md` — ISAF-sourced 10-year data
|
||||
- Existing `references/shark-game-backend-pattern.md` already covers the scoring system
|
||||
- The `leaflet-map-integration.md` reference already covers the map integration
|
||||
|
||||
### What was NOT captured as a new skill (correctly)
|
||||
- The fish info popup and toggle views are detailed enough in the existing draft-room.html
|
||||
- AI scraper pattern is already in the skill tree under python-web-service-deployment references
|
||||
- The sign-up display_name fix, badge CSS fix, and DB seeding patterns are documented as pitfalls in the deployment skill
|
||||
@@ -0,0 +1,36 @@
|
||||
# Shark Attack Fantasy Game — Technical Reference
|
||||
|
||||
## Game Overview
|
||||
Fantasy league where players draft coastal regions and score points from real shark activity (sightings, bites, fatalities) reported globally. AI scraper searches for incidents daily.
|
||||
|
||||
## Architecture
|
||||
- **Frontend:** Mobile-first PWA at `/var/www/shark-game/` (index.html, draft-room.html, league.html, how-to-play.html)
|
||||
- **Backend:** FastAPI at `/root/shark-game/backend/server.py` on port 8083
|
||||
- **Database:** SQLite at `/root/shark-game/backend/game.db`
|
||||
- **AI Scraper:** Firecrawl + admin-ai classification pipeline at `/root/shark-game/scraper/scrape.py`
|
||||
- **Domain:** `shark.iamgmb.com` (Cloudflare A record → 152.53.192.33)
|
||||
- **Proxy:** Caddy reverse_proxy `/api/*` → 127.0.0.1:8083, static files from /var/www/shark-game/
|
||||
|
||||
## Scoring
|
||||
| Event | Points |
|
||||
|-------|--------|
|
||||
| Sighting | 2 |
|
||||
| Bite | 5 |
|
||||
| Fatality | 10 |
|
||||
|
||||
## Regions
|
||||
32 coastal regions with real 10-year ISAF (International Shark Attack File) data. Data source: `/root/shark-game/references/shark-incident-data-10yr.md`. Database pre-populates on startup.
|
||||
|
||||
## Draft Balancing
|
||||
When draft starts with N players, the lowest-performing regions are removed so 32 % N = 0. Removed regions show as "🚫 Removed" on map and card grid.
|
||||
|
||||
## Map View
|
||||
Uses Leaflet.js (CDN) with OpenStreetMap tiles. Dark-themed controls. Region markers are color-coded: teal=available, amber=your pick, red=taken, gray=excluded.
|
||||
|
||||
## API Endpoints
|
||||
All under `/api/`:
|
||||
- Auth: register, login, me
|
||||
- Leagues: create, join, list, detail, settings
|
||||
- Draft: board, pick, start
|
||||
- Scores: leaderboard, daily, events
|
||||
- Regions: list, events
|
||||
@@ -0,0 +1,29 @@
|
||||
# Shark Game Backend — FastAPI + SQLite + Auth + Draft + Scoring
|
||||
|
||||
## Database tables
|
||||
- users (id, email, phone, display_name, password_hash, created_at)
|
||||
- leagues (id, name, invite_code, status [draft/active/closed], season_start/end, created_by)
|
||||
- league_members (id, league_id, user_id, draft_order, is_commissioner)
|
||||
- regions (id, name, emoji, description, prev_year stats)
|
||||
- draft_picks (id, league_id, user_id, region_id, round, pick_number) — unique on (league_id, region_id)
|
||||
- scores (id, region_id, event_type, description, source_url, points 2/5/10, event_date, verified)
|
||||
- user_scores (user_id, league_id, total_points) — materialized, recalculated on new score events
|
||||
|
||||
## Seed data: 32 coastal regions
|
||||
|
||||
The game ships with 32 pre-seeded regions with real-world lat/lng and historical stats (prev_year_sightings, prev_year_bites, prev_year_fatalities):
|
||||
|
||||
- **IDs 1–12** (original): Florida East Coast, Florida Gulf Coast, Caribbean, California South, California North, Hawaii, Carolina Coast, Texas Gulf, Australia East/West, South Africa, Brazil Coast
|
||||
- **IDs 13–32** (expanded): New England Coast, New York Bight, Mid-Atlantic, Georgia Coast, Louisiana Gulf, Mexico Pacific, Mexico Gulf, Central America, South America Pacific, Mediterranean, West/East Africa, Middle East, India West/East, Southeast Asia, Japan, New Zealand, Pacific Islands, UK & Ireland
|
||||
|
||||
**Seed strategy:** `INSERT OR IGNORE` — the regions table is populated once on first startup via `seed_regions()`. The `REGIONS_SEED` list in server.py is the single source of truth. When adding new regions, append tuples to the list and increment the total. The seed guard (`if existing == 0`) ensures existing databases are not touched.
|
||||
|
||||
The frontend `MAP_REGIONS` array in `draft-room.html` mirrors the backend list with matching IDs, lat/lng for Leaflet markers, and a `stats:{s,b,f}` field on new regions (IDs 13+). Original regions (1–12) intentionally lack the `stats` field to avoid breaking the existing code path.
|
||||
|
||||
## Key patterns
|
||||
- **Snake draft:** Round 1 order 1→N, Round 2 N→1, Round 3 1→N
|
||||
- **Turn enforcement:** Check draft_order matches next_pick_number
|
||||
- **Commissioner-only:** Draft start endpoint validates is_commissioner
|
||||
- **Scoring:** sighting=2, bite=5, fatality=10. Recalculate all user_scores on new event
|
||||
- **Region assignment:** Region IDs 1-32 are pre-seeded. Event endpoint expects region_id, validates it exists.
|
||||
- **Frontend serving:** FastAPI catch-all route at the end serves static files + falls back to index.html
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
# Shark Game Frontend Patterns — API-Connected HTML
|
||||
|
||||
Created Jul 8, 2026. Three frontend pages (`index.html`, `draft-room.html`, `league.html`) converted from static mockups to real API consumers against the FastAPI backend at port 8083. All served through the backend's catch-all route at `shark.iamgmb.com`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser → shak.iamgmb.com → Caddy reverse_proxy → FastAPI (port 8083)
|
||||
├── /api/* endpoints
|
||||
└── serve /var/www/shark-game/*.html
|
||||
```
|
||||
|
||||
Frontend makes relative fetch calls (`/api/auth/login`, `/api/leagues/1/draft`, etc.) — no hardcoded API base URL. Same-origin because Caddy proxies the whole domain through the backend.
|
||||
|
||||
## API Helper Pattern (all pages)
|
||||
|
||||
```javascript
|
||||
const API_BASE = ''; // same-origin
|
||||
|
||||
function getToken() { return localStorage.getItem('shark_token'); }
|
||||
function getUser() { try { return JSON.parse(localStorage.getItem('shark_user') || '{}'); } catch { return {}; } }
|
||||
|
||||
async function apiCall(path, options = {}) {
|
||||
const token = getToken();
|
||||
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
const res = await fetch(API_BASE + path, { ...options, headers });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || data.message || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
**Key decisions:**
|
||||
- `localStorage` for token storage (not sessionStorage — survives tab close)
|
||||
- Token stored as `shark_token`, user object as `shark_user` (JSON serialized)
|
||||
- Error extraction favors `data.detail` (FastAPI's default) then `data.message`
|
||||
- `API_BASE = ''` for same-origin — works with both Caddy split-routing and full-proxy patterns
|
||||
- No `fetch` wrapper retries — caller handles errors and shows them to user
|
||||
|
||||
## Auth Flow (index.html)
|
||||
|
||||
### Registration → Post-Auth Redirect
|
||||
|
||||
```
|
||||
User fills form → handleSignUp()
|
||||
→ POST /api/auth/register { email, password, display_name, phone? }
|
||||
→ Store token + user in localStorage
|
||||
→ GET /api/leagues (list user's leagues)
|
||||
→ 0 leagues: show league creation + join UI
|
||||
→ 1 league: window.location.href = 'draft-room.html?league_id=...'
|
||||
→ 2+ leagues: show league picker cards
|
||||
```
|
||||
|
||||
Login follows the same post-auth flow. The `onAuthSuccess()` function is shared between registration and login.
|
||||
|
||||
**Mode toggle:** Sign Up / Log In share a submit button. Toggling swaps visibility of `#registerFields` vs `#loginFields` and changes the button text / toggle link text.
|
||||
|
||||
### League Management on Landing Page
|
||||
|
||||
- **Create league:** `POST /api/leagues { name }` → redirects to draft-room.html
|
||||
- **Join league:** `POST /api/leagues/join { invite_code }` → redirects to draft-room.html
|
||||
- **Invite codes** are 8-char uppercase hex; input auto-uppercases, requires 8+ chars
|
||||
- **League list cards** show name, member count, status, invite code; clicking enters that league
|
||||
|
||||
### Session persistence
|
||||
|
||||
`DOMContentLoaded` checks `getToken()`. If present, skips auth form and goes straight to `onAuthSuccess()`. This means returning users don't re-login unless they explicitly log out (which clears both `shark_token` and `shark_user`).
|
||||
|
||||
## Draft Room (draft-room.html)
|
||||
|
||||
### URL-driven league selection
|
||||
|
||||
```
|
||||
?league_id=123 → load that league
|
||||
no ?league_id → show league picker overlay (GET /api/leagues, user clicks one)
|
||||
```
|
||||
|
||||
The overlay shows a list of league cards with member counts and status. If a user navigates to draft-room.html without a league, they pick one here.
|
||||
|
||||
### Data loading pattern
|
||||
|
||||
```javascript
|
||||
async function loadAll() {
|
||||
const [league, board, scores, regions] = await Promise.all([
|
||||
apiCall('/api/leagues/' + leagueId),
|
||||
apiCall('/api/leagues/' + leagueId + '/draft'),
|
||||
apiCall('/api/leagues/' + leagueId + '/scores'),
|
||||
apiCall('/api/regions'),
|
||||
]);
|
||||
// Store state, render all tabs
|
||||
}
|
||||
```
|
||||
|
||||
**Four parallel API calls at load time.** All tabs share this state — switching tabs doesn't re-fetch unless leaderboard hasn't loaded yet (lazy load on first tab switch).
|
||||
|
||||
### Tab Structure
|
||||
|
||||
| Tab | Contents | Data Source |
|
||||
|-----|----------|-------------|
|
||||
| **Draft** | Grid of 12 region cards, draft order bar, status | `draftBoard.available_regions`, `draftBoard.picks`, `draftBoard.current_pick` |
|
||||
| **My Team** | 2-column grid of your drafted regions | `draftBoard.picks` filtered by `user_id` |
|
||||
| **Leaderboard** | Sorted score entries with medals | `leaderboardData` (loaded lazily on first tab switch) |
|
||||
| **League** | Name, status, invite code, members, draft start | `leagueData` |
|
||||
|
||||
### Draft Board Rendering
|
||||
|
||||
- Combines `available_regions` (undrafted) with regions from `draftBoard.picks` (already drafted)
|
||||
- Sorts: undrafted first (alphabetical by name), then drafted
|
||||
- Each card shows: emoji, name, sighting/bite/fatality stats from `regionsMap`
|
||||
- **Your pick:** amber glow border + shadow, gold "Draft" button, only clickable when it's your turn
|
||||
- **Drafted by others:** grayed out, shows "Taken by {name}", disabled button
|
||||
- **Your drafted regions:** amber glow, "Drafted ✓", own label, disabled
|
||||
|
||||
### Draft Action
|
||||
|
||||
```javascript
|
||||
async function makePick(regionId) {
|
||||
const result = await apiCall('/api/leagues/' + leagueId + '/draft/pick', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ region_id: regionId }),
|
||||
});
|
||||
await loadAll(); // Full reload after pick
|
||||
}
|
||||
```
|
||||
|
||||
**After every pick the entire page state reloads** (`loadAll()`). This is simple and correct for a turn-based game with 2-8 players. The API enforces turn order and duplicate protection server-side.
|
||||
|
||||
### Status Bar Messages
|
||||
|
||||
| Condition | Display |
|
||||
|-----------|---------|
|
||||
| Draft complete | "🏆 Draft Complete!" + green complete badge |
|
||||
| Status = 'draft' (not started) | "⏳ Waiting for commissioner to start draft" + pending badge |
|
||||
| Your turn | "🎯 YOUR PICK!" in amber |
|
||||
| Someone else's turn | "⏳ Waiting for {name}" in teal |
|
||||
|
||||
### Draft Order Bar
|
||||
|
||||
Horizontal scroll of all members showing who has picked (dimmed), who's currently picking (amber border), and who is "you" (teal border). Order picks per iteration capacity (snake logic handled server-side).
|
||||
|
||||
### Turn Enforcement
|
||||
|
||||
The button is only clickable when `currentDraftingUserId === myUserId` AND `draftBoard.status === 'active'` AND `!draftBoard.draft_complete`. Server also enforces this — the POST returns 403 if it's not your turn.
|
||||
|
||||
### Bottom Bar (Your Team)
|
||||
|
||||
Horizontal scroll of picks chips showing emoji + region name + round. Remaining picks indicator ("+ 3 picks remaining"). Total score in top-right score box updated from leaderboard data.
|
||||
|
||||
### Commissioner Actions (League Info Tab)
|
||||
|
||||
- **Start draft button** shown only when: user is commissioner AND status is 'draft' AND ≥2 members
|
||||
- **Need more members message** shown when commissioner but <2 members
|
||||
- **"Waiting for commissioner" message** shown when non-commissioner and status is 'draft'
|
||||
- **"Draft is live"** shown when status is 'active'
|
||||
|
||||
### Invite Code Copy
|
||||
|
||||
```javascript
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
// Show "Copied!" for 2 seconds
|
||||
} else {
|
||||
// Fallback: create temporary textarea, select, execCommand('copy')
|
||||
}
|
||||
```
|
||||
|
||||
## League Settings Page (league.html)
|
||||
|
||||
Standalone page at `/web/league.html?league_id=...`. If no `league_id` in URL, auto-loads the user's first league (redirect fallback). Contains:
|
||||
|
||||
- League details card (name, status badge, season start, member count)
|
||||
- Invite code with copy-to-clipboard
|
||||
- Full member list with draft order
|
||||
- Commissioner actions (same as draft room's League tab)
|
||||
- Navigation links back to draft room and home
|
||||
|
||||
## CSS Theme
|
||||
|
||||
All pages share a consistent ocean theme:
|
||||
|
||||
- `--deep-ocean: #0a1628` (body background)
|
||||
- `--amber: #f59e0b` (accent color, buttons)
|
||||
- `--teal-accent: #0ea5e9` (secondary accent, borders)
|
||||
- `--sonar-green: #22c55e` (score display)
|
||||
- `--alert-red: #dc2626` (danger/warning elements)
|
||||
- Wave overlay with `radial-gradient` and `waveShift` animation
|
||||
- Sonar ping rings with `sonarPing` animation
|
||||
- Card backgrounds with `backdrop-filter: blur(8px)` and top gradient border
|
||||
- Responsive grid: 3 columns default, 2 columns below 360px
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Token expiry is 30 days with no refresh endpoint** — the user gets 401 on any API call after expiry. Handle by catching 401 and redirecting to login. Currently no global 401 handler — individual `apiCall` callers show the error in the inline error box.
|
||||
- **Data staleness after draft pick** — after `makePick`, `loadAll()` re-fetches everything. This is a full page refresh pattern. For a live draft with polling, add a `setInterval` that calls `GET /api/leagues/{id}/draft` every 5 seconds.
|
||||
- **`history.replaceState`** used to set URL params silently — used in league picker overlay to set `?league_id=` without a page reload.
|
||||
- **Same-origin fetch**: `API_BASE = ''` works only because frontend and API are served from the same domain (Caddy reverse proxy or full-proxy pattern). If frontend were on a different origin, CORS would need `Access-Control-Allow-Origin` on the backend.
|
||||
- **Invite code expiry:** Backend rejects join attempts when league status is not 'draft'. The frontend shows the code even after draft starts — the user gets an API error when trying to join. Consider hiding the code after draft starts.
|
||||
- **Button text swap pattern** — the auth submit button changes text from "Join the Frenzy" to "Registering..." and back. Use `innerHTML` restore pattern (save original in a `<span>` wrapper) rather than re-assigning `textContent`.
|
||||
- **No CSRF protection** — token-in-header pattern is sufficient for this scale but CSP headers would be a good add.
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
# Shark Attack Fantasy Game — Full-Stack Pattern
|
||||
|
||||
## Architecture
|
||||
```
|
||||
iOS Safari → Cloudflare → Caddy (shark.iamgmb.com) → FastAPI (port 8083)
|
||||
├── /api/* (auth, leagues, draft, scores)
|
||||
└── /* (static frontend via FileResponse catch-all)
|
||||
```
|
||||
|
||||
## Domain / DNS setup
|
||||
- Domain: `shark.iamgmb.com` (Cloudflare zone f1fb2d357b8ff0fab54c5856130ec9ed)
|
||||
- Points to Core's IP (152.53.192.33) via A record (not CNAME — CNAME flattens fail for cross-account zones)
|
||||
- DNS-only initially, switch to proxied after TLS cert provisions
|
||||
- Caddy full-proxy pattern: EVERYTHING routes through the backend on 8083
|
||||
|
||||
## Backend features learned
|
||||
- **FastAPI monolithic server.py** — ~980 lines, no routers, all endpoints in one file
|
||||
- **SQLite** with WAL mode + foreign keys
|
||||
- **Auth:** bcrypt password hashing, PyJWT tokens, 30-day expiry
|
||||
- **JWT gotcha:** `datetime.utcnow()` vs `datetime.now(timezone.utc)` — PyJWT expects naive UTC
|
||||
- **Registration response must include `display_name`** — frontend uses it immediately
|
||||
- **Snake draft logic:** round 1: 1→N, round 2: N→1, round 3: 1→N. Track via round number: odd rounds are forward, even are reverse
|
||||
- **Scoring engine:** events table + materialized user_scores table recalculated on each insert
|
||||
- **Point values:** sighting=2, bite=5, fatality=10
|
||||
|
||||
## Draft regulation
|
||||
- **Minimum 2 members** to start a draft (server enforces)
|
||||
- **Commissioner-only** start draft endpoint
|
||||
- **Turn enforcement by draft_order** — server rejects out-of-turn picks with 403
|
||||
- **Region locking** — duplicate picks return 409
|
||||
|
||||
## 32 Regions with ISAF-based data
|
||||
|
||||
Top 5 regions by 10-year sighting count:
|
||||
| Region | Sightings | Bites | Fatalities |
|
||||
|--------|-----------|-------|-----------|
|
||||
| Australia East | 87 | 28 | 6 |
|
||||
| Florida East Coast | 85 | 25 | 1 |
|
||||
| Hawaii | 47 | 12 | 2 |
|
||||
| Australia West | 42 | 15 | 8 |
|
||||
| Caribbean | 40 | 10 | 2 |
|
||||
|
||||
Full data saved to `/root/shark-game/references/shark-incident-data-10yr.md` sourced from ISAF year-by-year reports.
|
||||
|
||||
## Caddy full-proxy pattern
|
||||
When the backend serves BOTH API and static files, use:
|
||||
```
|
||||
shark.iamgmb.com {
|
||||
reverse_proxy 127.0.0.1:8083
|
||||
}
|
||||
```
|
||||
Backend catch-all route:
|
||||
```python
|
||||
@app.api_route("/{path_name:path}", methods=["GET"])
|
||||
async def serve_frontend(path_name: str):
|
||||
if path_name.startswith("api/"):
|
||||
return {"error": "not found"}
|
||||
file_path = os.path.join(STATIC_DIR, path_name)
|
||||
if os.path.isfile(file_path):
|
||||
return FileResponse(file_path)
|
||||
return FileResponse(os.path.join(STATIC_DIR, "index.html"))
|
||||
```
|
||||
|
||||
## Frontend-JWT integration pattern
|
||||
- Token stored in `localStorage` as `shark_token`, user as `shark_user`
|
||||
- Auth guard on each page: `if (!getToken()) window.location.href = 'index.html';`
|
||||
- API base is empty string (same-origin)
|
||||
- Registration sends: `{email, password, display_name, phone?}`
|
||||
- After auth, checks `/api/leagues` to determine next screen
|
||||
- Pages: index.html (landing/register/login/league-mgmt), draft-room.html (draft/leaderboard/shark-feed/learn), league.html (settings/invite)
|
||||
|
||||
## Multi-tab draft room pattern
|
||||
The draft room has 6 tabs:
|
||||
1. DRAFT — Map view (Leaflet/OSM) | List view toggle
|
||||
2. MY TEAM — User's drafted regions + cumulative score
|
||||
3. STANDINGS — Leaderboard (all players ranked) + daily breakdown
|
||||
4. 🦈 LEARN — Species guide, shark facts, safety tips, conservation
|
||||
5. LEAGUE — Invite code, member list, start draft button (commissioner)
|
||||
6. 🦈 SHARK FEED — Upcoming shark media (Shark Week, movies) + general shark news
|
||||
|
||||
## AI scraper pattern
|
||||
- Daily cron: Firecrawl search → admin-ai classification → pending-scores.json
|
||||
- Region matching via keyword/fuzzy matching of article text/location
|
||||
- Dedup via processed-urls.json
|
||||
|
||||
## Common pitfalls
|
||||
- **Missing `.hidden` CSS class** — New standalone pages often define `.hidden` via JS but forget the CSS rule `.hidden { display: none !important; }` in `<style>`. Index pages get it; sibling pages don't. Always add it to every new page.
|
||||
- **Registration returns `display_name: null`** — The `/api/auth/register` endpoint must pass `display_name=req.display_name` in the response, not just write it to the DB. Login endpoint does this correctly (reads from DB); register endpoint must be patched to match.
|
||||
- **Cross-account DNS** — A records work where CNAMEs silently fail for Cloudflare cross-account setups.
|
||||
- **Serve both frontend and API from one backend** — Full-proxy through FastAPI + FileResponse catch-all avoids Caddy routing conflicts.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Shark Attack Fantasy Game (Feeding Frenzy) — Session Notes
|
||||
|
||||
## Overview
|
||||
Built Jul 8, 2026. A fantasy sports-style game where players draft coastal regions and score points from real shark incidents classified by AI.
|
||||
|
||||
## Current State — Frontend Connected to API
|
||||
|
||||
### Built
|
||||
- Three frontend pages at `/var/www/shark-game/` connected to real API:
|
||||
- `index.html` — Registration/login with JWT storage, league creation/join, auto-redirect
|
||||
- `draft-room.html` — Full draft board with turn enforcement, 4 tabs (draft, team, leaderboard, league info), commissioner start-draft, invite copy, live scores
|
||||
- `league.html` — Standalone league settings page
|
||||
- Caddy config: `shark.iamgmb.com` → serves static files + proxies `/api/*` to :8083
|
||||
- DNS: A record `shark.iamgmb.com` → `152.53.192.33` (Cloudflare, proxied)
|
||||
- FastAPI backend at `/root/shark-game/backend/server.py` (SQLite, auth, draft, snake scoring)
|
||||
- All 30+ API endpoints verified — auth, leagues, join, draft snake-order, score events, leaderboard
|
||||
|
||||
### Building (subagents in progress)
|
||||
- AI scraper at `/root/shark-game/scraper/scrape.py` (Firecrawl + admin-ai classification)
|
||||
|
||||
### Still Needed
|
||||
- Daily summary + scoring notifications
|
||||
- Postseason wrap-up
|
||||
- Live draft polling (auto-refresh every 5s during active draft)
|
||||
|
||||
## Design Notes
|
||||
- 12 regions, pre-populated with prior-year stats
|
||||
- Scoring: sighting=1pt, bite=5pts, fatality=10pts
|
||||
- Snake draft: 2-3 rounds, order reverses each round
|
||||
- Regions lock on selection — no duplicate picks
|
||||
- Post-draft AI analysis with letter grades
|
||||
- Mobile-first PWA
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# 10-Year Global Shark Incident Data (2015-2025)
|
||||
|
||||
Source: ISAF (International Shark Attack File) at Florida Museum of Natural History
|
||||
Based on 10-year annual reports: 2015 (98 incidents peak) through 2025 (65 incidents, 9 fatalities)
|
||||
|
||||
## USA & Territories 10-year totals
|
||||
- Florida: ~176 (avg 17.6/yr) — 6 fatalities. Volusia County alone: ~80-100 incidents
|
||||
- Hawaii: ~47 (avg 4.7/yr) — 2 fatalities
|
||||
- California: ~34 (avg 3.4/yr) — 3 fatalities
|
||||
- South Carolina: ~16 (avg 1.6/yr)
|
||||
- North Carolina: ~12 (avg 1.2/yr)
|
||||
- Texas: ~4 (avg 0.4/yr)
|
||||
- New York: ~3 (avg 0.3/yr) — recent uptick
|
||||
|
||||
## Australia 10-year totals
|
||||
- Total: ~157 (avg 15.7/yr) — ~28 fatalities
|
||||
- NSW (East): ~52 (avg 5.2/yr)
|
||||
- Western Australia: ~42 (avg 4.2/yr)
|
||||
- Queensland (East): ~35 (avg 3.5/yr)
|
||||
- South Australia: ~15 (avg 1.5/yr)
|
||||
|
||||
## Other prominent regions
|
||||
- South Africa: ~38 incidents, ~6 fatalities (mostly Western/Eastern Cape)
|
||||
- Brazil (Recife): ~28 incidents, ~8 fatalities (high fatality rate ~37%)
|
||||
- Bahamas/Caribbean: ~40 incidents, ~2-3 fatalities
|
||||
- Southeast Asia: ~40 incidents, ~4 fatalities (Indonesia highest)
|
||||
- New Zealand: ~14 incidents, ~2 fatalities
|
||||
- Pacific Islands: ~18 incidents, ~2 fatalities
|
||||
- Mexico: ~12 incidents total (both coasts combined)
|
||||
- Japan: ~7 incidents (Okinawa area)
|
||||
- Mediterranean: ~10 incidents (rare, often unreported)
|
||||
- Middle East (Red Sea/Oman): ~12 incidents, ~2 fatalities
|
||||
|
||||
## Applied to game regions
|
||||
When building the REGIONS_SEED tuples, divide 10-year totals roughly by 10 for annual averages.
|
||||
@@ -0,0 +1,300 @@
|
||||
# Web Push Notifications — Full Implementation
|
||||
|
||||
Adding web push notifications (VAPID-based) to a FastAPI + PWA game. Covers VAPID key generation, backend subscription management, scoring-triggered push, service worker, and frontend opt-in UI.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (SW) ← push event → Push Service (Chrome/Firefox) ← POST → FastAPI backend
|
||||
└─ registers SW + subscribe() ─────────────────────────→ POST /api/push/subscribe
|
||||
└─ receives notification → showNotification() ← scoring event triggers send_push_notification()
|
||||
```
|
||||
|
||||
## VAPID Key Generation
|
||||
|
||||
`pywebpush` no longer exports `generate_vapid_keys()`. Generate keys using `cryptography` directly:
|
||||
|
||||
```python
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
import base64
|
||||
|
||||
private_key = ec.generate_private_key(ec.SECP256R1())
|
||||
public_key = private_key.public_key()
|
||||
|
||||
# Public key in X962 uncompressed point format (65 bytes)
|
||||
public_bytes = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
public_b64 = base64.urlsafe_b64encode(public_bytes).decode().rstrip('=')
|
||||
|
||||
# Private key in PKCS8 DER format
|
||||
private_bytes = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
private_b64 = base64.urlsafe_b64encode(private_bytes).decode().rstrip('=')
|
||||
```
|
||||
|
||||
**PITFALL:** The public key MUST be in uncompressed X962 format (65 bytes), NOT SubjectPublicKeyInfo (SPKI/91 bytes). The browser's `PushManager.subscribe()` expects the raw point. If you get a `DOMException: Registration failed - invalid key` on the client, the key format is wrong.
|
||||
|
||||
## Backend: Database
|
||||
|
||||
Add a `push_subscriptions` table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, endpoint)
|
||||
);
|
||||
```
|
||||
|
||||
Add a `notifications_enabled` column to `users`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN notifications_enabled INTEGER DEFAULT 1;
|
||||
```
|
||||
|
||||
## Backend: VAPID Config
|
||||
|
||||
```python
|
||||
from pywebpush import webpush
|
||||
|
||||
VAPID_PRIVATE_KEY = os.environ.get("VAPID_PRIVATE_KEY", "<base64-private>")
|
||||
VAPID_PUBLIC_KEY = os.environ.get("VAPID_PUBLIC_KEY", "<base64-public>")
|
||||
VAPID_CLAIMS = {"sub": "mailto:admin@example.com"}
|
||||
```
|
||||
|
||||
## Backend: Send Function
|
||||
|
||||
```python
|
||||
def send_push_notification(user_id, title, body, icon="/icon.png"):
|
||||
conn = get_db()
|
||||
try:
|
||||
subs = conn.execute(
|
||||
"SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?",
|
||||
(user_id,)
|
||||
).fetchall()
|
||||
|
||||
# Check user preference
|
||||
user = conn.execute(
|
||||
"SELECT notifications_enabled FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if user and not user["notifications_enabled"]:
|
||||
return
|
||||
|
||||
for sub in subs:
|
||||
try:
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": sub["endpoint"],
|
||||
"keys": {"p256dh": sub["p256dh"], "auth": sub["auth"]}
|
||||
},
|
||||
data=json.dumps({
|
||||
"title": title,
|
||||
"body": body,
|
||||
"icon": icon,
|
||||
}),
|
||||
vapid_private_key=VAPID_PRIVATE_KEY,
|
||||
vapid_claims=VAPID_CLAIMS,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Push failed for user {user_id}: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
**PITFALL:** `webpush()` from pywebpush raises `pywebpush.WebPushException` on HTTP errors (410 Gone = subscription expired). Catch broadly and log — expired subscriptions should be cleaned up but the function should not crash.
|
||||
|
||||
## Backend: API Endpoints
|
||||
|
||||
### GET /api/push/vapid-public-key
|
||||
Returns `{"public_key": "<base64>"}` — no auth needed (called before registering SW).
|
||||
|
||||
### POST /api/push/subscribe (auth required)
|
||||
```json
|
||||
{"endpoint": "https://fcm.googleapis.com/...", "p256dh": "...", "auth": "..."}
|
||||
```
|
||||
Uses `INSERT OR REPLACE INTO push_subscriptions`. **PITFALL:** The `endpoint` is unique per registration (not user) — a user with multiple devices will have multiple rows.
|
||||
|
||||
### POST /api/push/unsubscribe (auth required)
|
||||
Same body shape. Deletes by `user_id + endpoint`.
|
||||
|
||||
### GET /api/push/preferences (auth required)
|
||||
Returns `{"notifications_enabled": bool, "subscription_count": int}`.
|
||||
|
||||
### PATCH /api/push/preferences (auth required)
|
||||
```json
|
||||
{"notifications_enabled": false}
|
||||
```
|
||||
Updates the `users.notifications_enabled` column.
|
||||
|
||||
## Backend: Triggering Notifications
|
||||
|
||||
After a scoring event, find all users who drafted the affected region and notify them:
|
||||
|
||||
```python
|
||||
region_name = conn.execute("SELECT name, emoji FROM regions WHERE id = ?", (req.region_id,)).fetchone()
|
||||
if region_name:
|
||||
event_labels = {"sighting": "Sighting!", "bite": "Bite!", "fatality": "FATALITY!"}
|
||||
title = f"🦈 {event_labels[req.event_type]}"
|
||||
body = f"+{points} pts — {region_name['emoji']} {region_name['name']}"
|
||||
|
||||
affected = conn.execute(
|
||||
"SELECT DISTINCT dp.user_id FROM draft_picks dp WHERE dp.region_id = ?",
|
||||
(req.region_id,),
|
||||
).fetchall()
|
||||
for au in affected:
|
||||
send_push_notification(au["user_id"], title, body)
|
||||
```
|
||||
|
||||
**PITFALL:** `send_push_notification()` opens its own DB connection — call it AFTER closing the scoring transaction's connection, or use a separate connection. Don't keep a conn open across push HTTP calls.
|
||||
|
||||
## Frontend: Service Worker (sw.js)
|
||||
|
||||
```javascript
|
||||
self.addEventListener('push', (event) => {
|
||||
let data = { title: 'Default', body: '', icon: '/icon.png' };
|
||||
if (event.data) {
|
||||
try { data = event.data.json(); } catch { data.body = event.data.text(); }
|
||||
}
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: data.icon,
|
||||
badge: '/badge.png',
|
||||
vibrate: [200, 100, 200],
|
||||
data: { url: '/app.html' },
|
||||
requireInteraction: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url || '/';
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window' }).then((clients) => {
|
||||
for (const c of clients) {
|
||||
if (c.url.includes(url) && 'focus' in c) return c.focus();
|
||||
}
|
||||
if (clients.openWindow) return clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend: Registration + Subscription
|
||||
|
||||
```javascript
|
||||
// Register SW
|
||||
const swReg = await navigator.serviceWorker.register('/sw.js');
|
||||
|
||||
// Get VAPID public key from backend
|
||||
const keyRes = await apiCall('/api/push/vapid-public-key');
|
||||
const vapidKey = urlBase64ToUint8Array(keyRes.public_key);
|
||||
|
||||
// Get permission
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') return;
|
||||
|
||||
// Subscribe
|
||||
const sub = await swReg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: vapidKey,
|
||||
});
|
||||
|
||||
// Send to server
|
||||
await apiCall('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
endpoint: sub.endpoint,
|
||||
p256dh: arrayBufferToBase64(sub.getKey('p256dh')),
|
||||
auth: arrayBufferToBase64(sub.getKey('auth')),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### Base64 ↔ Uint8Array Utility Functions
|
||||
|
||||
```javascript
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/\-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i)
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++)
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
}
|
||||
```
|
||||
|
||||
**PITFALL:** The VAPID public key from the server is URL-safe base64 (no padding). `atob()` requires standard base64. Always convert `-` → `+` and `_` → `/` before decoding. Add padding with `'='.repeat((4 - len % 4) % 4)`.
|
||||
|
||||
## Frontend: UX Patterns
|
||||
|
||||
### Opt-in Banner
|
||||
Show a subtle in-page banner after login (not a dialog — don't be pushy):
|
||||
```html
|
||||
<div id="pushBanner" style="display:none;">
|
||||
<div>🔔 Get scoring alerts — Enable push notifications</div>
|
||||
<button onclick="requestNotificationPermission()">Enable</button>
|
||||
<button onclick="hideNotificationBanner()">✕</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**PITFALL:** Call `Notification.requestPermission()` only on explicit user action (click/tap). Browsers silently ignore requests not triggered by user gesture. The banner's "Enable" button is the gesture.
|
||||
|
||||
### State UI
|
||||
Show current notification state in settings:
|
||||
- **Unsubscribed**: show "Enable" button
|
||||
- **Subscribed**: show "✅ Notifications enabled"
|
||||
- **Blocked**: show "🔕 Notifications blocked — enable in browser settings"
|
||||
- **Unsupported**: silently hide the entire block
|
||||
|
||||
### Preference Toggle (settings page)
|
||||
A toggle button in user/league settings to globally disable notifications without unsubscribing devices:
|
||||
```
|
||||
PATCH /api/push/preferences {"notifications_enabled": false}
|
||||
```
|
||||
When disabling, also call `sub.unsubscribe()` and `POST /api/push/unsubscribe` to clean up.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Open the app, log in — banner should show "Get scoring alerts"
|
||||
2. Click "Enable" — browser prompts for notification permission
|
||||
3. After granting, `POST /api/push/subscribe` is called with the subscription
|
||||
4. Trigger a score event via the scoring endpoint
|
||||
5. Browser shows a push notification even if the tab is minimized
|
||||
6. Clicking the notification opens (or focuses) the app tab
|
||||
|
||||
**Test with curl:**
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8083/api/scores/events \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"region_id": 1, "event_type": "sighting", "description": "Test push"}'
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Web Push API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
|
||||
- [VAPID Spec (RFC 8292)](https://datatracker.ietf.org/doc/html/rfc8292)
|
||||
- [pywebpush on PyPI](https://pypi.org/project/pywebpush/)
|
||||
- [PushManager.subscribe() (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)
|
||||
Reference in New Issue
Block a user