| 1 | # DropHawk - Master Build Specification |
| 2 | |
| 3 | > **Working name:** DropHawk (rename freely) |
| 4 | > **Purpose:** Always-on, multi-source ticket drop monitor with real-time UI, instant push notifications, and shock-drop detection across concerts, festivals, sports (NFL/NBA/NHL/MLB/tennis/F1/soccer), and exclusive events (FIFA World Cup, etc.). |
| 5 | > **Audience:** This document is the **architectural source of truth** for any executing agent (Claude, Codex, etc.). |
| 6 | > |
| 7 | > **⚠️ COMPANION FILE IS BINDING:** `CONTRACTS.md` lives alongside this file and contains the **exact pinned versions, exact API schemas, exact SQL DDL, exact design tokens, exact algorithms, and exact failure runbooks**. Where CONTRACTS.md and this file disagree, **CONTRACTS.md wins**. Read both before writing any code. |
| 8 | > |
| 9 | > **Loading order for any agent:** |
| 10 | > 1. `CLAUDE.md` (this repo) - bootstrap |
| 11 | > 2. `SPEC.md` (this file) - architecture & intent |
| 12 | > 3. `CONTRACTS.md` - binding implementation contracts |
| 13 | > |
| 14 | > When something is ambiguous, the answer is in CONTRACTS.md first, then this file. If both are silent, **ask the operator** - do not improvise on security, data integrity, or notification correctness. |
| 15 | |
| 16 | --- |
| 17 | |
| 18 | ## 0. How to use this spec |
| 19 | |
| 20 | - Read sections 1-6 before writing any code. They are non-negotiable. |
| 21 | - Section 7 (Source Adapters) defines an interface; implement v1 sources (7.1, 7.2) fully, then expand per the roadmap (§14). |
| 22 | - Section 15 (Coding Standards) lists hard rules. Violations are bugs. |
| 23 | - Section 17 (Execution Checklist) is the per-phase verification gate. Do not merge a phase until its checklist passes. |
| 24 | - File paths in this doc are **absolute within the repo root** (e.g. `backend/Cargo.toml`). |
| 25 | |
| 26 | --- |
| 27 | |
| 28 | ## 1. Project Overview & Vision |
| 29 | |
| 30 | ### 1.1 What DropHawk is |
| 31 | A self-hosted service that continuously polls dozens of primary and resale ticket sources, detects the instant an event's status flips to "on sale" (or a new event appears, or a resale price drops), and pushes a notification to the user's phone within seconds - with a one-tap link to buy. |
| 32 | |
| 33 | ### 1.2 What DropHawk is NOT |
| 34 | - **Not a bulk auto-purchaser.** It surfaces drops; the human taps the link and buys. This keeps the tool legal under the US BOTS Act and equivalent EU/UK laws. |
| 35 | - **Not a scalping bot.** Personal-use monitoring + manual purchase only. (See §16 for legal guardrails.) |
| 36 | - **Not a ticket marketplace.** No transactions occur in DropHawk. |
| 37 | |
| 38 | ### 1.3 User |
| 39 | - Single user (the operator). No auth system, no multi-tenancy. The "watchlist" is global. |
| 40 | - Runs on the operator's homelab, accessed via LAN or Tailscale. |
| 41 | |
| 42 | ### 1.4 Quality bar |
| 43 | - **Sub-15-second detection-to-notification latency** for adaptive-poll sources. |
| 44 | - **Zero false-positive notifications** - every alert must correspond to a real status change. |
| 45 | - **Visually stunning UI** - dark, dense, animated, real-time. Treat the dashboard like a Bloomberg terminal for tickets, not a CRUD app. |
| 46 | |
| 47 | --- |
| 48 | |
| 49 | ## 2. Tech Stack (LOCKED - do not substitute) |
| 50 | |
| 51 | ### 2.1 Rationale |
| 52 | The operator asked for "fastest and most cutting edge." This means: Rust for the hot path (polling, scheduling, API), Python for scraping (because Playwright + anti-bot libraries only exist maturely in Python), TypeScript for the UI. |
| 53 | |
| 54 | ### 2.2 Stack table |
| 55 | |
| 56 | | Layer | Technology | Version | Why | |
| 57 | |---|---|---|---| |
| 58 | | Core engine + API | **Rust** | 1.96+ | Fastest, lowest memory, async-native | |
| 59 | | Rust web framework | **Axum** | 0.7 | Tower middleware, typed handlers, de-facto standard | |
| 60 | | Rust async runtime | **Tokio** | 1.x | Required by Axum | |
| 61 | | Rust HTTP client | **reqwest** + **rquest** (for TLS fingerprinting) | latest | rquest impersonates Chrome TLS JA3 | |
| 62 | | Rust DB | **sqlx** (compile-time checked) | 0.8 | No ORM, type-safe queries | |
| 63 | | Rust messaging | **async-nats** | latest | JetStream for durable event bus | |
| 64 | | Rust scheduler | Custom Tokio task per source (no cron crate) | - | Each source owns its poll loop; clean backoff | |
| 65 | | Rust serialization | **serde** + **serde_json** | 1.x | Standard | |
| 66 | | Rust tracing | **tracing** + **tracing-subscriber** + **opentelemetry-otlp** | latest | Structured logs to Loki | |
| 67 | | Scraper workers | **Python** | 3.11 | Required for Playwright | |
| 68 | | Python HTTP | **httpx** + **curl_cffi** | latest | curl_cffi for browser TLS impersonation | |
| 69 | | Python browser | **Playwright** | 1.40+ | Headless Chromium for JS-rendered sources | |
| 70 | | Python HTML | **selectolax** (fast) + **BeautifulSoup** (fallback) | latest | Selectolax is 10x faster | |
| 71 | | Python task runner | **Celery** + **Redis broker** | 5.3 | Distributed scraper workers | |
| 72 | | Frontend framework | **Next.js** | 14.2+ (App Router) | RSC, streaming, edge-ready | |
| 73 | | Frontend lang | **TypeScript** | 5.4+ | Strict mode | |
| 74 | | Styling | **Tailwind CSS** | 3.4+ | Utility-first | |
| 75 | | Component lib | **shadcn/ui** | latest | Owned, composable, beautiful | |
| 76 | | Animation | **Framer Motion** | 11+ | Spring physics for drop alerts | |
| 77 | | Dashboard widgets | **Tremor** | latest | Charts, KPIs, sparklines | |
| 78 | | Data fetching | **TanStack Query** | 5+ | Cache + invalidation | |
| 79 | | Client state | **Zustand** | 5+ | Minimal, no boilerplate | |
| 80 | | Forms | **React Hook Form** + **Zod** | latest | Type-safe validation | |
| 81 | | Real-time push (server→client) | **Server-Sent Events (SSE)** via Axum `axum::response::sse` | - | Simpler than WebSocket, auto-reconnects | |
| 82 | | Database | **PostgreSQL** | 16 | Primary store | |
| 83 | | Time-series | **TimescaleDB** (Postgres extension) | 2.14+ | hypertables for price/source-health history | |
| 84 | | Cache + queue | **Redis** | 7.2+ | Hot cache, Celery broker, rate-limit tokens | |
| 85 | | Search | **Meilisearch** | 1.6+ | Fuzzy event/artist search, typo-tolerant | |
| 86 | | Message bus | **NATS** + **JetStream** | 2.10+ | Durable, fast pub/sub between Rust core and Python workers | |
| 87 | | Notifications | **ntfy** (self-hosted) | 2.x | Free push to iPhone (ntfy iOS app), self-hosted | |
| 88 | | Object storage | **MinIO** | latest | Screenshots of scraped pages for debug | |
| 89 | | Reverse proxy | **Caddy** | 2.7+ | Auto-HTTPS, simple config | |
| 90 | | Observability | **Prometheus** + **Grafana** + **Loki** + **OpenTelemetry** | latest | Metrics, dashboards, logs | |
| 91 | | Containerization | **Docker** + **Docker Compose** | 24+ / v2 | Homelab deploy | |
| 92 | | Python package mgr | **uv** | latest | 10x faster than pip | |
| 93 | | Node package mgr | **pnpm** | 9+ | Disk-efficient, deterministic | |
| 94 | |
| 95 | ### 2.3 Hard "do not use" list |
| 96 | - ❌ Go (not available in build env, no benefit over Rust here) |
| 97 | - ❌ MongoDB (use Postgres) |
| 98 | - ❌ Django/Flask for scrapers (use Celery workers; no web server in Python land) |
| 99 | - ❌ create-react-app (use Next.js App Router) |
| 100 | - ❌ Redux (use Zustand) |
| 101 | - ❌ Material-UI / Chakra (use shadcn/ui) |
| 102 | - ❌ Bootstrap (use Tailwind) |
| 103 | - ❌ Prisma on backend (use sqlx raw) |
| 104 | - ❌ GraphQL (YAGNI for single-user) |
| 105 | - ❌ WebSockets for server→client push (use SSE; simpler, auto-reconnect) |
| 106 | |
| 107 | --- |
| 108 | |
| 109 | ## 3. High-Level Architecture |
| 110 | |
| 111 | ### 3.1 Service map |
| 112 | |
| 113 | ``` |
| 114 | ┌─────────────────────────────────────────────┐ |
| 115 | │ HOMELAB HOST │ |
| 116 | │ │ |
| 117 | Phone (ntfy app) ◄──┤ ntfy ◄──┐ │ |
| 118 | │ │ │ |
| 119 | Browser ◄── Caddy ──┤ ┌────────┴────────┐ │ |
| 120 | │ │ Next.js (UI) │ │ |
| 121 | │ └────────┬────────┘ │ |
| 122 | │ │ SSE │ |
| 123 | │ ┌────────▼────────────────────────┐ │ |
| 124 | │ │ Rust core (Axum API) │ │ |
| 125 | │ │ - REST handlers │ │ |
| 126 | │ │ - SSE broadcaster │ │ |
| 127 | │ │ - Scheduler (per-source tasks) │ │ |
| 128 | │ │ - Drop detector (hash diff) │ │ |
| 129 | │ └──┬──────────┬───────────┬────────┘ │ |
| 130 | │ │ │ │ │ |
| 131 | │ ┌──▼───┐ ┌───▼────┐ ┌───▼─────────┐ │ |
| 132 | │ │ NATS │ │ Redis │ │ PostgreSQL │ │ |
| 133 | │ │ JetS │ │ │ │ +Timescale │ │ |
| 134 | │ │ tr │ │ │ │ │ │ |
| 135 | │ └──┬───┘ └────────┘ └─────────────┘ │ |
| 136 | │ │ publish scraped_payload │ |
| 137 | │ ┌──▼──────────────────────┐ │ |
| 138 | │ │ Python scraper workers │ │ |
| 139 | │ │ (Celery, Playwright) │ │ |
| 140 | │ │ - ticketmaster │ │ |
| 141 | │ │ - seatgeek │ │ |
| 142 | │ │ - fifa │ │ |
| 143 | │ │ - axs, seetickets │ │ |
| 144 | │ │ - stubhub, vividseats │ │ |
| 145 | │ │ - twitter shock-drop │ │ |
| 146 | │ │ - sports (nfl/nba/nhl…) │ │ |
| 147 | │ │ - festivals │ │ |
| 148 | │ └───────────┬──────────────┘ │ |
| 149 | │ │ │ |
| 150 | │ ┌───────────▼───────┐ ┌─────────────┐ │ |
| 151 | │ │ Meilisearch index │ │ MinIO (screenshot)│ │ |
| 152 | │ └───────────────────┘ └─────────────┘ │ |
| 153 | │ │ |
| 154 | │ Observability: Prometheus / Grafana / Loki │ |
| 155 | └─────────────────────────────────────────────┘ |
| 156 | ``` |
| 157 | |
| 158 | ### 3.2 Data flow - happy path for a drop detection |
| 159 | |
| 160 | 1. Rust scheduler spawns one Tokio task per source adapter. |
| 161 | 2. Each task polls on its adaptive interval (see §11) - for API-backed sources (Ticketmaster, SeatGeek), the Rust task calls directly. For scrape-backed sources (FIFA, AXS, StubHub), the Rust task publishes a `scrape_request` to NATS subject `scrape.{source_id}`. |
| 162 | 3. A Python Celery worker subscribed to `scrape.{source_id}` runs Playwright/httpx, returns a `ScrapedPayload` JSON to NATS subject `scrape.{source_id}.result`. |
| 163 | 4. Rust task ingests payload, normalizes into the `Event` shape, computes a `content_hash`. |
| 164 | 5. Rust compares hash against `events.content_hash` in Postgres. |
| 165 | - If new event → insert, emit `event.new` on NATS, broadcast `drop` via SSE, push ntfy. |
| 166 | - If hash changed (status flip, price change, inventory change) → update row, emit appropriate event. |
| 167 | 6. SSE broadcaster fans out to all connected browser clients. |
| 168 | 7. ntfy publisher POSTs to local ntfy server → user's iPhone buzzes. |
| 169 | |
| 170 | **Latency budget:** source-poll-response → hash-diff → SSE+ntfy must complete in <2 seconds. End-to-end notification (poll → phone) target <15 seconds, dominated by poll interval. |
| 171 | |
| 172 | ### 3.3 Bounded context boundaries |
| 173 | |
| 174 | | Service | Owns | Does NOT touch | |
| 175 | |---|---|---| |
| 176 | | Rust core | Scheduling, DB, hash-diff, SSE, ntfy, REST API | Browser automation, HTML parsing | |
| 177 | | Python scrapers | HTTP fetch, JS rendering, parsing, anti-bot | DB, scheduling, notifications | |
| 178 | | Next.js | UI rendering, watchlist forms, SSE consumption | Direct DB access, business logic | |
| 179 | | NATS | Message delivery only | Persistence beyond JetStream retention | |
| 180 | | Redis | Cache, rate-limit tokens, Celery broker | Source of truth (that's Postgres) | |
| 181 | |
| 182 | --- |
| 183 | |
| 184 | ## 4. Repository Structure |
| 185 | |
| 186 | ``` |
| 187 | drophawk/ |
| 188 | ├── SPEC.md ← this file |
| 189 | ├── CLAUDE.md ← condensed agent instructions (extract of this) |
| 190 | ├── README.md ← operator quickstart |
| 191 | ├── docker-compose.yml ← all services |
| 192 | ├── docker-compose.prod.yml ← homelab prod overrides |
| 193 | ├── .env.example ← all required env vars (no secrets) |
| 194 | ├── .gitignore |
| 195 | │ |
| 196 | ├── backend/ ← Rust core |
| 197 | │ ├── Cargo.toml |
| 198 | │ ├── Cargo.lock |
| 199 | │ ├── Dockerfile |
| 200 | │ ├── migrations/ ← sqlx migrations (timestamped, .sql files) |
| 201 | │ │ ├── 20260101000000_init.sql |
| 202 | │ │ ├── 20260101000001_sources.sql |
| 203 | │ │ ├── 20260101000002_events.sql |
| 204 | │ │ ├── 20260101000003_drops.sql |
| 205 | │ │ ├── 20260101000004_watchlist.sql |
| 206 | │ │ ├── 20260101000005_timescale.sql |
| 207 | │ │ └── 20260101000006_seed_sources.sql |
| 208 | │ └── src/ |
| 209 | │ ├── main.rs ← tokio entry, service wiring |
| 210 | │ ├── config.rs ← env config (figment or config crate) |
| 211 | │ ├── error.rs ← AppError, thiserror |
| 212 | │ ├── state.rs ← AppState (DB pool, NATS, Redis, ntfy handle) |
| 213 | │ ├── model/ |
| 214 | │ │ ├── mod.rs |
| 215 | │ │ ├── source.rs ← Source struct |
| 216 | │ │ ├── event.rs ← Event struct (the normalized shape) |
| 217 | │ │ ├── drop.rs ← DropEvent struct |
| 218 | │ │ └── watchlist.rs |
| 219 | │ ├── store/ ← DB access layer (sqlx) |
| 220 | │ │ ├── mod.rs |
| 221 | │ │ ├── source.rs |
| 222 | │ │ ├── event.rs |
| 223 | │ │ ├── drop.rs |
| 224 | │ │ └── watchlist.rs |
| 225 | │ ├── api/ ← Axum routes |
| 226 | │ │ ├── mod.rs ← router assembly |
| 227 | │ │ ├── events.rs ← GET /api/events, filters |
| 228 | │ │ ├── drops.rs ← GET /api/drops (paginated) |
| 229 | │ │ ├── watchlist.rs ← CRUD |
| 230 | │ │ ├── sources.rs ← GET /api/sources (health) |
| 231 | │ │ ├── stats.rs ← GET /api/stats (dashboard KPIs) |
| 232 | │ │ └── sse.rs ← GET /api/stream (SSE broadcaster) |
| 233 | │ ├── scheduler/ |
| 234 | │ │ ├── mod.rs ← spawns one task per source |
| 235 | │ │ └── poller.rs ← the poll loop (adaptive interval) |
| 236 | │ ├── sources/ ← adapters - Rust-side, for API sources |
| 237 | │ │ ├── mod.rs ← SourceAdapter trait |
| 238 | │ │ ├── ticketmaster.rs |
| 239 | │ │ └── seatgeek.rs |
| 240 | │ ├── scrapers/ ← bridge to Python workers via NATS |
| 241 | │ │ ├── mod.rs ← ScraperBridge (publishes, awaits reply) |
| 242 | │ │ └── req.rs ← ScrapeRequest / ScrapedPayload types |
| 243 | │ ├── detect/ ← change detection |
| 244 | │ │ ├── mod.rs |
| 245 | │ │ ├── hash.rs ← content_hash computation |
| 246 | │ │ └── classify.rs ← NewEvent / StatusFlip / PriceDrop / InventoryDrop |
| 247 | │ ├── notify/ |
| 248 | │ │ ├── mod.rs |
| 249 | │ │ ├── ntfy.rs ← ntfy HTTP publish |
| 250 | │ │ └── filter.rs ← watchlist filter, dedupe, quiet hours |
| 251 | │ ├── bus/ ← NATS wrapper |
| 252 | │ │ ├── mod.rs |
| 253 | │ │ └── subjects.rs ← constants |
| 254 | │ ├── cache/ ← Redis wrapper |
| 255 | │ │ └── mod.rs |
| 256 | │ └── metrics.rs ← Prometheus counter/histogram defs |
| 257 | │ |
| 258 | ├── scrapers/ ← Python workers |
| 259 | │ ├── pyproject.toml ← uv-managed |
| 260 | │ ├── uv.lock |
| 261 | │ ├── Dockerfile |
| 262 | │ ├── celery_app.py ← Celery instance, NATS broker config |
| 263 | │ ├── worker.py ← task dispatcher |
| 264 | │ ├── base.py ← BaseScraper ABC + anti-bot helpers |
| 265 | │ ├── anti_bot/ |
| 266 | │ │ ├── __init__.py |
| 267 | │ │ ├── fingerprints.py ← UA, sec-ch-ua, JA3 rotation |
| 268 | │ │ ├── proxies.py ← proxy pool client |
| 269 | │ │ └── retry.py ← exponential backoff + jitter |
| 270 | │ ├── sources/ |
| 271 | │ │ ├── __init__.py |
| 272 | │ │ ├── fifa.py |
| 273 | │ │ ├── axs.py |
| 274 | │ │ ├── seetickets.py |
| 275 | │ │ ├── stubhub.py |
| 276 | │ │ ├── vividseats.py |
| 277 | │ │ ├── twitter.py ← shock-drop monitor |
| 278 | │ │ ├── nfl.py |
| 279 | │ │ ├── nba.py |
| 280 | │ │ ├── nhl.py |
| 281 | │ │ ├── mlb.py |
| 282 | │ │ ├── atp_wta.py ← tennis |
| 283 | │ │ ├── f1.py |
| 284 | │ │ ├── uefa.py ← Champions League, Euros |
| 285 | │ │ ├── premier_league.py |
| 286 | │ │ └── festivals/ |
| 287 | │ │ ├── __init__.py |
| 288 | │ │ ├── coachella.py |
| 289 | │ │ ├── glastonbury.py |
| 290 | │ │ ├── tomorrowland.py |
| 291 | │ │ ├── edc.py |
| 292 | │ │ └── lollapalooza.py |
| 293 | │ └── tests/ |
| 294 | │ ├── conftest.py ← VCR cassettes for replay |
| 295 | │ └── test_*.py |
| 296 | │ |
| 297 | ├── frontend/ ← Next.js |
| 298 | │ ├── package.json |
| 299 | │ ├── pnpm-lock.yaml |
| 300 | │ ├── next.config.mjs |
| 301 | │ ├── tailwind.config.ts |
| 302 | │ ├── tsconfig.json |
| 303 | │ ├── Dockerfile |
| 304 | │ ├── app/ |
| 305 | │ │ ├── layout.tsx ← root layout, theme provider, fonts |
| 306 | │ │ ├── globals.css ← tailwind directives, CSS vars |
| 307 | │ │ ├── page.tsx ← Dashboard / live drop feed |
| 308 | │ │ ├── drops/ |
| 309 | │ │ │ └── page.tsx ← searchable drop history |
| 310 | │ │ ├── watchlist/ |
| 311 | │ │ │ └── page.tsx ← manage tracked entities |
| 312 | │ │ ├── sources/ |
| 313 | │ │ │ └── page.tsx ← source health grid |
| 314 | │ │ ├── explore/ |
| 315 | │ │ │ └── page.tsx ← Meilisearch-powered browse |
| 316 | │ │ ├── settings/ |
| 317 | │ │ │ └── page.tsx ← ntfy topic, quiet hours |
| 318 | │ │ └── api/ ← route handlers (proxy to backend, optional) |
| 319 | │ ├── components/ |
| 320 | │ │ ├── ui/ ← shadcn primitives (button, card, sheet, etc.) |
| 321 | │ │ ├── layout/ |
| 322 | │ │ │ ├── Sidebar.tsx |
| 323 | │ │ │ ├── Topbar.tsx |
| 324 | │ │ │ └── CommandPalette.tsx ← Cmd+K, fuzzy jump |
| 325 | │ │ ├── dashboard/ |
| 326 | │ │ │ ├── LiveDropFeed.tsx ← SSE consumer, animated cards |
| 327 | │ │ │ ├── DropCard.tsx ← per-drop, shimmer-in animation |
| 328 | │ │ │ ├── StatsRow.tsx ← Tremor KPI cards |
| 329 | │ │ │ ├── SourceGrid.tsx ← health/latency per source |
| 330 | │ │ │ ├── DropHeatmap.tsx ← calendar of drop density |
| 331 | │ │ │ └── PriceSparkline.tsx |
| 332 | │ │ ├── drops/ |
| 333 | │ │ │ ├── DropTable.tsx |
| 334 | │ │ │ └── DropFilters.tsx |
| 335 | │ │ ├── watchlist/ |
| 336 | │ │ │ ├── WatchlistForm.tsx |
| 337 | │ │ │ └── WatchlistItem.tsx |
| 338 | │ │ └── shared/ |
| 339 | │ │ ├── ThemeToggle.tsx |
| 340 | │ │ ├── RelativeTime.tsx |
| 341 | │ │ └── EmptyState.tsx |
| 342 | │ ├── hooks/ |
| 343 | │ │ ├── useDropsStream.ts ← SSE subscription |
| 344 | │ │ ├── useWatchlist.ts |
| 345 | │ │ └── useSources.ts |
| 346 | │ ├── lib/ |
| 347 | │ │ ├── api.ts ← typed fetch wrappers (ky or fetch) |
| 348 | │ │ ├── sse.ts ← SSE client |
| 349 | │ │ ├── types.ts ← TS types mirroring Rust models |
| 350 | │ │ ├── utils.ts ← cn(), formatters |
| 351 | │ │ └── query.ts ← TanStack Query client |
| 352 | │ ├── stores/ |
| 353 | │ │ └── ui.ts ← Zustand: sidebar collapsed, filters |
| 354 | │ └── public/ |
| 355 | │ └── icons/ |
| 356 | │ |
| 357 | ├── infra/ ← homelab deploy artifacts |
| 358 | │ ├── Caddyfile |
| 359 | │ ├── ntfy.server.yml ← ntfy config (auth, attachment size) |
| 360 | │ ├── prometheus.yml |
| 361 | │ ├── grafana/ |
| 362 | │ │ ├── dashboards/ |
| 363 | │ │ │ ├── drophawk-overview.json |
| 364 | │ │ │ └── source-health.json |
| 365 | │ │ └── datasources.yml |
| 366 | │ ├── loki/ |
| 367 | │ │ └── loki-config.yml |
| 368 | │ └── backup/ |
| 369 | │ └── pg-backup.sh ← nightly pg_dump cron |
| 370 | │ |
| 371 | └── scripts/ |
| 372 | ├── seed-watchlist.ts ← bootstrap common artists/teams |
| 373 | ├── healthcheck.sh ← curl all services, exit 0 if green |
| 374 | └── dev-up.sh ← tmux split: backend logs, frontend, nats |
| 375 | ``` |
| 376 | |
| 377 | --- |
| 378 | |
| 379 | ## 5. Backend Services (Rust core) - detailed contracts |
| 380 | |
| 381 | ### 5.1 `SourceAdapter` trait (`backend/src/sources/mod.rs`) |
| 382 | |
| 383 | ```rust |
| 384 | #[async_trait] |
| 385 | pub trait SourceAdapter: Send + Sync { |
| 386 | /// Stable identifier, matches `sources.slug` in DB. |
| 387 | fn id(&self) -> &str; |
| 388 | |
| 389 | /// Human label, e.g. "Ticketmaster Discovery API". |
| 390 | fn name(&self) -> &str; |
| 391 | |
| 392 | /// Category for UI grouping: "primary", "resale", "sports", "festival". |
| 393 | fn category(&self) -> SourceCategory; |
| 394 | |
| 395 | /// Poll once. Returns a vec of normalized `Event` candidates. |
| 396 | /// Errors must be retried by the scheduler's backoff, not propagated up. |
| 397 | async fn poll(&self, ctx: &PollContext) -> Result<Vec<EventCandidate>, SourceError>; |
| 398 | |
| 399 | /// Adaptive interval hint based on `ctx.now` and any known drop window. |
| 400 | /// Default: 5 minutes. Tighten to 10s inside ±15min of known drop. |
| 401 | fn next_poll_hint(&self, ctx: &PollContext) -> Duration { |
| 402 | Duration::from_secs(300) |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | pub struct PollContext { |
| 407 | pub now: DateTime<Utc>, |
| 408 | pub http: reqwest::Client, // shared, pre-configured (TLS impersonation) |
| 409 | pub scraper_bridge: ScraperHandle, // for sources that need Python |
| 410 | pub cache: RedisHandle, |
| 411 | pub metrics: MetricsHandle, |
| 412 | } |
| 413 | |
| 414 | pub struct EventCandidate { |
| 415 | pub source_id: String, |
| 416 | pub source_event_id: String, // ID from the source's namespace |
| 417 | pub title: String, |
| 418 | pub subtitle: Option<String>, // tour name, support act, etc. |
| 419 | pub category: EventCategory, // Concert, Sports, Festival, Theater, Other |
| 420 | pub sport: Option<Sport>, // NFL/NBA/NHL/MLB/Tennis/F1/Soccer/... |
| 421 | pub starts_at: Option<DateTime<Utc>>, |
| 422 | pub venue_name: Option<String>, |
| 423 | pub city: Option<String>, |
| 424 | pub country: Option<String>, |
| 425 | pub latitude: Option<f64>, |
| 426 | pub longitude: Option<f64>, |
| 427 | pub image_url: Option<String>, |
| 428 | pub on_sale_at: Option<DateTime<Utc>>, // announced sale start |
| 429 | pub presale_on_sale_at: Option<DateTime<Utc>>, |
| 430 | pub status: SaleStatus, // Announced, Presale, OnSale, SoldOut, Cancelled |
| 431 | pub price_min: Option<i64>, // cents (USD or source currency) |
| 432 | pub price_max: Option<i64>, |
| 433 | pub currency: Option<String>, // ISO 4217 |
| 434 | pub inventory_remaining: Option<u32>, |
| 435 | pub buy_url: String, // canonical, with affiliate tags where allowed |
| 436 | pub raw: serde_json::Value, // full original payload, for debug |
| 437 | } |
| 438 | ``` |
| 439 | |
| 440 | ### 5.2 Scheduler (`backend/src/scheduler/`) |
| 441 | |
| 442 | - One `tokio::task` per source. |
| 443 | - Loop body: `sleep(next_poll_hint) → poll() → detect::diff() → store + emit`. |
| 444 | - On error: exponential backoff capped at 10 minutes; emit `source.unhealthy` after 3 consecutive failures. |
| 445 | - Use `tokio::select!` to also listen on a `shutdown` channel for clean exit. |
| 446 | - Persist `last_poll_at`, `last_success_at`, `last_error`, `consecutive_failures` in `sources` table. |
| 447 | |
| 448 | ### 5.3 Change detector (`backend/src/detect/`) |
| 449 | |
| 450 | - `content_hash` = SHA-256 of canonical JSON of `(source_event_id, status, on_sale_at, presale_on_sale_at, price_min, price_max, inventory_remaining)`. Fields are normalized (None → null, datetimes in RFC3339 UTC, prices in minor units). Render with `serde_json::to_string` after sorting keys (BTreeMap). |
| 451 | - Diff outcomes → emit different NATS subjects + different SSE event types: |
| 452 | - **New** (source_event_id never seen) → `event.new` → SSE `drop.new` |
| 453 | - **StatusFlip** (status changed) → `event.status_changed` → SSE `drop.status` |
| 454 | - Most-wanted transition: `Announced → OnSale` |
| 455 | - **PriceDrop** (price_min decreased ≥10%) → `event.price_drop` → SSE `drop.price` |
| 456 | - **InventoryDrop** (inventory_remaining decreased ≥25%) → `event.inventory` → SSE `drop.inventory` |
| 457 | - **Unchanged** (hash same) → no-op |
| 458 | |
| 459 | ### 5.4 SSE broadcaster (`backend/src/api/sse.rs`) |
| 460 | |
| 461 | - Maintain a `tokio::sync::broadcast::Sender<ServerEvent>` in `AppState`. |
| 462 | - Each `/api/stream` connection spawns a task that subscribes and writes `event: <type>\ndata: <json>\n\n` lines. |
| 463 | - Heartbeat every 15s (comment line `:\n\n`) to keep proxies from closing idle conns. |
| 464 | - Backpressure: if a client's receiver falls >50 events behind, drop oldest. |
| 465 | |
| 466 | ### 5.5 Notification pipeline (`backend/src/notify/`) |
| 467 | |
| 468 | Order of operations on a new `DropEvent`: |
| 469 | 1. **Filter** against watchlist (`notify::filter`): |
| 470 | - Does the event match any watchlist rule? (artist/team/venue/sport/category keywords, regex) |
| 471 | - Inside quiet hours? (configurable; default: never quiet - operator wants to wake up) |
| 472 | - Already notified for this `source_event_id` in last 10 min? (dedupe) |
| 473 | 2. If pass → build notification payload (title, body with markdown, click URL = `buy_url`, tags = `[source_id, category]`). |
| 474 | 3. POST to ntfy at `http://ntfy:80/internal-{topic}` with `Title`, `Message`, `Click`, `Tags`, `Priority` (elevated for status flips), `Actions` (a "Buy" button via ntfy action URLs). |
| 475 | 4. Insert row into `notifications_log`. |
| 476 | |
| 477 | ### 5.6 REST API surface (all under `/api`) |
| 478 | |
| 479 | | Method | Path | Purpose | |
| 480 | |---|---|---| |
| 481 | | GET | `/api/events` | Paginated, filterable (category, sport, source, status, price range, date range). Default sort: `on_sale_at asc nulls last`. | |
| 482 | | GET | `/api/events/:id` | Single event with price history. | |
| 483 | | GET | `/api/drops` | Drop history, paginated, filterable by type/category/source. | |
| 484 | | GET | `/api/drops/stream` | **SSE** - live drops. | |
| 485 | | GET | `/api/watchlist` | List rules. | |
| 486 | | POST | `/api/watchlist` | Create rule (validated by Zod-equivalent: serde + validator crate). | |
| 487 | | PATCH | `/api/watchlist/:id` | Update rule (toggle active, edit pattern). | |
| 488 | | DELETE | `/api/watchlist/:id` | Delete rule. | |
| 489 | | GET | `/api/sources` | Source health grid (status, latency p50/p95, last_success_at, consecutive_failures). | |
| 490 | | GET | `/api/stats` | Dashboard KPIs (drops last 24h, sources healthy, watchlist matches, etc.). | |
| 491 | | GET | `/api/explore?q=` | Proxy to Meilisearch for fuzzy event/artist search. | |
| 492 | | GET | `/api/health` | Liveness (200). | |
| 493 | | GET | `/api/ready` | Readiness (DB + NATS + Redis reachable). | |
| 494 | |
| 495 | **Response shape (consistent):** |
| 496 | ```json |
| 497 | { "data": [...], "meta": { "page": 1, "per_page": 50, "total": 1234 } } |
| 498 | ``` |
| 499 | Errors: `{ "error": { "code": "STRING", "message": "human-readable" } }` with appropriate HTTP status. |
| 500 | |
| 501 | --- |
| 502 | |
| 503 | ## 6. Scraper Workers (Python) - detailed contracts |
| 504 | |
| 505 | ### 6.1 Worker model |
| 506 | - One Celery worker process per Docker replica. Replica count per source is configurable. |
| 507 | - Tasks are published by Rust via NATS → a small bridge (the Celery worker subscribes to NATS and enqueues a Celery task) OR - simpler - Rust publishes scrape_request to NATS, a single Python "dispatcher" subscribes and calls Celery tasks; results come back via NATS reply subject. |
| 508 | - **Preferred pattern (simpler):** Use NATS directly as the Celery-less RPC bus. The Python process is a long-running asyncio loop subscribing to `scrape.>` subjects; each message is a `ScrapeRequest`, each reply is a `ScrapedPayload`. This avoids Celery's complexity for v1. (Migrate to Celery in v2 if scale demands.) |
| 509 | |
| 510 | **Decision: use asyncio + NATS, NOT Celery, for v1.** Simpler, fewer services, faster cold-path. |
| 511 | |
| 512 | ### 6.2 `BaseScraper` (`scrapers/base.py`) |
| 513 | |
| 514 | ```python |
| 515 | from typing import Protocol, Sequence |
| 516 | from .types import ScrapeRequest, ScrapedPayload |
| 517 | |
| 518 | class BaseScraper(Protocol): |
| 519 | source_id: str # matches Rust SourceAdapter id |
| 520 | |
| 521 | async def scrape(self, req: ScrapeRequest) -> ScrapedPayload: ... |
| 522 | |
| 523 | async def health(self) -> ScraperHealth: ... |
| 524 | ``` |
| 525 | |
| 526 | - Each scraper lives in `scrapers/sources/<name>.py` and is registered in a registry keyed by `source_id`. |
| 527 | - The dispatcher reads NATS messages, looks up the scraper, calls `scrape()`, publishes reply. |
| 528 | - Use `httpx.AsyncClient` for HTML sources; `playwright.async_api` for JS-rendered sources (lazy-import per source to keep memory down). |
| 529 | |
| 530 | ### 6.3 Anti-bot module (`scrapers/anti_bot/`) |
| 531 | - `fingerprints.py`: rotating pool of (User-Agent, sec-ch-ua, accept-language, JA3) tuples, sourced from a checked-in JSON. Update monthly. |
| 532 | - `proxies.py`: optional proxy rotation. For v1, support a static list of `http://user:pass@host:port` from env. (Future: integrate with rotating residential proxy.) |
| 533 | - `retry.py`: exponential backoff with jitter; honor `Retry-After` on 429/503; circuit-open after 5 consecutive failures in 60s. |
| 534 | - `playwright_stealth`: use `playwright-stealth` plugin to defeat basic bot detection. |
| 535 | - **Never** solve CAPTCHAs. If a source starts requiring CAPTCHA, mark it unhealthy and alert the operator. |
| 536 | |
| 537 | ### 6.4 ScrapedPayload schema (must match Rust) |
| 538 | |
| 539 | ```python |
| 540 | @dataclass |
| 541 | class ScrapedPayload: |
| 542 | source_id: str |
| 543 | fetched_at: str # RFC3339 UTC |
| 544 | ok: bool |
| 545 | error: Optional[str] |
| 546 | events: List[EventCandidateDict] # same shape as Rust EventCandidate |
| 547 | raw_html_sha256: Optional[str] # for fast "page unchanged" skip |
| 548 | latency_ms: int |
| 549 | ``` |
| 550 | |
| 551 | --- |
| 552 | |
| 553 | ## 7. Source Adapters - full spec for v1 + interface for v2 |
| 554 | |
| 555 | Each adapter section specifies: auth, endpoint, rate limit, parser approach, gotchas. |
| 556 | |
| 557 | ### 7.1 Ticketmaster Discovery API ✅ v1 (Rust) |
| 558 | - **Auth:** API key via `apikey` query param. Free, register at `developer.ticketmaster.com`. |
| 559 | - **Base:** `https://app.ticketmaster.com/discovery/v2/` |
| 560 | - **Endpoints:** |
| 561 | - `GET /events.json?keyword={artist}&countryCode={CC}&size=200&page={n}` |
| 562 | - `GET /events.json?segmentName=Music|Sports|Arts&countryCode={CC}` |
| 563 | - `GET /events.json?attractionId={id}` (after resolving attraction IDs) |
| 564 | - **Rate limit:** 5000 req/day per key. With multiple keys (allowed up to 5 free keys/account), rotate. |
| 565 | - **Parsing notes:** |
| 566 | - `sales.public.startDateTime` → `on_sale_at` |
| 567 | - `sales.presale.startDateTime` → `presale_on_sale_at` |
| 568 | - `dates.start.dateTime` → `starts_at` |
| 569 | - `_embedded.venues[0]` → venue, city, country, lat/lon |
| 570 | - `priceRanges[0].min`/`.max` → prices (already in major units, multiply by 100 for cents) |
| 571 | - `url` → `buy_url` |
| 572 | - Status inference: if `on_sale_at` is past and no `sold_out` flag → `OnSale`; if `dates.status.code == "sold"` → `SoldOut`. |
| 573 | - **Gotchas:** |
| 574 | - Pagination: cap `size=200`, follow `page.total` to know how many pages. |
| 575 | - Some events have null `priceRanges` - leave prices as None. |
| 576 | - `_embedded.attractions` gives canonical artist/team IDs - store these for watchlist resolution. |
| 577 | |
| 578 | ### 7.2 SeatGeek API ✅ v1 (Rust) |
| 579 | - **Auth:** Client ID via `client_id` query param. Free at `seatgeek.com/developers`. |
| 580 | - **Base:** `https://api.seatgeek.com/2/` |
| 581 | - **Endpoints:** |
| 582 | - `GET /events?client_id={id}&performers.slug={slug}&type={concert|sports|...}` |
| 583 | - `GET /events?client_id={id}&listing_count.gt=0&q={query}` |
| 584 | - `GET /performers?client_id={id}&slug={slug}` to resolve IDs |
| 585 | - **Rate limit:** not officially stated; stay under 1 req/sec per endpoint. |
| 586 | - **Parsing notes:** |
| 587 | - `time_utc` → `starts_at` |
| 588 | - `score` → relevance score (use to rank) |
| 589 | - `stats.lowest_price` / `.highest_price` / `.average_price` → prices (in dollars → cents) |
| 590 | - `url` → `buy_url` (SeatGeek affiliate handles resale listings too) |
| 591 | - `venue.city`, `venue.country`, `venue.location.lat`/`.lon` |
| 592 | - Status inference: `announced` field; `visible_at_utc` → `on_sale_at`. |
| 593 | - **Gotchas:** |
| 594 | - SeatGeek aggregates resale - `listing_count` flips >0 when tickets hit secondary market. |
| 595 | |
| 596 | ### 7.3 FIFA World Cup portal ✅ v1 (Python scraper) |
| 597 | - **URL:** `https://tickets.fifa.com` (rotates per tournament - make this configurable). |
| 598 | - **Approach:** Playwright headless. Render the sales phases page, extract: |
| 599 | - Active phase name (Random Selection Draw / First Come First Served / Last Minute) |
| 600 | - Phase window (`start`, `end`) |
| 601 | - Per-match availability indicator (often color-coded) |
| 602 | - **Detection:** When phase transitions from "closed/coming" → "open" → emit drop. When a match goes from "sold out" → "available" (last-minute sales) → emit `drop.status`. |
| 603 | - **Gotchas:** |
| 604 | - FIFA uses heavy anti-bot (Cloudflare). Use `playwright-stealth` + realistic TLS (curl_cffi for any XHR you can identify). |
| 605 | - The site is localized; parameterize language to `en`. |
| 606 | - Phase dates are announced but actual opening minute varies - poll every 30s within ±1h of announced start. |
| 607 | - **Legal:** Display only. Link to official portal for purchase. Do not attempt to auto-submit applications. |
| 608 | |
| 609 | ### 7.4 AXS ✅ v2 (Python scraper) |
| 610 | - `https://www.axs.com/events` - JSON behind the search endpoint; reverse-engineer XHR. Use `curl_cffi` with browser impersonation. |
| 611 | - Look for `onSaleDate` field. |
| 612 | |
| 613 | ### 7.5 See Tickets ✅ v2 (Python scraper) |
| 614 | - `https://www.seetickets.com/events` - server-rendered HTML; use selectolax. |
| 615 | |
| 616 | ### 7.6 StubHub ✅ v2 (Python scraper, resale) |
| 617 | - Target: detect price drops. StubHub has GraphQL; reverse-engineer carefully. |
| 618 | - Track `lowestPrice` per event; emit `drop.price` when it drops ≥10%. |
| 619 | |
| 620 | ### 7.7 Vivid Seats ✅ v2 (Python scraper, resale) |
| 621 | - HTML scrape; track lowest listing price. |
| 622 | |
| 623 | ### 7.8 Twitter/X shock-drop monitor ✅ v2 (Python) |
| 624 | - Use the official X API v2 filtered stream (paid) OR scrape via Nitter instances (fragile) OR RSS of artist/team accounts via rsshub. |
| 625 | - Track a curated list of accounts (configured in watchlist with `kind: twitter_account`). |
| 626 | - Pattern match tweets for: "tickets", "on sale", "presale code", "shock drop", "available now", "link in bio". |
| 627 | - Emit `drop.announcement` with the tweet URL. |
| 628 | |
| 629 | ### 7.9 NFL ✅ v1 via Ticketmaster segment + SeatGeek |
| 630 | - Use Ticketmaster `segmentName=Sports&sport=NFL` (or genre ID). Cross-reference with SeatGeek `performers.slug` for known teams. |
| 631 | |
| 632 | ### 7.10 NBA ✅ v1 via Ticketmaster + SeatGeek - same pattern as NFL. |
| 633 | |
| 634 | ### 7.11 NHL ✅ v1 - same pattern. |
| 635 | |
| 636 | ### 7.12 MLB ✅ v1 - same pattern (note: MLB uses Tickets.com / SeatGeek heavily). |
| 637 | |
| 638 | ### 7.13 ATP/WTA Tennis ✅ v2 |
| 639 | - Scrape `https://www.atptour.com/en/tournaments` and `https://www.wtatennis.com/tournaments`. |
| 640 | - Cross-reference Ticketmaster for slams (US Open, Wimbledon via official + Ticketmaster). |
| 641 | |
| 642 | ### 7.14 F1 ✅ v2 |
| 643 | - Scrape `https://www.formula1.com/en/tickets.html` - Playwright. |
| 644 | - Grand Prix tickets are sold in waves; detect wave open. |
| 645 | |
| 646 | ### 7.15 UEFA (Champions League, Europa, Euros) ✅ v2 |
| 647 | - `https://www.uefa.com/tickets/` - Playwright. |
| 648 | - UEFA uses ballot + FCFS phases. |
| 649 | |
| 650 | ### 7.16 Premier League ✅ v2 |
| 651 | - Each club has its own ticket portal. v2 implements a per-club adapter interface; v2 ships with the Big 6 (Man City, Man Utd, Liverpool, Chelsea, Arsenal, Tottenham). |
| 652 | |
| 653 | ### 7.17 Festivals ✅ v2-v3 (one adapter each) |
| 654 | - Coachella, Glastonbury, Tomorrowland, EDC, Lollapalooza, Austin City Limits, Bonnaroo, Rolling Loud, Ultra, Boom, Burning Man (lottery). |
| 655 | - Each festival has unique sales mechanics (lottery, registration, FCFS waves). Each adapter documents its phase model. |
| 656 | |
| 657 | --- |
| 658 | |
| 659 | ## 8. Data Model & Schema (PostgreSQL + TimescaleDB) |
| 660 | |
| 661 | ### 8.1 Tables |
| 662 | |
| 663 | ```sql |
| 664 | -- sources: one row per registered source adapter |
| 665 | CREATE TABLE sources ( |
| 666 | id TEXT PRIMARY KEY, -- e.g. "ticketmaster", "fifa" |
| 667 | name TEXT NOT NULL, |
| 668 | category TEXT NOT NULL, -- primary|resale|sports|festival|social |
| 669 | enabled BOOLEAN NOT NULL DEFAULT TRUE, |
| 670 | poll_interval_s INTEGER NOT NULL DEFAULT 300, |
| 671 | tight_interval_s INTEGER NOT NULL DEFAULT 10, |
| 672 | last_poll_at TIMESTAMPTZ, |
| 673 | last_success_at TIMESTAMPTZ, |
| 674 | last_error TEXT, |
| 675 | consecutive_failures INTEGER NOT NULL DEFAULT 0, |
| 676 | config JSONB NOT NULL DEFAULT '{}', -- per-source: api keys live here? NO - env only |
| 677 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() |
| 678 | ); |
| 679 | |
| 680 | -- events: normalized events across all sources |
| 681 | CREATE TABLE events ( |
| 682 | id BIGSERIAL PRIMARY KEY, |
| 683 | source_id TEXT NOT NULL REFERENCES sources(id), |
| 684 | source_event_id TEXT NOT NULL, -- ID in source's namespace |
| 685 | title TEXT NOT NULL, |
| 686 | subtitle TEXT, |
| 687 | category TEXT NOT NULL, -- Concert|Sports|Festival|Theater|Other |
| 688 | sport TEXT, -- NFL|NBA|NHL|MLB|Tennis|F1|Soccer|... |
| 689 | starts_at TIMESTAMPTZ, |
| 690 | venue_name TEXT, |
| 691 | city TEXT, |
| 692 | country TEXT, |
| 693 | latitude DOUBLE PRECISION, |
| 694 | longitude DOUBLE PRECISION, |
| 695 | image_url TEXT, |
| 696 | on_sale_at TIMESTAMPTZ, |
| 697 | presale_on_sale_at TIMESTAMPTZ, |
| 698 | status TEXT NOT NULL, -- Announced|Presale|OnSale|SoldOut|Cancelled |
| 699 | price_min INTEGER, -- cents |
| 700 | price_max INTEGER, |
| 701 | currency TEXT, |
| 702 | inventory_remaining INTEGER, |
| 703 | buy_url TEXT NOT NULL, |
| 704 | raw JSONB, |
| 705 | content_hash TEXT NOT NULL, -- SHA-256, see §5.3 |
| 706 | first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| 707 | updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| 708 | UNIQUE (source_id, source_event_id) |
| 709 | ); |
| 710 | CREATE INDEX idx_events_status ON events(status); |
| 711 | CREATE INDEX idx_events_on_sale ON events(on_sale_at); |
| 712 | CREATE INDEX idx_events_category ON events(category); |
| 713 | CREATE INDEX idx_events_starts_at ON events(starts_at); |
| 714 | CREATE INDEX idx_events_title_trgm ON events USING gin (title gin_trgm_ops); -- needs pg_trgm |
| 715 | |
| 716 | -- drops: every detected change. Append-only. |
| 717 | CREATE TABLE drops ( |
| 718 | id BIGSERIAL PRIMARY KEY, |
| 719 | event_id BIGINT NOT NULL REFERENCES events(id), |
| 720 | drop_type TEXT NOT NULL, -- new|status|price|inventory|announcement |
| 721 | from_state JSONB, -- prior values |
| 722 | to_state JSONB, -- new values |
| 723 | detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| 724 | notified BOOLEAN NOT NULL DEFAULT FALSE, |
| 725 | notified_at TIMESTAMPTZ |
| 726 | ); |
| 727 | CREATE INDEX idx_drops_detected ON drops(detected_at DESC); |
| 728 | CREATE INDEX idx_drops_event ON drops(event_id); |
| 729 | CREATE INDEX idx_drops_type ON drops(drop_type); |
| 730 | |
| 731 | -- watchlist: user-defined rules |
| 732 | CREATE TABLE watchlist ( |
| 733 | id BIGSERIAL PRIMARY KEY, |
| 734 | kind TEXT NOT NULL, -- artist|team|venue|sport|category|keyword|twitter_account |
| 735 | pattern TEXT NOT NULL, -- regex or glob |
| 736 | label TEXT NOT NULL, -- friendly name |
| 737 | active BOOLEAN NOT NULL DEFAULT TRUE, |
| 738 | priority TEXT NOT NULL DEFAULT 'normal', -- low|normal|high|critical (drives ntfy priority) |
| 739 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() |
| 740 | ); |
| 741 | CREATE INDEX idx_watchlist_active ON watchlist(active); |
| 742 | |
| 743 | -- notifications_log: every ntfy push |
| 744 | CREATE TABLE notifications_log ( |
| 745 | id BIGSERIAL PRIMARY KEY, |
| 746 | drop_id BIGINT REFERENCES drops(id), |
| 747 | channel TEXT NOT NULL, -- ntfy|telegram|email |
| 748 | title TEXT NOT NULL, |
| 749 | body TEXT NOT NULL, |
| 750 | priority TEXT NOT NULL, |
| 751 | sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| 752 | success BOOLEAN NOT NULL, |
| 753 | response TEXT |
| 754 | ); |
| 755 | CREATE INDEX idx_notif_sent ON notifications_log(sent_at DESC); |
| 756 | |
| 757 | -- price_history (TimescaleDB hypertable) |
| 758 | CREATE TABLE price_history ( |
| 759 | event_id BIGINT NOT NULL REFERENCES events(id), |
| 760 | observed_at TIMESTAMPTZ NOT NULL, |
| 761 | price_min INTEGER, |
| 762 | price_max INTEGER, |
| 763 | currency TEXT, |
| 764 | source TEXT -- 'primary' | 'stubhub' | 'vividseats' | ... |
| 765 | ); |
| 766 | SELECT create_hypertable('price_history', 'observed_at'); |
| 767 | CREATE INDEX idx_price_event_time ON price_history(event_id, observed_at DESC); |
| 768 | |
| 769 | -- source_health (TimescaleDB hypertable) |
| 770 | CREATE TABLE source_health ( |
| 771 | source_id TEXT NOT NULL, |
| 772 | observed_at TIMESTAMPTZ NOT NULL, |
| 773 | ok BOOLEAN NOT NULL, |
| 774 | latency_ms INTEGER NOT NULL, |
| 775 | events_returned INTEGER NOT NULL, |
| 776 | error TEXT |
| 777 | ); |
| 778 | SELECT create_hypertable('source_health', 'observed_at'); |
| 779 | CREATE INDEX idx_health_source_time ON source_health(source_id, observed_at DESC); |
| 780 | ``` |
| 781 | |
| 782 | ### 8.2 Extensions required in migrations |
| 783 | ```sql |
| 784 | CREATE EXTENSION IF NOT EXISTS pg_trgm; |
| 785 | CREATE EXTENSION IF NOT EXISTS timescaledb; |
| 786 | ``` |
| 787 | |
| 788 | ### 8.3 Seed data |
| 789 | - Seed `sources` rows for every adapter listed in §7 (with `enabled = TRUE` for v1 sources, `FALSE` for v2-v3). |
| 790 | - Seed a starter `watchlist` with a few example rules the operator can edit (e.g. `"Taylor Swift"`, `"World Cup"`, `"Super Bowl"`, `"Wimbledon"`). |
| 791 | |
| 792 | --- |
| 793 | |
| 794 | ## 9. Notification System (ntfy, self-hosted, free iPhone push) |
| 795 | |
| 796 | ### 9.1 Why ntfy |
| 797 | - Free, self-hosted, no third-party relay. |
| 798 | - iOS app supports instant push via APNs (ntfy's public APNs key - no Apple Developer account needed by you). |
| 799 | - HTTP-only API: `POST http://ntfy.your_homelab/topic { Title, Message, Click, Priority, Tags, Actions }`. |
| 800 | |
| 801 | ### 9.2 Setup |
| 802 | - Run `ntfy` container in `docker-compose.yml`. |
| 803 | - Topic naming: `drophawk-drops` (high-priority), `drophawk-info` (low). |
| 804 | - **Auth:** generate an access token; persist in `.env` as `NTFY_TOKEN`. Subscribe the iOS app with the token. |
| 805 | - iOS app: add subscription to `https://ntfy.your_homelab/drophawk-drops`. |
| 806 | |
| 807 | ### 9.3 Notification payload spec |
| 808 | ``` |
| 809 | POST /drophawk-drops HTTP/1.1 |
| 810 | Host: ntfy |
| 811 | Authorization: Bearer {NTFY_TOKEN} |
| 812 | Title: 🎟️ On Sale: Taylor Swift - Miami |
| 813 | Tags: ticket,concert,concerts,ticketmaster |
| 814 | Priority: high |
| 815 | Click: https://www.ticketmaster.com/event/... |
| 816 | Actions: view, Buy now, https://www.ticketmaster.com/event/... |
| 817 | |
| 818 | Eras Tour floor seats from $49.50. 15,000+ remaining. |
| 819 | Venue: Hard Rock Stadium · Jun 22 2026 |
| 820 | ``` |
| 821 | |
| 822 | ### 9.4 Priority mapping |
| 823 | - `drop.new` for a watchlist match → `urgent` |
| 824 | - `drop.status` (Announced→OnSale) → `high` |
| 825 | - `drop.price` ≥25% drop → `high` |
| 826 | - `drop.price` <25% drop → `default` |
| 827 | - `drop.inventory` → `default` |
| 828 | - Non-watchlist events → not notified (still visible in UI). |
| 829 | |
| 830 | ### 9.5 Fallback channels (optional, future) |
| 831 | - Telegram bot (set `TELEGRAM_BOT_TOKEN` to enable). |
| 832 | - Email via Postfix relay. |
| 833 | |
| 834 | --- |
| 835 | |
| 836 | ## 10. Frontend - detailed UI spec |
| 837 | |
| 838 | ### 10.1 Design language |
| 839 | - **Theme:** Dark by default (light available). Use OKLCH colors. |
| 840 | - **Type:** Inter for UI, JetBrains Mono for numbers/timestamps. |
| 841 | - **Density:** High. Card-based grid for drops, no whitespace waste. |
| 842 | - **Motion:** Spring physics on drop cards (slide-in + shimmer), gentle parallax on stats. Respect `prefers-reduced-motion`. |
| 843 | - **Identity accent:** electric teal (#14b8a6) + amber for warnings + red for sold out. |
| 844 | |
| 845 | ### 10.2 Layout |
| 846 | - **Sidebar (left, collapsible):** Dashboard, Live Drops, Explore, Watchlist, Sources, Settings. |
| 847 | - **Topbar:** global Cmd+K palette, theme toggle, source health dot, "watchlist active" badge. |
| 848 | - **Main area:** per-route content. |
| 849 | |
| 850 | ### 10.3 Routes & components |
| 851 | |
| 852 | **`/` (Dashboard) - the wow page** |
| 853 | - Row 1: Tremor KPI cards - Drops (24h), Active Sources, Watchlist Matches, Avg Detection Latency. |
| 854 | - Row 2 (3/4 width): **LiveDropFeed** - SSE-driven, animated cards. Each `DropCard` shows: source icon, title, status pill, price range, time-since-detected (RelativeTime), "Buy" button (opens `buy_url` in new tab), category tag. New cards slide in from top; flash accent border for 2s. |
| 855 | - Row 2 (1/4 width): **SourceGrid** - health dots (green/amber/red), latency sparkline per source, click → sources page filtered. |
| 856 | - Row 3: **DropHeatmap** - calendar of last 90 days, color intensity = drop count per day. Click a day → drops page filtered. |
| 857 | - Row 4: **Top Drops** - table of highest-signal drops this week. |
| 858 | |
| 859 | **`/drops` - Drop history** |
| 860 | - Filters: type (new/status/price/inventory), category, source, date range, search (Meilisearch-backed). |
| 861 | - Virtualized table (TanStack Virtual) - handle 100k+ rows. |
| 862 | - Each row expandable → event detail + price history sparkline. |
| 863 | |
| 864 | **`/explore` - Browse** |
| 865 | - Search box (debounced 200ms → `/api/explore?q=`). |
| 866 | - Results grouped by category. Click → modal with detail + "Add to watchlist" CTA. |
| 867 | |
| 868 | **`/watchlist` - Manage** |
| 869 | - Form (React Hook Form + Zod): kind (select), pattern (input), label, priority. |
| 870 | - List of rules with toggle, edit, delete, "matched N events" count. |
| 871 | - Bulk import: paste a list of artist/team names → creates `keyword` rules in batch. |
| 872 | |
| 873 | **`/sources` - Source health** |
| 874 | - Grid of cards, one per source. Shows: status, latency p50/p95 (last hour), events seen (24h), last error, poll interval. |
| 875 | - Toggle enable/disable per source (writes to `sources.enabled`). |
| 876 | |
| 877 | **`/settings` - Settings** |
| 878 | - ntfy topic + token (test button sends a push). |
| 879 | - Quiet hours (time range, days of week). |
| 880 | - Theme + density preferences (persisted in localStorage). |
| 881 | |
| 882 | ### 10.4 SSE handling (`hooks/useDropsStream.ts`) |
| 883 | - Open EventSource on `/api/drops/stream`. |
| 884 | - On message: parse, push to Zustand store (cap buffer at 200, FIFO). |
| 885 | - On error: close, exponential backoff reconnect (max 30s). |
| 886 | - On `drop.new` for a watchlist match: trigger browser notification (request permission on first dashboard mount). |
| 887 | |
| 888 | ### 10.5 Accessibility |
| 889 | - All interactive elements keyboard-navigable. |
| 890 | - ARIA labels on icon-only buttons. |
| 891 | - Color contrast ≥ 4.5:1. |
| 892 | - Status pills have text, not color alone. |
| 893 | |
| 894 | ### 10.6 PWA |
| 895 | - `manifest.json` + service worker (via `next-pwa` or `serwist`). |
| 896 | - Installable to home screen; works offline for cached views. |
| 897 | |
| 898 | --- |
| 899 | |
| 900 | ## 11. Polling & Anti-Ban Strategy |
| 901 | |
| 902 | ### 11.1 Adaptive intervals (per-source, in Rust scheduler) |
| 903 | - **Baseline:** `poll_interval_s` from `sources` table (default 300s = 5min). |
| 904 | - **Tight window:** from `T-15min` to `T+15min` around a known `on_sale_at`, switch to `tight_interval_s` (default 10s). |
| 905 | - **Pre-announcement:** for sources where the next drop time is unknown (e.g. FIFA last-minute sales), stay at 30s baseline. |
| 906 | - Source adapters can override `next_poll_hint` for source-specific intelligence (e.g. F1 wave patterns). |
| 907 | |
| 908 | ### 11.2 Polite scraping rules |
| 909 | - Honor `robots.txt`. If a path is disallowed, do not scrape it; mark source as `disabled` and alert. |
| 910 | - Identify DropHawk with a stable User-Agent: `DropHawk/1.0 (+https://your-homelab/drophawk)`. Do not impersonate browsers for *API* sources (only for sources that require it, e.g. FIFA behind Cloudflare). |
| 911 | - Default `Accept-Encoding: gzip, br`. |
| 912 | - Cache aggressively: if `raw_html_sha256` unchanged, skip parsing entirely. |
| 913 | |
| 914 | ### 11.3 Rate limiting (per-source token bucket in Redis) |
| 915 | - Key: `ratelimit:{source_id}`. Refill rate from source config. |
| 916 | - Acquire token before each poll. If empty, wait (don't skip). |
| 917 | - Honors 429s: on 429 with `Retry-After`, set bucket to empty for that duration. |
| 918 | |
| 919 | ### 11.4 Circuit breaker (per source) |
| 920 | - 3 consecutive failures → status `degraded` (still try, but at half rate). |
| 921 | - 10 consecutive failures → status `down` (pause, alert operator). |
| 922 | - First success after degraded/down → status `healthy`. |
| 923 | |
| 924 | ### 11.5 Anti-bot resilience (Python scrapers) |
| 925 | - TLS fingerprint rotation via `curl_cffi` (impersonates Chrome 120, Firefox 121, Safari 17). |
| 926 | - Header rotation: matching (UA + sec-ch-ua + accept-language) tuples. |
| 927 | - Optional residential proxy pool (env: `PROXY_POOL_URL`). For v1, no proxy - start clean. |
| 928 | - `playwright-stealth` for JS-rendered sources. |
| 929 | - **Never** attempt CAPTCHA bypass. Fail loudly instead. |
| 930 | |
| 931 | ### 11.6 Distributed polling |
| 932 | - v1 is single-instance (one Rust process). The architecture supports horizontal scaling later by sharding sources across instances via a Redis lease (`source:{id}:owner` with TTL). |
| 933 | |
| 934 | --- |
| 935 | |
| 936 | ## 12. Real-Time Pipeline |
| 937 | |
| 938 | ``` |
| 939 | [source poll] → Rust detect::diff → Postgres write → |
| 940 | ├→ NATS publish "drop.{type}" (durable, JetStream) |
| 941 | ├→ SSE broadcast to browsers (in-process broadcast channel) |
| 942 | └→ ntfy publish (if watchlist match) |
| 943 | ``` |
| 944 | |
| 945 | - JetStream retention: `drop.*` subjects retained 7 days (for replay on restart). |
| 946 | - On Rust startup: replay any unacked drops newer than `MAX(last_browser_seen, now-1h)`. (Browsers also reconnect via SSE and resync from `/api/drops?since=`.) |
| 947 | |
| 948 | --- |
| 949 | |
| 950 | ## 13. Docker Compose Layout |
| 951 | |
| 952 | ### 13.1 Services (`docker-compose.yml`) |
| 953 | |
| 954 | | Service | Image / Build | Ports | Depends on | |
| 955 | |---|---|---|---| |
| 956 | | `caddy` | `caddy:2.7` | 80, 443 | `frontend`, `backend`, `ntfy` | |
| 957 | | `frontend` | build `./frontend` | internal | `backend` | |
| 958 | | `backend` | build `./backend` | internal | `postgres`, `redis`, `nats` | |
| 959 | | `scrapers` | build `./scrapers` | internal | `nats`, `redis` | |
| 960 | | `postgres` | `postgres:16` + Timescale | internal | - | |
| 961 | | `redis` | `redis:7.2-alpine` | internal | - | |
| 962 | | `nats` | `nats:2.10` (with JetStream) | internal | - | |
| 963 | | `meilisearch` | `getmeili/meilisearch:v1.6` | internal | - | |
| 964 | | `ntfy` | `binwiederhier/ntfy:2` | internal | - | |
| 965 | | `minio` | `minio/minio` | internal | - | |
| 966 | | `prometheus` | `prom/prometheus` | internal | - | |
| 967 | | `grafana` | `grafana/grafana` | 3000 (LAN only) | `prometheus`, `loki` | |
| 968 | | `loki` | `grafana/loki` | internal | - | |
| 969 | |
| 970 | ### 13.2 Networking |
| 971 | - One internal network `drophawk-net`. |
| 972 | - Only `caddy` exposes ports to the host. Everything else is internal. |
| 973 | - Caddy routes: |
| 974 | - `drophawk.lan` → `frontend:3000` |
| 975 | - `drophawk.lan/api/*` → `backend:8080` |
| 976 | - `drophawk.lan/ntfy/*` → `ntfy:80` |
| 977 | - For homelab DNS: operator adds `drophawk.lan` to their Pi-hole/AdGuard (or uses Tailscale MagicDNS). |
| 978 | |
| 979 | ### 13.3 Volumes |
| 980 | - `pg-data`, `redis-data`, `nats-data`, `meili-data`, `ntfy-data`, `minio-data`, `grafana-data`, `loki-data`, `prometheus-data`. |
| 981 | |
| 982 | ### 13.4 Resource guidance |
| 983 | - Postgres: 2GB memory ceiling. Backend: 512MB. Scrapers: 1GB (Playwright is hungry). Frontend (SSR): 256MB. |
| 984 | |
| 985 | ### 13.5 `.env.example` (template - copy to `.env`, fill in) |
| 986 | ``` |
| 987 | # Postgres |
| 988 | POSTGRES_PASSWORD=change_me |
| 989 | POSTGRES_DB=drophawk |
| 990 | DATABASE_URL=postgres://drophawk:change_me@postgres:5432/drophawk |
| 991 | |
| 992 | # Redis |
| 993 | REDIS_URL=redis://redis:6379/0 |
| 994 | |
| 995 | # NATS |
| 996 | NATS_URL=nats://nats:4222 |
| 997 | |
| 998 | # Sources - Ticketmaster |
| 999 | TICKETMASTER_API_KEYS=key1,key2,key3 |
| 1000 | # Sources - SeatGeek |
| 1001 | SEATGEEK_CLIENT_ID=xxx |
| 1002 | |
| 1003 | # ntfy |
| 1004 | NTFY_BASE_URL=http://ntfy:80 |
| 1005 | NTFY_TOPIC=drophawk-drops |
| 1006 | NTFY_TOKEN= |
| 1007 | |
| 1008 | # Meilisearch |
| 1009 | MEILI_URL=http://meilisearch:7700 |
| 1010 | MEILI_MASTER_KEY=change_me |
| 1011 | |
| 1012 | # Optional |
| 1013 | PROXY_POOL_URL= |
| 1014 | TELEGRAM_BOT_TOKEN= |
| 1015 | ``` |
| 1016 | |
| 1017 | **Secrets never go in the repo.** `.env` is gitignored. Document keys in `.env.example` only. |
| 1018 | |
| 1019 | --- |
| 1020 | |
| 1021 | ## 14. Phased Delivery Roadmap |
| 1022 | |
| 1023 | Each phase ends with the §17 checklist passing. |
| 1024 | |
| 1025 | ### Phase 1 - Vertical slice (1 source end-to-end) |
| 1026 | Scope: |
| 1027 | - Repo scaffold per §4. |
| 1028 | - Rust backend: config, DB pool, NATS connect, Axum server, `health`/`ready`, scheduler skeleton with **Ticketmaster adapter only**. |
| 1029 | - Postgres + Timescale + migrations through §8. |
| 1030 | - ntfy container + publisher. |
| 1031 | - Next.js: layout, dashboard, LiveDropFeed (real SSE), DropCard, StatsRow (basic), watchlist page (CRUD). |
| 1032 | - Caddy + Docker Compose for this subset. |
| 1033 | - Deliverable: operator can add "Taylor Swift" to watchlist, get an ntfy push when a Ticketmaster event flips on-sale, see it in the UI. |
| 1034 | |
| 1035 | ### Phase 2 - Second source + resale + Meilisearch |
| 1036 | - SeatGeek adapter (Rust). |
| 1037 | - Meilisearch integration, `/explore` page. |
| 1038 | - `price_history` writes, PriceSparkline component. |
| 1039 | - SourceGrid + `/sources` page. |
| 1040 | - DropHeatmap. |
| 1041 | - Deliverable: two primary sources feeding the UI; search works. |
| 1042 | |
| 1043 | ### Phase 3 - Python scraper bridge |
| 1044 | - NATS-based RPC to Python workers. |
| 1045 | - FIFA adapter (Python, Playwright). |
| 1046 | - AXS adapter (Python). |
| 1047 | - Scraper health surfacing in `/sources`. |
| 1048 | - MinIO screenshot capture on parse failure (debug). |
| 1049 | - Deliverable: FIFA + AXS drops flow through the same pipeline. |
| 1050 | |
| 1051 | ### Phase 4 - Sports expansion |
| 1052 | - NFL, NBA, NHL, MLB via Ticketmaster segment + SeatGeek cross-reference. |
| 1053 | - Sport-aware UI badges. |
| 1054 | - Watchlist `kind: team` with team slug resolution. |
| 1055 | - Deliverable: major sports covered. |
| 1056 | |
| 1057 | ### Phase 5 - Resale price tracking |
| 1058 | - StubHub + Vivid Seats Python scrapers. |
| 1059 | - Price-drop detection (≥10% / ≥25% thresholds). |
| 1060 | - Resale price sparkline overlay on event detail. |
| 1061 | - Deliverable: alerts when resale prices drop. |
| 1062 | |
| 1063 | ### Phase 6 - Shock drops + extras |
| 1064 | - Twitter/X monitor. |
| 1065 | - Tennis (ATP/WTA), F1, UEFA, Premier League (Big 6). |
| 1066 | - Festival adapters (Coachella, Glastonbury, Tomorrowland, EDC, Lollapalooza first). |
| 1067 | - Command palette (Cmd+K). |
| 1068 | - PWA manifest. |
| 1069 | - Deliverable: full breadth. |
| 1070 | |
| 1071 | ### Phase 7 - Polish & resilience |
| 1072 | - Grafana dashboards + alerts (Prometheus alertmanager). |
| 1073 | - Backup automation. |
| 1074 | - Source sharding for horizontal scale. |
| 1075 | - i18n (en first; structure for more). |
| 1076 | - Accessibility audit. |
| 1077 | - Deliverable: production-grade. |
| 1078 | |
| 1079 | --- |
| 1080 | |
| 1081 | ## 15. Coding Standards (HARD RULES) |
| 1082 | |
| 1083 | ### 15.1 Rust |
| 1084 | - `#![forbid(unsafe_code)]` at crate root. |
| 1085 | - `clippy::pedantic` enabled; fix all warnings before merge. |
| 1086 | - `rustfmt` with edition 2021. |
| 1087 | - Errors: `thiserror` for error enums, `?` propagation, no `unwrap()`/`expect()` outside tests and main startup. |
| 1088 | - Async: `tokio` runtime only. No `async_std`. |
| 1089 | - DB: sqlx with `query!` macros (compile-time checked). Migrations under `migrations/`, never edit applied. |
| 1090 | - Logging: `tracing::{info, warn, error, debug}` with structured fields. No `println!`. |
| 1091 | - Config: load from env via `figment` or hand-rolled; fail fast on missing required keys. |
| 1092 | - Tests: `#[tokio::test]` for async; use `wiremock` for HTTP mocking. |
| 1093 | |
| 1094 | ### 15.2 Python |
| 1095 | - Python 3.11+. |
| 1096 | - `ruff` (lint + format). `mypy --strict` passes. |
| 1097 | - `uv` for env + lockfile. |
| 1098 | - All scrapers typed (Protocol return types). |
| 1099 | - Async-first (`async def` everywhere in hot path). |
| 1100 | - No global mutable state. |
| 1101 | - Tests: `pytest` + `vcr.py` cassettes for HTTP replay. |
| 1102 | |
| 1103 | ### 15.3 TypeScript / Next.js |
| 1104 | - `"strict": true` in tsconfig. |
| 1105 | - `eslint` + `prettier`. |
| 1106 | - App Router conventions: server components by default, `"use client"` only where needed (SSE consumers, forms with state). |
| 1107 | - All API calls go through `lib/api.ts` - no raw `fetch` in components. |
| 1108 | - All server actions / route handlers validate input with Zod. |
| 1109 | - No `any`. No `// @ts-ignore`. |
| 1110 | - Tailwind only for styling - no inline `style={}` except dynamic values. |
| 1111 | - Tests: Vitest for unit, Playwright for e2e (one happy-path per phase). |
| 1112 | |
| 1113 | ### 15.4 Cross-cutting |
| 1114 | - **No secrets in repo.** `.env` gitignored. CI fails if a secret-looking string is committed (use `gitleaks`). |
| 1115 | - **Every PR runs:** `cargo clippy`, `cargo test`, `ruff`, `mypy`, `pytest`, `tsc --noEmit`, `eslint`, `prettier --check`. Wire these into a root `Makefile` target `make check`. |
| 1116 | - **Commit messages:** conventional commits (`feat:`, `fix:`, `chore:`, `docs:`). |
| 1117 | - **Branching:** `main` is deployable. Feature branches off `main`. No direct pushes to `main`. |
| 1118 | |
| 1119 | --- |
| 1120 | |
| 1121 | ## 16. Legal & Ethical Guardrails |
| 1122 | |
| 1123 | - **No bulk auto-purchase.** DropHawk only notifies and links. The human completes purchase. |
| 1124 | - **No CAPTCHA solving.** If a source requires CAPTCHA, mark unhealthy. |
| 1125 | - **No credential stuffing / account takeover.** Never store third-party site credentials. |
| 1126 | - **No circumvention of purchase limits.** DropHawk surfaces availability; it does not bypass per-account caps. |
| 1127 | - **Honor `robots.txt`.** |
| 1128 | - **Honor rate limits and `Retry-After`.** |
| 1129 | - **Resale compliance:** DropHawk does not facilitate resale of restricted tickets (e.g. FIFA, paperless tickets). Notifications for such events link only to the official sales channel. Resale of personal tickets is the operator's responsibility and must comply with local law. |
| 1130 | - **Identify the bot honestly** with a stable UA on API sources. Impersonation only where strictly required (Cloudflare-protected portals) and only for read-only public pages. |
| 1131 | - **Data minimization:** do not collect or store personal data of other users. |
| 1132 | |
| 1133 | --- |
| 1134 | |
| 1135 | ## 17. Execution Checklist (per phase) |
| 1136 | |
| 1137 | Before declaring a phase done, every box must be ticked: |
| 1138 | |
| 1139 | - [ ] All files in the phase's scope exist with the names/paths from §4. |
| 1140 | - [ ] `make check` passes clean (clippy, ruff, mypy, tsc, eslint, tests). |
| 1141 | - [ ] `docker compose up -d` brings up all services for that phase without errors. |
| 1142 | - [ ] `scripts/healthcheck.sh` returns 0. |
| 1143 | - [ ] DB migrations apply cleanly on a fresh database. |
| 1144 | - [ ] The phase's stated deliverable works end-to-end (manually verified: trigger a poll, see the SSE event, see the UI update, see the ntfy push). |
| 1145 | - [ ] No `unwrap`/`any`/`console.log`/`print` left in committed code. |
| 1146 | - [ ] `.env.example` updated with any new vars introduced. |
| 1147 | - [ ] README.md updated with how to run this phase. |
| 1148 | - [ ] Grafana dashboard for the new sources renders. |
| 1149 | |
| 1150 | --- |
| 1151 | |
| 1152 | ## 18. Reference Decisions (FAQ for the executing agent) |
| 1153 | |
| 1154 | **Q: Why not use Celery?** |
| 1155 | A: For v1, NATS-as-RPC is simpler (one less service, less moving parts). Celery is reserved for Phase 8 if worker fan-out demands it. |
| 1156 | |
| 1157 | **Q: Why SSE not WebSocket?** |
| 1158 | A: SSE is one-way (all we need), auto-reconnects in browsers, works through Caddy without upgrade dance, simpler server code. |
| 1159 | |
| 1160 | **Q: Why Rust for the scheduler at all - why not Python end-to-end?** |
| 1161 | A: The operator asked for "fastest." Rust's async scheduler comfortably handles hundreds of concurrent source polls at <1% CPU. Python's GIL would bottleneck. The split is principled: Rust for orchestration/detection/storage/notify (hot path), Python for browser-automation-heavy scraping (mature ecosystem). |
| 1162 | |
| 1163 | **Q: Why TimescaleDB?** |
| 1164 | A: Price + source-health are time-series. TimescaleDB gives us compression + continuous aggregates without adding InfluxDB. |
| 1165 | |
| 1166 | **Q: Why ntfy not Pushover/Bark?** |
| 1167 | A: Self-hosted, free, APNs-backed iPhone push, supports action buttons. Pushover costs $5 once (fine) but is third-party. Bark is iOS-only and simpler but ntfy is cross-platform and richer. |
| 1168 | |
| 1169 | **Q: Where do I get artist/team IDs for watchlist?** |
| 1170 | A: Use the explore endpoint (Meilisearch) to resolve names → IDs. Watchlist stores patterns (regex/keyword), not IDs - more resilient to source churn. |
| 1171 | |
| 1172 | **Q: How do I add a new source?** |
| 1173 | A: 1) Add row to `sources` migration (or call POST /api/sources if you build that). 2) Implement `SourceAdapter` (Rust, if API-based) or `BaseScraper` (Python, if scrape-based). 3) Register in the appropriate registry. 4) Restart backend / scrapers. |
| 1174 | |
| 1175 | **Q: The operator mentioned iPhone - what about Android?** |
| 1176 | A: ntfy is cross-platform. Same topic works on Android's ntfy app, no extra code. |
| 1177 | |
| 1178 | **Q: What if a source gets me IP-banned?** |
| 1179 | A: Circuit breaker trips after 10 failures, source goes `down`, operator is alerted. Recovery is manual (investigate, possibly enable proxy pool). |
| 1180 | |
| 1181 | **Q: How is "shock drop" defined technically?** |
| 1182 | A: A `drop.new` where `on_sale_at` is within ±5 minutes of `now()` (i.e. the sale was not pre-announced with a future timestamp, or the announcement came via Twitter/X monitor with no prior listing). |
| 1183 | |
| 1184 | --- |
| 1185 | |
| 1186 | ## 19. File-by-File Starter Notes (for the executing agent) |
| 1187 | |
| 1188 | For each file below, the agent should produce a working implementation, not a stub. If a file is marked `v2`, skip it in Phase 1. |
| 1189 | |
| 1190 | ### 19.1 Rust backend - minimum viable implementations per file |
| 1191 | |
| 1192 | - `backend/Cargo.toml`: deps = `tokio (full)`, `axum`, `hyper`, `tower`, `tower-http` (cors, trace, compression), `reqwest` (json, gzip, brotli, rustls-tls), `rquest` (for impersonation, optional Phase 3), `sqlx` (postgres, runtime-tokio-rustls, macros, migrate, chrono, json), `async-nats`, `redis`, `serde` (derive), `serde_json`, `chrono` (serde), `uuid`, `sha2`, `hex`, `tracing`, `tracing-subscriber` (env-filter, json), `opentelemetry-otlp` (optional), `thiserror`, `async-trait`, `validator` (derive), `figment` (env, toml), `anyhow` (bin only). |
| 1193 | - `backend/src/main.rs`: init tracing, load config, build AppState, run migrations, spawn scheduler, start Axum server on 0.0.0.0:8080, await shutdown signal. |
| 1194 | - `backend/src/config.rs`: struct `Config` with all env vars; `figment::Jail` for testing; fail-fast on missing required. |
| 1195 | - `backend/src/api/sse.rs`: `axum::response::sse::Sse` over a `BroadcastStream<ServerEvent>`; serialize as `event: {type}\ndata: {json}\n\n`. |
| 1196 | - `backend/src/sources/ticketmaster.rs`: implement `SourceAdapter` for `TicketmasterAdapter`. Use `?` and `SourceError::Network`/`Parse`/`RateLimited`. Walk pagination. |
| 1197 | |
| 1198 | ### 19.2 Python scrapers |
| 1199 | |
| 1200 | - `scrapers/pyproject.toml`: deps = `httpx`, `curl_cffi`, `selectolax`, `beautifulsoup4`, `playwright`, `playwright-stealth`, `async-nats`, `pydantic` (v2), `tenacity`, `python-dotenv`, `orjson`, `structlog`. |
| 1201 | - `scrapers/worker.py`: `asyncio.run(main())`; subscribe to `scrape.>`; per-message dispatch to registered scraper; publish reply with `orjson.dumps`. |
| 1202 | - `scrapers/base.py`: `BaseScraper` Protocol + `ScraperRegistry` global. |
| 1203 | - Each `sources/<name>.py`: a class implementing the protocol, registered at import. |
| 1204 | |
| 1205 | ### 19.3 Frontend |
| 1206 | |
| 1207 | - `frontend/package.json`: deps = `next@14`, `react@18`, `react-dom@18`, `tailwindcss`, `@tremor/react`, `framer-motion`, `@tanstack/react-query`, `@tanstack/react-virtual`, `zustand`, `react-hook-form`, `zod`, `@hookform/resolvers`, `lucide-react`, `clsx`, `tailwind-merge`, `cmdk`, `sonner` (toasts), `date-fns`. DevDeps: `typescript`, `eslint`, `eslint-config-next`, `prettier`, `prettier-plugin-tailwindcss`, `vitest`, `@testing-library/react`, `@playwright/test`. |
| 1208 | - `frontend/app/layout.tsx`: Geist fonts (or Inter + JetBrains Mono via `next/font`), ThemeProvider, TanStackQueryProvider, Toaster, Sidebar+Topbar shell. |
| 1209 | - `frontend/lib/types.ts`: mirror Rust models as TS types - keep in sync manually. |
| 1210 | - `frontend/components/dashboard/DropCard.tsx`: Framer Motion `motion.div` with `initial={{opacity:0, y:-20}} animate={{opacity:1, y:0}}`. Accent ring flashes for 2s on mount via keyframe variant. |
| 1211 | |
| 1212 | ### 19.4 Infra |
| 1213 | |
| 1214 | - `infra/Caddyfile`: one site block, `reverse_proxy` for `/api/*`, `/ntfy/*`, and the rest. Auto-HTTPS on real domains, internal CA for `.lan`. |
| 1215 | - `docker-compose.yml`: see §13. Add `restart: unless-stopped` to every service. Healthchecks on Postgres/Redis/NATS/Meilisearch before backend starts. |
| 1216 | |
| 1217 | --- |
| 1218 | |
| 1219 | ## 20. Final notes to the executing agent |
| 1220 | |
| 1221 | 1. **Do not deviate from this spec.** If you believe something is wrong, stop and ask - do not silently substitute. |
| 1222 | 2. **Implement Phase 1 first, end to end, before touching Phase 2.** A thin working slice beats a wide unfinished one. |
| 1223 | 3. **Every phase must run via `docker compose up`.** No "works on my machine" host dependencies. |
| 1224 | 4. **Commit often** - conventional commits, one logical change per commit. |
| 1225 | 5. **Update SPEC.md** if a decision changes during implementation - keep this doc as the living source of truth. |
| 1226 | 6. **The operator is disabled and depends on this working.** Reliability > features. A working Phase 1 they can rely on is worth more than a half-built Phase 6. |