● Zion Boggan zionboggan.com ↗

feat: implement Phase 1 vertical slice (Ticketmaster -> ntfy -> dashboard)

Builds the end-to-end Phase 1 slice defined in CLAUDE.md/SPEC.md/CONTRACTS.md:

backend/  Rust + Axum + sqlx core, Ticketmaster-only adapter. 12 migrations
          (DDL verbatim from CONTRACTS §G), full §B.3 API surface, SSE stream
          at /api/drops/stream with Last-Event-ID replay + 15s heartbeat,
          §D content_hash with the golden-vector regression test, §E drop
          classification, §I ntfy publisher + watchlist/dedupe/quiet-hours
          filter, §C adaptive poller with circuit breaker. #![forbid(unsafe_code)].

frontend/ Next.js 16 + React 19 + Tailwind v4 (CSS-first) + Framer Motion.
          Honors the binding OKLCH/electric-teal tokens (§H). Real-time live
          drop feed over SSE with spring-entrance cards + teal flash, count-up
          KPI tiles, glass topbar, Cmd+K palette, collapsible sidebar, watchlist
          CRUD (RHF+Zod), source health grid, settings + test push. WCAG 2.2 AA
          intent: focus rings, reduced-motion, tabular nums, mobile bottom nav.

infra/    docker-compose (+ prod overrides), Makefile (12 targets), Caddy
          (SSE-safe /api proxy), ntfy server config, Prometheus/Grafana/Loki,
          nightly pg-backup + healthcheck scripts.

Cross-stack verified statically: backend binds :8080, SSE path + event names
+ response envelope all match the frontend client. Builds were not run (noexec
CIFS, no toolchains here); code is compile-intentional per the spec.
11ba322   Zion Boggan committed on Jun 22, 2026 (1 month ago)
Makefile +129 -0
@@ -0,0 +1,129 @@
+# DropHawk - Makefile
+# Implements every target listed in CLAUDE.md.
+# Requires: docker compose v2, cargo, pnpm, uv
+
+COMPOSE := docker compose --env-file .env
+COMPOSE_ALL := $(COMPOSE) -f docker-compose.yml -f docker-compose.prod.yml
+
+# ─── Core lifecycle ────────────────────────────────────────────────────────────
+
+.PHONY: up
+up: ## Bring up all services (dev mode - no resource limits)
+ $(COMPOSE) up -d
+ @echo "Services starting. Run 'make health' in ~30s to verify."
+
+.PHONY: down
+down: ## Tear down all services (preserves volumes)
+ $(COMPOSE) down
+
+.PHONY: up-prod
+up-prod: ## Bring up with homelab prod overrides (resource limits, unless-stopped)
+ $(COMPOSE_ALL) up -d
+
+.PHONY: logs
+logs: ## Tail logs for all services
+ $(COMPOSE) logs -f
+
+.PHONY: logs-%
+logs-%: ## Tail logs for a specific service: make logs-backend
+ $(COMPOSE) logs -f $*
+
+# ─── Quality gate ──────────────────────────────────────────────────────────────
+
+.PHONY: check
+check: check-backend check-scrapers check-frontend ## Run ALL linters + tests (must pass before merge)
+
+.PHONY: check-backend
+check-backend: ## Rust: clippy + fmt + tests
+ cd backend && cargo clippy -- -D warnings
+ cd backend && cargo fmt --check
+ cd backend && cargo test --all
+
+.PHONY: check-scrapers
+check-scrapers: ## Python: ruff + mypy + pytest
+ cd scrapers && uv run ruff check .
+ cd scrapers && uv run ruff format --check .
+ cd scrapers && uv run mypy . --ignore-missing-imports
+ cd scrapers && uv run pytest --cov=scrapers --cov-fail-under=75
+
+.PHONY: check-frontend
+check-frontend: ## TypeScript: tsc + eslint + vitest
+ cd frontend && pnpm typecheck
+ cd frontend && pnpm lint
+ cd frontend && pnpm test
+
+.PHONY: e2e
+e2e: ## Playwright e2e (needs running stack)
+ cd frontend && pnpm test:e2e
+
+# ─── Database ──────────────────────────────────────────────────────────────────
+
+.PHONY: migrate
+migrate: ## Run sqlx migrations against $DATABASE_URL
+ cd backend && cargo sqlx migrate run --database-url "$${DATABASE_URL}"
+
+.PHONY: seed
+seed: ## Apply seed migrations (sources table initial data)
+ $(COMPOSE) exec -T backend sqlx migrate run --database-url "$${DATABASE_URL}" \
+ || cargo sqlx migrate run --database-url "$${DATABASE_URL}"
+
+.PHONY: psql
+psql: ## Open psql shell inside postgres container
+ $(COMPOSE) exec postgres psql -U $${POSTGRES_USER:-drophawk} -d $${POSTGRES_DB:-drophawk}
+
+# ─── Cache & messaging ────────────────────────────────────────────────────────
+
+.PHONY: redis-cli
+redis-cli: ## Open redis-cli inside redis container
+ $(COMPOSE) exec redis redis-cli -a "$${REDIS_PASSWORD}"
+
+.PHONY: nats-sub
+nats-sub: ## Subscribe to all NATS subjects (requires nats CLI in image)
+ $(COMPOSE) exec nats nats sub '>' \
+ --server nats://localhost:4222 \
+ --user "$${NATS_USER:-drophawk}" \
+ --password "$${NATS_PASSWORD}"
+
+# ─── Operations ───────────────────────────────────────────────────────────────
+
+.PHONY: health
+health: ## Check health of every service
+ @bash scripts/healthcheck.sh
+
+.PHONY: backup
+backup: ## Run pg_dump backup now
+ @bash infra/backup/pg-backup.sh
+
+.PHONY: bench
+bench: ## Run Rust benchmarks + Playwright load test
+ cd backend && cargo bench
+ cd frontend && pnpm test:e2e
+
+# ─── Dev helpers ──────────────────────────────────────────────────────────────
+
+.PHONY: dev
+dev: ## Start infra deps only, then start backend + frontend locally
+ @bash scripts/dev-up.sh
+
+.PHONY: build
+build: ## Build all Docker images
+ $(COMPOSE) build
+
+.PHONY: pull
+pull: ## Pull latest pinned images
+ $(COMPOSE) pull
+
+.PHONY: ps
+ps: ## Show container status
+ $(COMPOSE) ps
+
+.PHONY: restart-%
+restart-%: ## Restart a specific service: make restart-backend
+ $(COMPOSE) restart $*
+
+.PHONY: help
+help: ## Show this help
+ @grep -E '^[a-zA-Z_%-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
+ awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' | sort
+
+.DEFAULT_GOAL := help
README.md +109 -23
@@ -1,39 +1,125 @@
# DropHawk
-Always-on, self-hosted ticket drop monitor. Polls dozens of primary + resale sources, pushes to your phone within 15 seconds when tickets go on sale.
+Always-on, self-hosted ticket drop monitor. Polls primary and resale sources, pushes to your phone within 15 seconds when tickets go on sale.
-> **Status:** Specification complete. Implementation Phase 1 not yet started.
+> **Status:** Phase 1 - infrastructure scaffolded, Ticketmaster adapter in progress.
-## Documents
+## Requirements
-| File | Role |
-|---|---|
-| [`CLAUDE.md`](./CLAUDE.md) | Agent bootstrap - start here |
-| [`SPEC.md`](./SPEC.md) | Architecture & intent |
-| [`CONTRACTS.md`](./CONTRACTS.md) | **Binding implementation contracts** (versions, schemas, DDL, colors, algorithms, runbooks) |
+- Docker + Docker Compose v2 on the homelab host (recommend 16 GB RAM)
+- Pi-hole or AdGuard: add `drophawk.lan` pointing to the host IP
+- iOS: install the [ntfy app](https://apps.apple.com/us/app/ntfy/id1625396347) and subscribe to `drophawk-drops`
-## Quickstart (once Phase 1 ships)
+## Quickstart
```bash
+# 1. Clone and configure
+git clone <repo> drophawk
+cd drophawk
cp .env.example .env
-# edit .env - set every __CHANGE_ME__
-docker compose up -d
-# open https://drophawk.lan (or http://localhost)
+$EDITOR .env # set every __CHANGE_ME__ - see comments in file
+```
+
+At minimum you must set:
+- `POSTGRES_PASSWORD`, `REDIS_PASSWORD`, `NATS_PASSWORD`
+- `GRAFANA_ADMIN_PASSWORD`
+- `TICKETMASTER_API_KEYS` - free key at [developer.ticketmaster.com](https://developer.ticketmaster.com)
+- `NTFY_TOKEN` - generated after first boot (step 4 below)
+
+```bash
+# 2. Start everything
+make up
+
+# 3. Run database migrations
+make migrate
+
+# 4. Create the ntfy publisher token (one-time)
+docker compose exec ntfy ntfy user add --role=admin admin
+docker compose exec ntfy ntfy token create admin
+# Copy the token → paste as NTFY_TOKEN in .env → restart backend:
+# docker compose restart backend
+```
+
+```bash
+# 5. Open the dashboard
+# In your browser: http://drophawk.lan (or http://<host-ip>)
```
-## What it does
+```bash
+# 6. Add a watchlist entry
+# Navigate to Watchlist → Add Rule
+# Artist: "Taylor Swift" Priority: Critical
+# Save. The next Ticketmaster poll will match events and alert you.
+```
-- Monitors Ticketmaster, SeatGeek, FIFA, AXS, See Tickets, StubHub, Vivid Seats, NFL/NBA/NHL/MLB, ATP/WTA, F1, UEFA, Premier League, major festivals, and Twitter/X for shock drops.
-- Detects: new events, status flips (Announced → OnSale), price drops (≥10%), inventory drops (≥25%), shock drops.
-- Pushes to your iPhone via self-hosted ntfy (free) within 15 seconds.
-- Beautiful dense dashboard with live drop feed, source health grid, price sparklines, drop heatmap.
+```bash
+# 7. Verify all services are healthy
+make health
+```
+
+## Common operations
+
+| Task | Command |
+|---|---|
+| Start all services | `make up` |
+| Stop all services | `make down` |
+| View logs (all) | `make logs` |
+| View logs (one service) | `make logs-backend` |
+| Run migrations | `make migrate` |
+| Open Postgres shell | `make psql` |
+| Open Redis shell | `make redis-cli` |
+| Watch NATS subjects | `make nats-sub` |
+| Check all service health | `make health` |
+| Manual backup now | `make backup` |
+| Run full lint + test suite | `make check` |
+| Local dev (no Docker for app) | `make dev` |
-## What it does NOT do
+## Service URLs (all via Caddy on `:80`)
-- Auto-purchase tickets (illegal under BOTS Act / UK law).
-- Solve CAPTCHAs.
-- Facilitate resale of restricted tickets (FIFA, paperless, etc.).
+| URL | Service |
+|---|---|
+| `http://drophawk.lan/` | Dashboard |
+| `http://drophawk.lan/api/health` | Backend health |
+| `http://drophawk.lan/ntfy/` | ntfy push server |
+| `http://drophawk.lan/grafana/` | Grafana (metrics/logs) |
+
+## Observability
+
+- **Grafana** at `/grafana/` (default login: `admin` / value of `GRAFANA_ADMIN_PASSWORD`)
+ - Dashboard: DropHawk Overview - drops/24h, source health, detection latency p99
+- **Prometheus** at `http://<host>:9090` (internal only, not exposed by Caddy by default)
+- **Loki** receives logs from all services - query in Grafana's Explore tab
+
+## Backups
+
+Nightly `pg_dump` runs at 03:00. To run immediately:
+
+```bash
+make backup
+```
+
+Dumps land in `backups/postgres/` and rotate (30-day daily, 12-week weekly, 12-month monthly).
+
+To restore:
+
+```bash
+make down
+docker volume rm drophawk_pg-data
+make up # starts postgres only initially
+gunzip -c backups/postgres/drophawk-YYYYMMDD_HHMMSS.sql.gz \
+ | docker compose exec -T postgres psql -U drophawk -d drophawk
+make up # bring up the rest
+make health
+```
+
+## Project documents
+
+| File | Role |
+|---|---|
+| [`CLAUDE.md`](./CLAUDE.md) | Agent bootstrap + hard rules |
+| [`SPEC.md`](./SPEC.md) | Architecture and intent |
+| [`CONTRACTS.md`](./CONTRACTS.md) | **Binding** - pinned versions, SQL DDL, API contracts, algorithms |
-## License
+## Legal
-Personal use. See `CONTRACTS.md §16` for legal guardrails.
+Personal-use monitoring + manual purchase only. Auto-purchase is disabled (US BOTS Act / UK equivalent). CAPTCHA solving is disabled. See `CONTRACTS.md §16` for full guardrails.
backend/Cargo.toml +56 -0
@@ -0,0 +1,56 @@
+[package]
+name = "drophawk-backend"
+version = "0.1.0"
+edition = "2021"
+rust-version = "1.96"
+
+[dependencies]
+tokio = { version = "=1.45.1", features = ["full", "parking_lot", "tracing"] }
+axum = { version = "=0.8.4", features = ["macros", "tokio", "ws"] }
+tower = "=0.5.2"
+tower-http = { version = "=0.6.6", features = ["cors", "trace", "compression-br", "compression-gzip", "timeout", "request-id"] }
+hyper = { version = "=1.6.0", features = ["full"] }
+reqwest = { version = "=0.12.22", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls", "http2", "stream"] }
+rquest = { version = "=5.0.0", features = ["impersonate", "json"] } # TLS fingerprinting, Phase 3+
+sqlx = { version = "=0.8.6", features = ["runtime-tokio-rustls", "postgres", "macros", "migrate", "chrono", "json", "ipnetwork"] }
+async-nats = "=0.41.0"
+redis = { version = "=0.32.3", features = ["tokio-rustls-comp", "connection-manager"] }
+serde = { version = "=1.0.219", features = ["derive"] }
+serde_json = { version = "=1.0.140", features = ["preserve_key_order", "raw_value"] }
+chrono = { version = "=0.4.41", features = ["serde", "clock"], default-features = false }
+uuid = { version = "=1.17.0", features = ["v7", "serde"] }
+sha2 = "=0.10.9"
+hex = "=0.4.3"
+tracing = "=0.1.41"
+tracing-subscriber = { version = "=0.3.19", features = ["env-filter", "json", "registry"] }
+opentelemetry = "=0.29.0"
+opentelemetry-otlp = "=0.29.0"
+opentelemetry_sdk = { version = "=0.29.0", features = ["rt-tokio"] }
+thiserror = "=2.0.12"
+async-trait = "=0.1.88"
+validator = { version = "=0.20.0", features = ["derive"] }
+figment = { version = "=0.10.19", features = ["env", "toml"] }
+anyhow = "=1.0.98" # binary entry only
+prometheus = "=0.14.0"
+once_cell = "=1.21.3"
+parking_lot = "=0.12.3"
+futures = "=0.3.31"
+bytes = "=1.10.1"
+mime = "=0.3.17"
+url = { version = "=2.5.4", features = ["serde"] }
+regex = "=1.11.1"
+rand = "=0.8.5"
+tokio-stream = { version = "=0.1.17", features = ["sync"] }
+
+[dev-dependencies]
+wiremock = "=0.6.3"
+tokio-test = "=0.4.4"
+pretty_assertions = "=1.4.1"
+insta = "=1.43.1"
+
+[profile.release]
+lto = "fat"
+codegen-units = 1
+strip = "symbols"
+panic = "abort"
+opt-level = 3
backend/Dockerfile +39 -0
@@ -0,0 +1,39 @@
+# syntax=docker/dockerfile:1.7
+
+# ---- Build stage ----
+FROM rust:1.96-bookworm AS builder
+
+WORKDIR /app
+
+# Cache dependencies: copy the manifest first, build a dummy target, then sources.
+# Cargo.lock is committed in CI; if present it is honored, otherwise generated.
+COPY Cargo.toml ./
+COPY Cargo.lock* ./
+RUN mkdir -p src \
+ && echo 'fn main() {}' > src/main.rs \
+ && echo '' > src/lib.rs \
+ && cargo build --release || true \
+ && rm -rf src
+
+# Now copy the real sources and migrations and build for real.
+COPY src ./src
+COPY migrations ./migrations
+# SQLx uses runtime queries (no compile-time DB checks), so no DB is needed here.
+RUN cargo build --release --bin drophawk-backend \
+ && strip target/release/drophawk-backend
+
+# ---- Runtime stage ----
+# Distroless: minimal, no shell, runs as nonroot. cc variant provides libgcc/libc
+# needed by the dynamically linked binary.
+FROM gcr.io/distroless/cc-debian12:nonroot AS runtime
+
+WORKDIR /app
+
+# The binary and the migrations it applies at startup.
+COPY --from=builder /app/target/release/drophawk-backend /usr/local/bin/drophawk-backend
+COPY --from=builder /app/migrations /app/migrations
+
+EXPOSE 8080
+USER nonroot:nonroot
+
+ENTRYPOINT ["/usr/local/bin/drophawk-backend"]
backend/migrations/20260101000000_extensions.sql +3 -0
@@ -0,0 +1,3 @@
+CREATE EXTENSION IF NOT EXISTS "pg_trgm";
+CREATE EXTENSION IF NOT EXISTS timescaledb;
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
backend/migrations/20260101000001_set_updated_at_fn.sql +6 -0
@@ -0,0 +1,6 @@
+CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at := NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
backend/migrations/20260101000002_sources.sql +20 -0
@@ -0,0 +1,20 @@
+CREATE TABLE sources (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ category TEXT NOT NULL CHECK (category IN ('primary','resale','sports','festival','social')),
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ status TEXT NOT NULL DEFAULT 'healthy'
+ CHECK (status IN ('healthy','degraded','down','disabled')),
+ poll_interval_s INTEGER NOT NULL DEFAULT 300 CHECK (poll_interval_s BETWEEN 1 AND 86400),
+ tight_interval_s INTEGER NOT NULL DEFAULT 10 CHECK (tight_interval_s BETWEEN 1 AND 3600),
+ last_poll_at TIMESTAMPTZ,
+ last_success_at TIMESTAMPTZ,
+ last_error TEXT,
+ consecutive_failures INTEGER NOT NULL DEFAULT 0 CHECK (consecutive_failures >= 0),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TRIGGER sources_set_updated_at
+ BEFORE UPDATE ON sources
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
backend/migrations/20260101000003_events.sql +44 -0
@@ -0,0 +1,44 @@
+CREATE TABLE events (
+ id BIGSERIAL PRIMARY KEY,
+ source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE RESTRICT,
+ source_event_id TEXT NOT NULL,
+ title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 512),
+ subtitle TEXT,
+ category TEXT NOT NULL CHECK (category IN ('concert','sports','festival','theater','other')),
+ sport TEXT CHECK (sport IS NULL OR sport IN ('nfl','nba','nhl','mlb','tennis','f1','soccer','golf','boxing','other')),
+ starts_at TIMESTAMPTZ,
+ venue_name TEXT,
+ city TEXT,
+ country CHAR(2), -- ISO 3166-1 alpha-2
+ latitude DOUBLE PRECISION CHECK (latitude IS NULL OR (latitude BETWEEN -90 AND 90)),
+ longitude DOUBLE PRECISION CHECK (longitude IS NULL OR (longitude BETWEEN -180 AND 180)),
+ image_url TEXT,
+ on_sale_at TIMESTAMPTZ,
+ presale_on_sale_at TIMESTAMPTZ,
+ status TEXT NOT NULL CHECK (status IN ('announced','presale','onsale','soldout','cancelled')),
+ price_min INTEGER CHECK (price_min IS NULL OR price_min >= 0),
+ price_max INTEGER CHECK (price_max IS NULL OR price_max >= 0),
+ currency CHAR(3), -- ISO 4217
+ inventory_remaining INTEGER CHECK (inventory_remaining IS NULL OR inventory_remaining >= 0),
+ buy_url TEXT NOT NULL,
+ raw JSONB,
+ content_hash CHAR(64) NOT NULL,
+ first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (source_id, source_event_id),
+ CONSTRAINT price_range CHECK (price_min IS NULL OR price_max IS NULL OR price_min <= price_max)
+);
+
+CREATE INDEX idx_events_status ON events(status);
+CREATE INDEX idx_events_on_sale ON events(on_sale_at) WHERE on_sale_at IS NOT NULL;
+CREATE INDEX idx_events_category ON events(category);
+CREATE INDEX idx_events_sport ON events(sport) WHERE sport IS NOT NULL;
+CREATE INDEX idx_events_starts_at ON events(starts_at) WHERE starts_at IS NOT NULL;
+CREATE INDEX idx_events_source_status ON events(source_id, status);
+CREATE INDEX idx_events_updated_at ON events(updated_at DESC);
+CREATE INDEX idx_events_title_trgm ON events USING gin (title gin_trgm_ops);
+CREATE INDEX idx_events_starts_at_source ON events(starts_at, source_id);
+
+CREATE TRIGGER events_set_updated_at
+ BEFORE UPDATE ON events
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
backend/migrations/20260101000004_drops.sql +19 -0
@@ -0,0 +1,19 @@
+CREATE TABLE drops (
+ id BIGSERIAL PRIMARY KEY,
+ event_id BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
+ drop_type TEXT NOT NULL CHECK (drop_type IN ('new','status','price','inventory','announcement')),
+ from_state JSONB,
+ to_state JSONB NOT NULL,
+ detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ detected_at_ms BIGINT GENERATED ALWAYS AS (EXTRACT(EPOCH FROM detected_at) * 1000) STORED,
+ notified BOOLEAN NOT NULL DEFAULT FALSE,
+ notified_at TIMESTAMPTZ
+);
+
+CREATE INDEX idx_drops_detected_desc ON drops(detected_at DESC);
+CREATE INDEX idx_drops_event ON drops(event_id);
+CREATE INDEX idx_drops_type ON drops(drop_type);
+CREATE INDEX idx_drops_type_detected ON drops(drop_type, detected_at DESC);
+
+-- TimescaleDB hypertable (drops grow fast; partition by time)
+SELECT create_hypertable('drops', 'detected_at', chunk_time_interval => INTERVAL '1 day');
backend/migrations/20260101000005_watchlist.sql +16 -0
@@ -0,0 +1,16 @@
+CREATE TABLE watchlist (
+ id BIGSERIAL PRIMARY KEY,
+ kind TEXT NOT NULL CHECK (kind IN ('artist','team','venue','sport','category','keyword','twitter_account')),
+ pattern TEXT NOT NULL, -- Rust regex syntax
+ label TEXT NOT NULL CHECK (length(label) BETWEEN 1 AND 128),
+ active BOOLEAN NOT NULL DEFAULT TRUE,
+ priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high','critical')),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (kind, pattern)
+);
+
+CREATE INDEX idx_watchlist_active_kind ON watchlist(active, kind);
+CREATE TRIGGER watchlist_set_updated_at
+ BEFORE UPDATE ON watchlist
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
backend/migrations/20260101000006_notifications.sql +16 -0
@@ -0,0 +1,16 @@
+CREATE TABLE notifications_log (
+ id BIGSERIAL PRIMARY KEY,
+ drop_id BIGINT REFERENCES drops(id) ON DELETE SET NULL,
+ channel TEXT NOT NULL CHECK (channel IN ('ntfy','telegram','email','browser')),
+ title TEXT NOT NULL,
+ body TEXT NOT NULL,
+ priority TEXT NOT NULL CHECK (priority IN ('min','low','default','high','urgent')),
+ sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ success BOOLEAN NOT NULL,
+ response TEXT,
+ topic TEXT
+);
+
+CREATE INDEX idx_notif_sent_desc ON notifications_log(sent_at DESC);
+CREATE INDEX idx_notif_drop ON notifications_log(drop_id);
+SELECT create_hypertable('notifications_log', 'sent_at', chunk_time_interval => INTERVAL '7 days');
backend/migrations/20260101000007_timeseries.sql +40 -0
@@ -0,0 +1,40 @@
+CREATE TABLE price_history (
+ event_id BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
+ source TEXT NOT NULL,
+ observed_at TIMESTAMPTZ NOT NULL,
+ price_min INTEGER,
+ price_max INTEGER,
+ currency CHAR(3)
+);
+SELECT create_hypertable('price_history', 'observed_at', chunk_time_interval => INTERVAL '7 days');
+CREATE INDEX idx_price_event_time ON price_history(event_id, observed_at DESC);
+
+CREATE TABLE source_health (
+ source_id TEXT NOT NULL,
+ observed_at TIMESTAMPTZ NOT NULL,
+ ok BOOLEAN NOT NULL,
+ latency_ms INTEGER NOT NULL CHECK (latency_ms >= 0),
+ events_returned INTEGER NOT NULL DEFAULT 0,
+ error TEXT
+);
+SELECT create_hypertable('source_health', 'observed_at', chunk_time_interval => INTERVAL '1 day');
+CREATE INDEX idx_health_source_time ON source_health(source_id, observed_at DESC);
+
+-- Continuous aggregates for UI sparklines
+CREATE MATERIALIZED VIEW source_health_5min
+WITH (timescaledb.continuous) AS
+SELECT
+ source_id,
+ time_bucket('5 minutes', observed_at) AS bucket,
+ AVG(latency_ms) AS latency_ms_p50,
+ MAX(latency_ms) AS latency_ms_p95,
+ COUNT(*) FILTER (WHERE ok) AS ok_count,
+ COUNT(*) FILTER (WHERE NOT ok) AS err_count
+FROM source_health
+GROUP BY source_id, bucket
+WITH NO DATA;
+
+SELECT add_continuous_aggregate_policy('source_health_5min',
+ start_offset => INTERVAL '1 hour',
+ end_offset => INTERVAL '5 minutes',
+ schedule_interval => INTERVAL '1 minute');
backend/migrations/20260101000008_detection_latency.sql +6 -0
@@ -0,0 +1,6 @@
+-- Histogram for the detection_latency_ms metric (SLO §O.3)
+CREATE TABLE detection_latency (
+ drop_id BIGINT PRIMARY KEY REFERENCES drops(id) ON DELETE CASCADE,
+ latency_ms INTEGER NOT NULL CHECK (latency_ms >= 0)
+);
+CREATE INDEX idx_latency ON detection_latency(latency_ms);
backend/migrations/20260101000009_cross_source_links.sql +10 -0
@@ -0,0 +1,10 @@
+CREATE TABLE cross_source_links (
+ cluster_id BIGSERIAL PRIMARY KEY,
+ event_id BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
+ confidence REAL NOT NULL CHECK (confidence BETWEEN 0 AND 1),
+ match_basis TEXT NOT NULL, -- 'date+venue' | 'performer_id' | 'title+date'
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (cluster_id, event_id)
+);
+CREATE INDEX idx_xsrc_event ON cross_source_links(event_id);
+CREATE INDEX idx_xsrc_cluster ON cross_source_links(cluster_id);
backend/migrations/20260101000010_seed_sources.sql +31 -0
@@ -0,0 +1,31 @@
+INSERT INTO sources (id, name, category, enabled, poll_interval_s, tight_interval_s) VALUES
+ ('ticketmaster', 'Ticketmaster Discovery API', 'primary', TRUE, 300, 10),
+ ('seatgeek', 'SeatGeek', 'primary', TRUE, 300, 10),
+ ('fifa', 'FIFA World Cup Portal', 'sports', TRUE, 60, 30),
+ ('axs', 'AXS', 'primary', FALSE, 300, 10),
+ ('seetickets', 'See Tickets', 'primary', FALSE, 300, 10),
+ ('stubhub', 'StubHub (resale)', 'resale', FALSE, 600, 30),
+ ('vividseats', 'Vivid Seats (resale)', 'resale', FALSE, 600, 30),
+ ('twitter', 'Twitter/X Shock Drop', 'social', FALSE, 120, 15),
+ ('nfl', 'NFL (Ticketmaster Sports)', 'sports', FALSE, 300, 10),
+ ('nba', 'NBA', 'sports', FALSE, 300, 10),
+ ('nhl', 'NHL', 'sports', FALSE, 300, 10),
+ ('mlb', 'MLB', 'sports', FALSE, 300, 10),
+ ('atp_wta', 'ATP / WTA Tennis', 'sports', FALSE, 600, 30),
+ ('f1', 'Formula 1', 'sports', FALSE, 600, 30),
+ ('uefa', 'UEFA', 'sports', FALSE, 600, 30),
+ ('epl', 'Premier League (Big 6)', 'sports', FALSE, 600, 30),
+ ('coachella', 'Coachella', 'festival',FALSE, 600, 30),
+ ('glastonbury', 'Glastonbury', 'festival',FALSE, 600, 30),
+ ('tomorrowland', 'Tomorrowland', 'festival',FALSE, 600, 30),
+ ('edc', 'EDC', 'festival',FALSE, 600, 30),
+ ('lollapalooza', 'Lollapalooza', 'festival',FALSE, 600, 30)
+ON CONFLICT (id) DO NOTHING;
+
+INSERT INTO watchlist (kind, pattern, label, priority) VALUES
+ ('keyword', '(?i)\btaylor\s+swift\b', 'Taylor Swift', 'critical'),
+ ('keyword', '(?i)\bworld\s+cup\b', 'World Cup', 'critical'),
+ ('keyword', '(?i)\bsuper\s+bowl\b', 'Super Bowl', 'critical'),
+ ('keyword', '(?i)\bwimbledon\b', 'Wimbledon', 'high'),
+ ('keyword', '(?i)\bchampions\s+league\s+final\b', 'UCL Final', 'high')
+ON CONFLICT (kind, pattern) DO NOTHING;
backend/migrations/20260101000011_retention.sql +5 -0
@@ -0,0 +1,5 @@
+-- Drop old data automatically (TimescaleDB)
+SELECT add_retention_policy('drops', INTERVAL '90 days');
+SELECT add_retention_policy('price_history', INTERVAL '1 year');
+SELECT add_retention_policy('source_health', INTERVAL '30 days');
+SELECT add_retention_policy('notifications_log', INTERVAL '90 days');
backend/src/api/drops.rs +76 -0
@@ -0,0 +1,76 @@
+//! `GET /api/drops` (CONTRACTS §B.3) - paginated, embedded event.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use axum::extract::{Query, State};
+use axum::Json;
+use chrono::{DateTime, Utc};
+
+use crate::api::{ok_page, PageMeta};
+use crate::error::{AppError, AppResult};
+use crate::model::Drop;
+use crate::state::AppState;
+use crate::store::DropFilter;
+
+/// `GET /api/drops`.
+///
+/// # Errors
+/// Returns a store error or 422 on a malformed filter value.
+pub async fn list(
+ State(state): State<Arc<AppState>>,
+ Query(params): Query<HashMap<String, String>>,
+) -> AppResult<Json<serde_json::Value>> {
+ let filter = parse_filter(&params)?;
+ let (rows, total) = state.store.drops.list(&filter).await?;
+
+ // Embed each drop's event.
+ let mut data = Vec::with_capacity(rows.len());
+ for row in rows {
+ let event = state
+ .store
+ .events
+ .by_id(row.event_id)
+ .await?
+ .map(Into::into);
+ data.push(Drop::from_row(row, event));
+ }
+
+ let meta = PageMeta::new(filter.page, filter.per_page, total);
+ Ok(ok_page(data, meta))
+}
+
+fn parse_filter(params: &HashMap<String, String>) -> AppResult<DropFilter> {
+ let page = parse_int(params, "page", 1)?.max(1);
+ let per_page = parse_int(params, "per_page", 50)?.clamp(1, 100);
+ Ok(DropFilter {
+ drop_type: params.get("drop_type").cloned(),
+ category: params.get("category").cloned(),
+ source_id: params.get("source_id").cloned(),
+ since: parse_opt_ts(params, "since")?,
+ until: parse_opt_ts(params, "until")?,
+ page,
+ per_page,
+ })
+}
+
+fn parse_int(params: &HashMap<String, String>, key: &str, default: i64) -> AppResult<i64> {
+ match params.get(key) {
+ None => Ok(default),
+ Some(v) => v
+ .parse()
+ .map_err(|_| AppError::Validation(format!("'{key}' must be an integer"))),
+ }
+}
+
+fn parse_opt_ts(
+ params: &HashMap<String, String>,
+ key: &str,
+) -> AppResult<Option<DateTime<Utc>>> {
+ match params.get(key) {
+ None => Ok(None),
+ Some(v) => DateTime::parse_from_rfc3339(v)
+ .map(|dt| Some(dt.with_timezone(&Utc)))
+ .map_err(|_| AppError::Validation(format!("'{key}' must be RFC3339"))),
+ }
+}
backend/src/api/events.rs +126 -0
@@ -0,0 +1,126 @@
+//! `GET /api/events` and `GET /api/events/:id` (CONTRACTS §B.3).
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use axum::extract::{Path, Query, State};
+use axum::Json;
+use chrono::{DateTime, Utc};
+use serde_json::json;
+
+use crate::api::{ok_page, ok_one, PageMeta};
+use crate::error::{AppError, AppResult};
+use crate::model::Event;
+use crate::state::AppState;
+use crate::store::EventFilter;
+
+/// `GET /api/events`.
+///
+/// # Errors
+/// Returns a store error or 422 on a malformed filter value.
+pub async fn list(
+ State(state): State<Arc<AppState>>,
+ Query(params): Query<HashMap<String, String>>,
+) -> AppResult<Json<serde_json::Value>> {
+ let filter = parse_filter(&params)?;
+ let (rows, total) = state.store.events.list(&filter).await?;
+ let data: Vec<Event> = rows.into_iter().map(Into::into).collect();
+ let meta = PageMeta::new(filter.page, filter.per_page, total);
+ Ok(ok_page(data, meta))
+}
+
+/// `GET /api/events/:id` with price history.
+///
+/// # Errors
+/// Returns 404 if the event does not exist, or a store error.
+pub async fn get_one(
+ State(state): State<Arc<AppState>>,
+ Path(id): Path<i64>,
+) -> AppResult<Json<serde_json::Value>> {
+ let row = state
+ .store
+ .events
+ .by_id(id)
+ .await?
+ .ok_or_else(|| AppError::NotFound(format!("No event with id '{id}'")))?;
+ let event: Event = row.into();
+ let history = state.store.events.price_history(id).await?;
+ Ok(ok_one(json!({
+ "event": event,
+ "price_history": history,
+ "cross_source_links": [],
+ })))
+}
+
+/// Parse and validate the events filter (§B.3 query params).
+fn parse_filter(params: &HashMap<String, String>) -> AppResult<EventFilter> {
+ let page = parse_int(params, "page", 1)?.max(1);
+ let per_page = parse_int(params, "per_page", 50)?.clamp(1, 100);
+
+ let categories = parse_list(params, "category");
+ let source_ids = parse_list(params, "source_id");
+ let sort = params
+ .get("sort")
+ .cloned()
+ .unwrap_or_else(|| "on_sale".to_string());
+
+ Ok(EventFilter {
+ categories,
+ sport: params.get("sport").cloned(),
+ source_ids,
+ status: params.get("status").cloned(),
+ price_min: parse_opt_i32(params, "price_min_usd_cents")?,
+ price_max: parse_opt_i32(params, "price_max_usd_cents")?,
+ starts_after: parse_opt_ts(params, "starts_after")?,
+ starts_before: parse_opt_ts(params, "starts_before")?,
+ on_sale_after: parse_opt_ts(params, "on_sale_after")?,
+ q: params.get("q").cloned().filter(|s| !s.is_empty()),
+ sort,
+ page,
+ per_page,
+ })
+}
+
+fn parse_int(params: &HashMap<String, String>, key: &str, default: i64) -> AppResult<i64> {
+ match params.get(key) {
+ None => Ok(default),
+ Some(v) => v
+ .parse()
+ .map_err(|_| AppError::Validation(format!("'{key}' must be an integer"))),
+ }
+}
+
+fn parse_opt_i32(params: &HashMap<String, String>, key: &str) -> AppResult<Option<i32>> {
+ match params.get(key) {
+ None => Ok(None),
+ Some(v) => v
+ .parse()
+ .map(Some)
+ .map_err(|_| AppError::Validation(format!("'{key}' must be an integer"))),
+ }
+}
+
+fn parse_opt_ts(
+ params: &HashMap<String, String>,
+ key: &str,
+) -> AppResult<Option<DateTime<Utc>>> {
+ match params.get(key) {
+ None => Ok(None),
+ Some(v) => DateTime::parse_from_rfc3339(v)
+ .map(|dt| Some(dt.with_timezone(&Utc)))
+ .map_err(|_| AppError::Validation(format!("'{key}' must be RFC3339"))),
+ }
+}
+
+fn parse_list(params: &HashMap<String, String>, key: &str) -> Vec<String> {
+ params
+ .get(key)
+ .map(|v| {
+ v.split(',')
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(ToString::to_string)
+ .collect()
+ })
+ .unwrap_or_default()
+}
backend/src/api/mod.rs +157 -0
@@ -0,0 +1,157 @@
+//! Axum router assembly and the shared response envelope (CONTRACTS §B.1).
+
+pub mod drops;
+pub mod events;
+pub mod sources;
+pub mod sse;
+pub mod stats;
+pub mod watchlist;
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use axum::extract::State;
+use axum::http::StatusCode;
+use axum::response::IntoResponse;
+use axum::routing::{get, patch, post};
+use axum::{Json, Router};
+use serde::Serialize;
+use serde_json::json;
+use tower_http::cors::CorsLayer;
+use tower_http::timeout::TimeoutLayer;
+use tower_http::trace::TraceLayer;
+
+use crate::state::AppState;
+
+/// Pagination metadata for collection responses.
+#[derive(Debug, Clone, Serialize)]
+pub struct PageMeta {
+ pub request_id: String,
+ pub version: &'static str,
+ pub page: i64,
+ pub per_page: i64,
+ pub total: i64,
+ pub total_pages: i64,
+}
+
+impl PageMeta {
+ #[must_use]
+ pub fn new(page: i64, per_page: i64, total: i64) -> Self {
+ let total_pages = if per_page > 0 {
+ (total + per_page - 1) / per_page
+ } else {
+ 0
+ };
+ PageMeta {
+ request_id: uuid::Uuid::now_v7().to_string(),
+ version: "v1",
+ page,
+ per_page,
+ total,
+ total_pages,
+ }
+ }
+}
+
+/// `{ "data": <T>, "meta": { request_id, version } }` for single resources.
+#[must_use]
+pub fn ok_one<T: Serialize>(data: T) -> Json<serde_json::Value> {
+ Json(json!({
+ "data": data,
+ "meta": { "request_id": uuid::Uuid::now_v7().to_string(), "version": "v1" }
+ }))
+}
+
+/// `{ "data": [...], "meta": { page, per_page, total, total_pages } }`.
+#[must_use]
+pub fn ok_page<T: Serialize>(data: Vec<T>, meta: PageMeta) -> Json<serde_json::Value> {
+ Json(json!({ "data": data, "meta": meta }))
+}
+
+/// Build the full `/api` router with shared middleware.
+#[must_use]
+pub fn router(state: Arc<AppState>) -> Router {
+ let api = Router::new()
+ .route("/events", get(events::list))
+ .route("/events/{id}", get(events::get_one))
+ .route("/drops", get(drops::list))
+ .route("/drops/stream", get(sse::stream))
+ .route("/watchlist", get(watchlist::list).post(watchlist::create))
+ .route(
+ "/watchlist/{id}",
+ patch(watchlist::update).delete(watchlist::remove),
+ )
+ .route("/watchlist/bulk", post(watchlist::bulk))
+ .route("/sources", get(sources::list))
+ .route(
+ "/sources/{id}",
+ get(sources::get_one).patch(sources::update),
+ )
+ .route("/stats", get(stats::get))
+ .route("/health", get(health))
+ .route("/ready", get(ready))
+ .route("/metrics", get(metrics))
+ .route("/notifications/test", post(notifications_test));
+
+ Router::new()
+ .nest("/api", api)
+ .with_state(state)
+ .layer(TraceLayer::new_for_http())
+ .layer(TimeoutLayer::new(Duration::from_secs(30)))
+ .layer(CorsLayer::permissive())
+}
+
+/// `GET /api/health` -> liveness.
+async fn health() -> impl IntoResponse {
+ (StatusCode::OK, Json(json!({ "status": "ok" })))
+}
+
+/// `GET /api/ready` -> 200 only if Postgres + NATS + Redis reachable (§B.3).
+async fn ready(State(state): State<Arc<AppState>>) -> impl IntoResponse {
+ let db = state.store.ping().await.is_ok();
+ let cache = state.cache.ping().await.is_ok();
+ let bus = state.bus.ping().await;
+ if db && cache && bus {
+ (StatusCode::OK, Json(json!({ "status": "ready" }))).into_response()
+ } else {
+ (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({
+ "error": {
+ "code": "UPSTREAM_UNAVAILABLE",
+ "message": "dependency unavailable",
+ "details": { "postgres": db, "redis": cache, "nats": bus }
+ }
+ })),
+ )
+ .into_response()
+ }
+}
+
+/// `GET /api/metrics` -> Prometheus text exposition.
+async fn metrics(State(state): State<Arc<AppState>>) -> impl IntoResponse {
+ // Refresh point-in-time gauges.
+ if let Ok(n) = state.store.events.count_all().await {
+ state.metrics.events_total.set(n);
+ }
+ if let Ok(n) = state.store.watchlist.count_active().await {
+ state.metrics.watchlist_rules_active.set(n);
+ }
+ #[allow(clippy::cast_possible_wrap)]
+ state
+ .metrics
+ .db_connections_in_use
+ .set(i64::from(state.store.pool.size()));
+ (
+ [("content-type", "text/plain; version=0.0.4")],
+ state.metrics.render(),
+ )
+}
+
+/// `POST /api/notifications/test` (§B.3).
+async fn notifications_test(
+ State(state): State<Arc<AppState>>,
+) -> Result<Json<serde_json::Value>, crate::error::AppError> {
+ let topic = crate::notify::send_test(&state).await?;
+ Ok(Json(json!({ "data": { "sent": true, "topic": topic } })))
+}
backend/src/api/sources.rs +79 -0
@@ -0,0 +1,79 @@
+//! `GET /api/sources`, `GET /api/sources/:id`, `PATCH /api/sources/:id`.
+
+use std::sync::Arc;
+
+use axum::extract::{Path, State};
+use axum::Json;
+use serde::Deserialize;
+use serde_json::json;
+
+use crate::api::{ok_one, PageMeta};
+use crate::error::{AppError, AppResult};
+use crate::model::Source;
+use crate::state::AppState;
+
+/// `GET /api/sources` - source health grid.
+///
+/// # Errors
+/// Propagates store errors.
+pub async fn list(
+ State(state): State<Arc<AppState>>,
+) -> AppResult<Json<serde_json::Value>> {
+ let rows = state.store.sources.list().await?;
+ let total = i64::try_from(rows.len()).unwrap_or(0);
+ let mut data = Vec::with_capacity(rows.len());
+ for row in rows {
+ let (p50, p95) = state.store.sources.latency_percentiles(&row.id).await.unwrap_or((None, None));
+ let seen = state.store.sources.events_seen_24h(&row.id).await.unwrap_or(0);
+ data.push(Source::from_row(row, p50, p95, seen));
+ }
+ let meta = PageMeta::new(1, total.max(1), total);
+ Ok(crate::api::ok_page(data, meta))
+}
+
+/// `GET /api/sources/:id` - one source + 24h health buckets.
+///
+/// # Errors
+/// 404 if absent; propagates store errors.
+pub async fn get_one(
+ State(state): State<Arc<AppState>>,
+ Path(id): Path<String>,
+) -> AppResult<Json<serde_json::Value>> {
+ let row = state
+ .store
+ .sources
+ .by_id(&id)
+ .await?
+ .ok_or_else(|| AppError::NotFound(format!("No source with id '{id}'")))?;
+ let (p50, p95) = state.store.sources.latency_percentiles(&id).await.unwrap_or((None, None));
+ let seen = state.store.sources.events_seen_24h(&id).await.unwrap_or(0);
+ let buckets = state.store.sources.health_buckets(&id).await.unwrap_or_default();
+ let source = Source::from_row(row, p50, p95, seen);
+ Ok(ok_one(json!({ "source": source, "health": buckets })))
+}
+
+/// `PATCH /api/sources/:id` body.
+#[derive(Debug, Deserialize)]
+pub struct SourcePatch {
+ pub enabled: bool,
+}
+
+/// `PATCH /api/sources/:id` - toggle polling.
+///
+/// # Errors
+/// 404 if absent; propagates store errors.
+pub async fn update(
+ State(state): State<Arc<AppState>>,
+ Path(id): Path<String>,
+ Json(body): Json<SourcePatch>,
+) -> AppResult<Json<serde_json::Value>> {
+ let row = state
+ .store
+ .sources
+ .set_enabled(&id, body.enabled)
+ .await?
+ .ok_or_else(|| AppError::NotFound(format!("No source with id '{id}'")))?;
+ let (p50, p95) = state.store.sources.latency_percentiles(&id).await.unwrap_or((None, None));
+ let seen = state.store.sources.events_seen_24h(&id).await.unwrap_or(0);
+ Ok(ok_one(Source::from_row(row, p50, p95, seen)))
+}
backend/src/api/sse.rs +192 -0
@@ -0,0 +1,192 @@
+//! Server-Sent Events stream for live drops (CONTRACTS §B.3 + SPEC §5.4).
+//!
+//! Wire protocol per event:
+//! ```text
+//! event: drop.status
+//! id: 987654
+//! data: {...}
+//!
+//! ```
+//! Heartbeat every 15s as a comment line. On reconnect the client's
+//! `Last-Event-ID` header drives a replay of `drops WHERE id > $last`.
+
+use std::convert::Infallible;
+use std::sync::Arc;
+use std::time::Duration;
+
+use axum::extract::State;
+use axum::http::header::{HeaderMap, HeaderValue};
+use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
+use axum::response::IntoResponse;
+use futures::stream::{self, Stream, StreamExt};
+use tokio::sync::broadcast;
+use tokio_stream::wrappers::BroadcastStream;
+
+use crate::model::{Drop as DropModel, DropType};
+use crate::state::AppState;
+
+/// A single broadcastable SSE message (already serialized).
+#[derive(Clone)]
+pub struct SseMessage {
+ pub id: i64,
+ pub event: &'static str,
+ pub data: String,
+}
+
+/// Broadcast hub held in [`AppState`]. Cloneable senders fan out to all clients.
+#[derive(Clone)]
+pub struct SseHub {
+ tx: broadcast::Sender<SseMessage>,
+}
+
+impl SseHub {
+ /// Create a hub with the given channel capacity. Older messages are dropped
+ /// for slow consumers (backpressure per SPEC §5.4).
+ #[must_use]
+ pub fn new(capacity: usize) -> Self {
+ let (tx, _rx) = broadcast::channel(capacity.max(16));
+ SseHub { tx }
+ }
+
+ /// Subscribe a new client.
+ #[must_use]
+ pub fn subscribe(&self) -> broadcast::Receiver<SseMessage> {
+ self.tx.subscribe()
+ }
+
+ /// Broadcast a drop to all connected clients. Serialization failures are
+ /// logged and dropped rather than propagated.
+ pub fn broadcast(&self, drop: &DropModel, drop_type: DropType) {
+ let data = match serde_json::to_string(drop) {
+ Ok(s) => s,
+ Err(e) => {
+ tracing::warn!(error = %e, "failed to serialize drop for SSE");
+ return;
+ }
+ };
+ let msg = SseMessage {
+ id: drop.id,
+ event: drop_type.sse_event(),
+ data,
+ };
+ // Err only when there are no receivers - that is fine.
+ let _ = self.tx.send(msg);
+ }
+}
+
+/// `GET /api/drops/stream` handler.
+pub async fn stream(
+ State(state): State<Arc<AppState>>,
+ headers: HeaderMap,
+) -> impl IntoResponse {
+ let last_event_id: Option<i64> = headers
+ .get("last-event-id")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|s| s.parse().ok());
+
+ let rx = state.sse.subscribe();
+ let gauge = state.metrics.sse_connections.clone();
+ gauge.inc();
+
+ // Replay any missed drops first, then switch to the live tail.
+ let replay = build_replay(&state, last_event_id).await;
+
+ let live = BroadcastStream::new(rx).filter_map(|res| async move {
+ match res {
+ Ok(msg) => Some(Ok::<SseEvent, Infallible>(
+ SseEvent::default()
+ .id(msg.id.to_string())
+ .event(msg.event)
+ .data(msg.data),
+ )),
+ // Lagged: client fell behind; skip dropped messages.
+ Err(_) => None,
+ }
+ });
+
+ let body = stream::iter(replay)
+ .chain(live)
+ .chain(OnDrop::new(gauge));
+
+ let mut response = Sse::new(body)
+ .keep_alive(
+ KeepAlive::new()
+ .interval(Duration::from_secs(15))
+ .text("heartbeat"),
+ )
+ .into_response();
+
+ let h = response.headers_mut();
+ h.insert(
+ "Cache-Control",
+ HeaderValue::from_static("no-cache, no-transform"),
+ );
+ h.insert("X-Accel-Buffering", HeaderValue::from_static("no"));
+ response
+}
+
+/// Build the replay prefix as a vec of SSE events.
+async fn build_replay(
+ state: &Arc<AppState>,
+ last_event_id: Option<i64>,
+) -> Vec<Result<SseEvent, Infallible>> {
+ let Some(last) = last_event_id else {
+ return Vec::new();
+ };
+ let rows = match state.store.drops.replay_after(last).await {
+ Ok(r) => r,
+ Err(e) => {
+ tracing::warn!(error = %e, "SSE replay query failed");
+ return Vec::new();
+ }
+ };
+ let mut out = Vec::with_capacity(rows.len());
+ for row in rows {
+ let drop_type =
+ DropType::parse(&row.drop_type).map_or("drop.status", DropType::sse_event);
+ let event = state
+ .store
+ .events
+ .by_id(row.event_id)
+ .await
+ .ok()
+ .flatten()
+ .map(Into::into);
+ let drop = DropModel::from_row(row, event);
+ if let Ok(data) = serde_json::to_string(&drop) {
+ out.push(Ok(SseEvent::default()
+ .id(drop.id.to_string())
+ .event(drop_type)
+ .data(data)));
+ }
+ }
+ out
+}
+
+/// A zero-length stream that decrements the connection gauge when dropped.
+struct OnDrop {
+ gauge: prometheus::IntGauge,
+}
+
+impl OnDrop {
+ fn new(gauge: prometheus::IntGauge) -> Self {
+ OnDrop { gauge }
+ }
+}
+
+impl Stream for OnDrop {
+ type Item = Result<SseEvent, Infallible>;
+
+ fn poll_next(
+ self: std::pin::Pin<&mut Self>,
+ _cx: &mut std::task::Context<'_>,
+ ) -> std::task::Poll<Option<Self::Item>> {
+ std::task::Poll::Ready(None)
+ }
+}
+
+impl Drop for OnDrop {
+ fn drop(&mut self) {
+ self.gauge.dec();
+ }
+}
backend/src/api/stats.rs +21 -0
@@ -0,0 +1,21 @@
+//! `GET /api/stats` - dashboard KPIs (CONTRACTS §B.3).
+
+use std::sync::Arc;
+
+use axum::extract::State;
+use axum::Json;
+
+use crate::api::ok_one;
+use crate::error::AppResult;
+use crate::state::AppState;
+
+/// `GET /api/stats`.
+///
+/// # Errors
+/// Propagates store errors.
+pub async fn get(
+ State(state): State<Arc<AppState>>,
+) -> AppResult<Json<serde_json::Value>> {
+ let stats = state.store.stats().await?;
+ Ok(ok_one(stats))
+}
backend/src/api/watchlist.rs +192 -0
@@ -0,0 +1,192 @@
+//! Watchlist CRUD + bulk (CONTRACTS §B.3).
+
+use std::sync::Arc;
+
+use axum::extract::{Path, State};
+use axum::http::StatusCode;
+use axum::Json;
+use regex::Regex;
+use serde_json::json;
+
+use crate::api::{ok_one, PageMeta};
+use crate::error::{AppError, AppResult};
+use crate::model::watchlist::{
+ CreateWatchlistRule, UpdateWatchlistRule, WatchlistBulkRequest, WatchlistRule,
+};
+use crate::model::Priority;
+use crate::state::AppState;
+
+const VALID_KINDS: &[&str] = &[
+ "artist",
+ "team",
+ "venue",
+ "sport",
+ "category",
+ "keyword",
+ "twitter_account",
+];
+
+/// `GET /api/watchlist`.
+///
+/// # Errors
+/// Propagates store errors.
+pub async fn list(
+ State(state): State<Arc<AppState>>,
+) -> AppResult<Json<serde_json::Value>> {
+ let rows = state.store.watchlist.list().await?;
+ let total = i64::try_from(rows.len()).unwrap_or(0);
+ let mut data = Vec::with_capacity(rows.len());
+ for row in rows {
+ let matches = state.store.watchlist.matches_30d(&row.pattern).await.unwrap_or(0);
+ data.push(WatchlistRule::from_row(row, matches));
+ }
+ let meta = PageMeta::new(1, total.max(1), total);
+ Ok(crate::api::ok_page(data, meta))
+}
+
+/// `POST /api/watchlist`.
+///
+/// # Errors
+/// 422 on invalid kind / regex / priority; 409 on duplicate `(kind, pattern)`.
+pub async fn create(
+ State(state): State<Arc<AppState>>,
+ Json(body): Json<CreateWatchlistRule>,
+) -> AppResult<(StatusCode, Json<serde_json::Value>)> {
+ validate_kind(&body.kind)?;
+ validate_regex(&body.pattern)?;
+ let priority = validate_priority(body.priority.as_deref())?;
+ validate_label(&body.label)?;
+ enforce_max_rules(&state).await?;
+
+ let row = state
+ .store
+ .watchlist
+ .insert(&body.kind, &body.pattern, &body.label, priority.as_str())
+ .await?
+ .ok_or_else(|| {
+ AppError::Conflict(format!(
+ "Watchlist rule ({}, {}) already exists",
+ body.kind, body.pattern
+ ))
+ })?;
+
+ let rule = WatchlistRule::from_row(row, 0);
+ Ok((StatusCode::CREATED, ok_one(rule)))
+}
+
+/// `PATCH /api/watchlist/:id`.
+///
+/// # Errors
+/// 404 if absent; 422 on invalid priority.
+pub async fn update(
+ State(state): State<Arc<AppState>>,
+ Path(id): Path<i64>,
+ Json(body): Json<UpdateWatchlistRule>,
+) -> AppResult<Json<serde_json::Value>> {
+ let priority = match &body.priority {
+ Some(p) => Some(validate_priority(Some(p))?.as_str()),
+ None => None,
+ };
+ if let Some(label) = &body.label {
+ validate_label(label)?;
+ }
+ let row = state
+ .store
+ .watchlist
+ .update(id, body.active, priority, body.label.as_deref())
+ .await?
+ .ok_or_else(|| AppError::NotFound(format!("No watchlist rule with id '{id}'")))?;
+ let matches = state.store.watchlist.matches_30d(&row.pattern).await.unwrap_or(0);
+ Ok(ok_one(WatchlistRule::from_row(row, matches)))
+}
+
+/// `DELETE /api/watchlist/:id` -> 204.
+///
+/// # Errors
+/// 404 if the rule does not exist.
+pub async fn remove(
+ State(state): State<Arc<AppState>>,
+ Path(id): Path<i64>,
+) -> AppResult<StatusCode> {
+ let removed = state.store.watchlist.delete(id).await?;
+ if removed {
+ Ok(StatusCode::NO_CONTENT)
+ } else {
+ Err(AppError::NotFound(format!("No watchlist rule with id '{id}'")))
+ }
+}
+
+/// `POST /api/watchlist/bulk` - auto-generate patterns from labels.
+///
+/// # Errors
+/// 422 on invalid kind; propagates store errors.
+pub async fn bulk(
+ State(state): State<Arc<AppState>>,
+ Json(body): Json<WatchlistBulkRequest>,
+) -> AppResult<Json<serde_json::Value>> {
+ let mut created = 0_i64;
+ let mut skipped = 0_i64;
+
+ for spec in body.rules {
+ validate_kind(&spec.kind)?;
+ validate_label(&spec.label)?;
+ let priority = validate_priority(spec.priority.as_deref())?;
+ let pattern = format!(r"(?i)\b{}\b", regex::escape(&spec.label));
+ match state
+ .store
+ .watchlist
+ .insert(&spec.kind, &pattern, &spec.label, priority.as_str())
+ .await?
+ {
+ Some(_) => created += 1,
+ None => skipped += 1,
+ }
+ }
+
+ Ok(Json(json!({
+ "data": { "created": created, "skipped_duplicates": skipped }
+ })))
+}
+
+fn validate_kind(kind: &str) -> AppResult<()> {
+ if VALID_KINDS.contains(&kind) {
+ Ok(())
+ } else {
+ Err(AppError::Validation(format!("invalid kind '{kind}'")))
+ }
+}
+
+fn validate_regex(pattern: &str) -> AppResult<()> {
+ Regex::new(pattern)
+ .map(|_| ())
+ .map_err(|e| AppError::Validation(format!("invalid regex: {e}")))
+}
+
+fn validate_priority(p: Option<&str>) -> AppResult<Priority> {
+ match p {
+ None => Ok(Priority::Normal),
+ Some(v) => {
+ Priority::parse(v).ok_or_else(|| AppError::Validation(format!("invalid priority '{v}'")))
+ }
+ }
+}
+
+fn validate_label(label: &str) -> AppResult<()> {
+ let len = label.chars().count();
+ if (1..=128).contains(&len) {
+ Ok(())
+ } else {
+ Err(AppError::Validation("label must be 1-128 chars".to_string()))
+ }
+}
+
+async fn enforce_max_rules(state: &Arc<AppState>) -> AppResult<()> {
+ let total = state.store.watchlist.count_all().await?;
+ if total >= state.config.watchlist_max_rules {
+ return Err(AppError::Validation(format!(
+ "watchlist rule limit ({}) reached",
+ state.config.watchlist_max_rules
+ )));
+ }
+ Ok(())
+}
backend/src/bus/mod.rs +41 -0
@@ -0,0 +1,41 @@
+//! Thin async-nats wrapper.
+
+pub mod subjects;
+
+use bytes::Bytes;
+
+/// NATS client handle shared across the app.
+#[derive(Clone)]
+pub struct Bus {
+ client: async_nats::Client,
+}
+
+impl Bus {
+ /// Connect to NATS at `url`.
+ ///
+ /// # Errors
+ /// Returns an error if the initial connection fails.
+ pub async fn connect(url: &str) -> Result<Self, async_nats::ConnectError> {
+ let client = async_nats::connect(url).await?;
+ Ok(Bus { client })
+ }
+
+ /// Publish a payload to a subject.
+ ///
+ /// # Errors
+ /// Returns the underlying publish error if the message cannot be queued.
+ pub async fn publish(
+ &self,
+ subject: impl async_nats::subject::ToSubject,
+ payload: Bytes,
+ ) -> Result<(), async_nats::PublishError> {
+ self.client.publish(subject, payload).await
+ }
+
+ /// Best-effort liveness check via a round-trip flush.
+ ///
+ /// Returns true if the connection flushed successfully.
+ pub async fn ping(&self) -> bool {
+ self.client.flush().await.is_ok()
+ }
+}
backend/src/bus/subjects.rs +26 -0
@@ -0,0 +1,26 @@
+//! NATS subject constants and helpers.
+
+use crate::model::DropType;
+
+/// Status/health events for sources.
+pub const SOURCE_HEALTH: &str = "source.health";
+/// Generic system informational events.
+pub const SYSTEM_INFO: &str = "system.info";
+
+/// Subject for a given drop type, e.g. `drop.status`.
+#[must_use]
+pub fn drop_subject(t: DropType) -> &'static str {
+ match t {
+ DropType::New => "drop.new",
+ DropType::Status => "drop.status",
+ DropType::Price => "drop.price",
+ DropType::Inventory => "drop.inventory",
+ DropType::Announcement => "drop.announcement",
+ }
+}
+
+/// Wake subject for a source's fast-path short-circuit (§C.2).
+#[must_use]
+pub fn wake_subject(source_id: &str) -> String {
+ format!("wake.{source_id}")
+}
backend/src/cache/mod.rs +85 -0
@@ -0,0 +1,85 @@
+//! Redis wrapper: token-bucket rate limiting and dedupe keys (CONTRACTS §C, §I.3).
+
+use std::time::Duration;
+
+use redis::aio::ConnectionManager;
+use redis::AsyncCommands;
+
+/// Shared Redis handle (uses a multiplexed connection manager).
+#[derive(Clone)]
+pub struct Cache {
+ conn: ConnectionManager,
+}
+
+impl Cache {
+ /// Connect to Redis at `url`.
+ ///
+ /// # Errors
+ /// Returns an error if the client cannot be built or the manager cannot
+ /// establish its initial connection.
+ pub async fn connect(url: &str) -> Result<Self, redis::RedisError> {
+ let client = redis::Client::open(url)?;
+ let conn = ConnectionManager::new(client).await?;
+ Ok(Cache { conn })
+ }
+
+ /// Block until a rate-limit token is available for `key`, then consume one.
+ ///
+ /// Polls a simple fixed-window counter (1s window). This is the scheduler's
+ /// per-source limiter; the window keeps us well under upstream daily caps.
+ pub async fn acquire_token(&self, key: &str, max_per_window: u32, window: Duration) {
+ loop {
+ match self.try_acquire(key, max_per_window, window).await {
+ Ok(true) => return,
+ Ok(false) | Err(_) => {
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ }
+ }
+ }
+ }
+
+ /// Try to consume a token from a fixed-window counter without blocking.
+ ///
+ /// # Errors
+ /// Returns the Redis error if the operations fail.
+ pub async fn try_acquire(
+ &self,
+ key: &str,
+ max_per_window: u32,
+ window: Duration,
+ ) -> Result<bool, redis::RedisError> {
+ let mut conn = self.conn.clone();
+ let full_key = format!("rl:{key}");
+ let count: u32 = conn.incr(&full_key, 1u32).await?;
+ if count == 1 {
+ let _: () = conn
+ .expire(&full_key, window.as_secs().max(1) as i64)
+ .await?;
+ }
+ Ok(count <= max_per_window)
+ }
+
+ /// Atomically set a dedupe key if absent. Returns true if this caller "won"
+ /// (key did not exist) and the notification should proceed (§I.3).
+ ///
+ /// # Errors
+ /// Returns the Redis error if the SET fails.
+ pub async fn dedupe_acquire(&self, key: &str, ttl: Duration) -> Result<bool, redis::RedisError> {
+ let mut conn = self.conn.clone();
+ let opts = redis::SetOptions::default()
+ .conditional_set(redis::ExistenceCheck::NX)
+ .with_expiration(redis::SetExpiry::EX(ttl.as_secs().max(1)));
+ let res: Option<String> = conn.set_options(key, "1", opts).await?;
+ Ok(res.is_some())
+ }
+
+ /// Liveness check.
+ ///
+ /// # Errors
+ /// Returns the Redis error if PING fails.
+ pub async fn ping(&self) -> Result<(), redis::RedisError> {
+ let mut conn = self.conn.clone();
+ let _: String = redis::cmd("PING").query_async(&mut conn).await?;
+ Ok(())
+ }
+}
backend/src/config.rs +114 -0
@@ -0,0 +1,114 @@
+//! Configuration surface. Loaded from environment per CONTRACTS §Y.1.
+
+use figment::providers::Env;
+use figment::Figment;
+use serde::Deserialize;
+
+/// Fully resolved backend configuration.
+#[derive(Debug, Clone, Deserialize)]
+pub struct Config {
+ pub database_url: String,
+ pub redis_url: String,
+ pub nats_url: String,
+
+ #[serde(default = "default_meili_url")]
+ pub meili_url: String,
+ #[serde(default)]
+ pub meili_master_key: Option<String>,
+
+ #[serde(default = "default_ntfy_base_url")]
+ pub ntfy_base_url: String,
+ #[serde(default = "default_ntfy_topic")]
+ pub ntfy_topic: String,
+ #[serde(default)]
+ pub ntfy_token: Option<String>,
+
+ #[serde(default = "default_log_level")]
+ pub log_level: String,
+
+ #[serde(default)]
+ pub public_base_url: Option<String>,
+
+ /// Comma-separated Ticketmaster API keys (rotated round-robin).
+ #[serde(default)]
+ pub ticketmaster_api_keys: Option<String>,
+ #[serde(default)]
+ pub ticketmaster_affiliate_id: Option<String>,
+
+ #[serde(default = "default_api_rate_limit")]
+ pub api_rate_limit_per_min: u32,
+ #[serde(default = "default_sse_max_clients")]
+ pub sse_max_clients: usize,
+ #[serde(default = "default_watchlist_max_rules")]
+ pub watchlist_max_rules: i64,
+
+ #[serde(default = "default_poll_interval")]
+ pub poll_default_interval_s: u64,
+ #[serde(default = "default_tight_interval")]
+ pub poll_tight_interval_s: u64,
+ #[serde(default = "default_tight_window")]
+ pub poll_tight_window_s: u64,
+
+ /// HTTP bind address for the Axum server.
+ #[serde(default = "default_bind_addr")]
+ pub bind_addr: String,
+}
+
+fn default_meili_url() -> String {
+ "http://meilisearch:7700".to_string()
+}
+fn default_ntfy_base_url() -> String {
+ "http://ntfy:80".to_string()
+}
+fn default_ntfy_topic() -> String {
+ "drophawk-drops".to_string()
+}
+fn default_log_level() -> String {
+ "info".to_string()
+}
+fn default_api_rate_limit() -> u32 {
+ 100
+}
+fn default_sse_max_clients() -> usize {
+ 200
+}
+fn default_watchlist_max_rules() -> i64 {
+ 500
+}
+fn default_poll_interval() -> u64 {
+ 300
+}
+fn default_tight_interval() -> u64 {
+ 10
+}
+fn default_tight_window() -> u64 {
+ 900
+}
+fn default_bind_addr() -> String {
+ "0.0.0.0:8080".to_string()
+}
+
+impl Config {
+ /// Load configuration from the process environment.
+ ///
+ /// # Errors
+ /// Returns an error if a required key is missing or a value fails to parse.
+ pub fn from_env() -> Result<Self, figment::Error> {
+ Figment::new().merge(Env::raw()).extract()
+ }
+
+ /// Parsed list of Ticketmaster API keys (empty if unset).
+ #[must_use]
+ pub fn ticketmaster_keys(&self) -> Vec<String> {
+ self.ticketmaster_api_keys
+ .as_deref()
+ .map(|raw| {
+ raw.split(',')
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(ToString::to_string)
+ .collect()
+ })
+ .unwrap_or_default()
+ }
+}
backend/src/detect/classify.rs +42 -0
@@ -0,0 +1,42 @@
+//! Drop classification thresholds (CONTRACTS §E).
+//!
+//! Pure functions over previous/next field values so they can be unit-tested in
+//! isolation from the store.
+
+/// Price-drop threshold: `price_min` decreased by at least 10%.
+pub const PRICE_DROP_PCT: i64 = 10;
+
+/// Inventory-drop threshold: `inventory_remaining` decreased by at least 25%.
+pub const INVENTORY_DROP_PCT: i64 = 25;
+
+/// True if `new_min` is a price drop of at least [`PRICE_DROP_PCT`] vs `prev_min`.
+#[must_use]
+pub fn is_price_drop(prev_min: i64, new_min: i64) -> bool {
+ new_min < prev_min && (prev_min - new_min) * 100 / prev_min.max(1) >= PRICE_DROP_PCT
+}
+
+/// True if `new_inv` is an inventory drop of at least [`INVENTORY_DROP_PCT`].
+#[must_use]
+pub fn is_inventory_drop(prev_inv: i64, new_inv: i64) -> bool {
+ new_inv < prev_inv && (prev_inv - new_inv) * 100 / prev_inv.max(1) >= INVENTORY_DROP_PCT
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{is_inventory_drop, is_price_drop};
+
+ #[test]
+ fn price_drop_boundary() {
+ assert!(is_price_drop(10000, 9000)); // exactly 10%
+ assert!(!is_price_drop(10000, 9100)); // 9% - no
+ assert!(!is_price_drop(10000, 10000)); // unchanged
+ assert!(!is_price_drop(9000, 10000)); // increase
+ }
+
+ #[test]
+ fn inventory_drop_boundary() {
+ assert!(is_inventory_drop(100, 75)); // exactly 25%
+ assert!(!is_inventory_drop(100, 80)); // 20% - no
+ assert!(!is_inventory_drop(100, 100)); // unchanged
+ }
+}
backend/src/detect/hash.rs +35 -0
@@ -0,0 +1,35 @@
+//! Content hash canonicalization (CONTRACTS §D - regression gate).
+//!
+//! The hash is SHA-256 over compact canonical JSON of a fixed, alphabetically
+//! ordered field set with the normalizations specified in §D.2. The crate's
+//! `serde_json` is built with `preserve_key_order`, and the object below is
+//! constructed in alphabetical key order, guaranteeing deterministic bytes.
+
+use chrono::SecondsFormat;
+use serde_json::json;
+use sha2::{Digest, Sha256};
+
+use crate::model::EventCandidate;
+
+/// Compute the canonical content hash of an event candidate.
+///
+/// Field set (and only these): `inventory_remaining`, `on_sale_at`,
+/// `presale_on_sale_at`, `price_max`, `price_min`, `source_event_id`, `status`.
+#[must_use]
+pub fn content_hash(c: &EventCandidate) -> String {
+ let canonical = json!({
+ "inventory_remaining": c.inventory_remaining.map(i64::from),
+ "on_sale_at": c.on_sale_at.map(|dt| dt.to_rfc3339_opts(SecondsFormat::Millis, true)),
+ "presale_on_sale_at": c.presale_on_sale_at.map(|dt| dt.to_rfc3339_opts(SecondsFormat::Millis, true)),
+ "price_max": c.price_max,
+ "price_min": c.price_min,
+ "source_event_id": c.source_event_id.trim(),
+ "status": c.status.as_str().to_lowercase(),
+ });
+ // serde_json::Map preserves insertion order (preserve_key_order feature);
+ // the object above is built in alphabetical order, so the bytes are stable.
+ let bytes = serde_json::to_vec(&canonical).unwrap_or_default();
+ let mut hasher = Sha256::new();
+ hasher.update(&bytes);
+ hex::encode(hasher.finalize())
+}
backend/src/detect/mod.rs +163 -0
@@ -0,0 +1,163 @@
+//! Change detection and drop emission (CONTRACTS §D + §E).
+
+pub mod classify;
+pub mod hash;
+
+pub use hash::content_hash;
+
+use serde_json::json;
+use std::sync::Arc;
+
+use crate::bus::subjects;
+use crate::error::AppResult;
+use crate::model::{DropEvent, DropType, EventCandidate};
+use crate::notify;
+use crate::state::AppState;
+use crate::store::event::PersistOutcome;
+
+/// Ingest one candidate: persist, diff against the prior content hash, and emit
+/// any resulting drops. Mirrors the §E reference algorithm exactly.
+///
+/// # Errors
+/// Propagates store / bus / cache errors so the caller can record a failure.
+pub async fn ingest_candidate(state: &Arc<AppState>, c: &EventCandidate) -> AppResult<()> {
+ let new_hash = content_hash(c);
+
+ let outcome = state.store.events.upsert(c, &new_hash).await?;
+
+ match outcome {
+ PersistOutcome::Unchanged => Ok(()),
+ PersistOutcome::New { event_id } => {
+ // Record an initial price observation for history.
+ state
+ .store
+ .events
+ .record_price(event_id, c)
+ .await
+ .ok();
+ emit_drop(
+ state,
+ DropEvent {
+ event_id,
+ drop_type: DropType::New,
+ from_state: json!(null),
+ to_state: json!({
+ "status": c.status.as_str(),
+ "on_sale_at": c.on_sale_at,
+ }),
+ },
+ )
+ .await?;
+ Ok(())
+ }
+ PersistOutcome::Changed { event_id, prev } => {
+ state
+ .store
+ .events
+ .record_price(event_id, c)
+ .await
+ .ok();
+
+ // Status flip.
+ if prev.status != c.status.as_str() {
+ emit_drop(
+ state,
+ DropEvent {
+ event_id,
+ drop_type: DropType::Status,
+ from_state: json!({ "status": prev.status }),
+ to_state: json!({ "status": c.status.as_str() }),
+ },
+ )
+ .await?;
+ }
+
+ // Price drop (>=10%).
+ if let (Some(prev_min), Some(new_min)) = (prev.price_min, c.price_min) {
+ let prev_min = i64::from(prev_min);
+ if classify::is_price_drop(prev_min, new_min) {
+ emit_drop(
+ state,
+ DropEvent {
+ event_id,
+ drop_type: DropType::Price,
+ from_state: json!({ "price_min": prev_min }),
+ to_state: json!({ "price_min": new_min }),
+ },
+ )
+ .await?;
+ }
+ }
+
+ // Inventory drop (>=25%).
+ if let (Some(prev_inv), Some(new_inv)) = (prev.inventory_remaining, c.inventory_remaining)
+ {
+ let prev_inv = i64::from(prev_inv);
+ let new_inv = i64::from(new_inv);
+ if classify::is_inventory_drop(prev_inv, new_inv) {
+ emit_drop(
+ state,
+ DropEvent {
+ event_id,
+ drop_type: DropType::Inventory,
+ from_state: json!({ "inventory_remaining": prev_inv }),
+ to_state: json!({ "inventory_remaining": new_inv }),
+ },
+ )
+ .await?;
+ }
+ }
+
+ Ok(())
+ }
+ }
+}
+
+/// Persist a drop, publish to NATS, broadcast over SSE, and run the notification
+/// pipeline. A single candidate change may call this multiple times (§E).
+///
+/// # Errors
+/// Propagates store / serialization errors. Bus and SSE failures are logged and
+/// swallowed so one transport never blocks the others.
+pub async fn emit_drop(state: &Arc<AppState>, ev: DropEvent) -> AppResult<()> {
+ let drop_type = ev.drop_type;
+
+ // 1. Persist and fetch the embedded event for the wire payload.
+ let drop = state.store.drops.insert(&ev).await?;
+ let event: Option<crate::model::Event> =
+ state.store.events.by_id(ev.event_id).await?.map(Into::into);
+ let api_drop = crate::model::Drop::from_row(drop, event.clone());
+
+ // 2. Metrics.
+ let category = event
+ .as_ref()
+ .map_or_else(|| "unknown".to_string(), |e| e.category.clone());
+ let source = event
+ .as_ref()
+ .map_or_else(|| "unknown".to_string(), |e| e.source_id.clone());
+ state
+ .metrics
+ .drops_total
+ .with_label_values(&[drop_type.as_str(), &category, &source])
+ .inc();
+
+ // 3. NATS publish.
+ let payload = serde_json::to_vec(&api_drop)?;
+ if let Err(e) = state
+ .bus
+ .publish(subjects::drop_subject(drop_type), payload.into())
+ .await
+ {
+ tracing::warn!(error = %e, "failed to publish drop to NATS");
+ }
+
+ // 4. SSE broadcast.
+ state.sse.broadcast(&api_drop, drop_type);
+
+ // 5. Notification pipeline (§5.5 / §I).
+ if let Err(e) = notify::on_drop(state, &api_drop).await {
+ tracing::warn!(error = %e, drop_id = api_drop.id, "notification pipeline error");
+ }
+
+ Ok(())
+}
backend/src/error.rs +121 -0
@@ -0,0 +1,121 @@
+//! Application error type and HTTP mapping (CONTRACTS §K + §B.1/§B.2).
+
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Response};
+use axum::Json;
+use serde_json::json;
+use thiserror::Error;
+
+/// Unified application error.
+#[derive(Debug, Error)]
+pub enum AppError {
+ #[error("database error: {0}")]
+ Db(#[from] sqlx::Error),
+
+ #[error("nats publish error: {0}")]
+ Bus(#[from] async_nats::PublishError),
+
+ #[error("redis error: {0}")]
+ Cache(#[from] redis::RedisError),
+
+ #[error("http client error: {0}")]
+ Http(#[from] reqwest::Error),
+
+ #[error("json error: {0}")]
+ Json(#[from] serde_json::Error),
+
+ #[error("validation failed: {0}")]
+ Validation(String),
+
+ #[error("not found: {0}")]
+ NotFound(String),
+
+ #[error("conflict: {0}")]
+ Conflict(String),
+
+ #[error("rate limited")]
+ RateLimited,
+
+ #[error("upstream unavailable: {0}")]
+ Upstream(String),
+
+ #[error("internal error: {0}")]
+ Internal(#[from] anyhow::Error),
+}
+
+impl AppError {
+ /// HTTP status code per CONTRACTS §B.2.
+ #[must_use]
+ pub fn status_code(&self) -> u16 {
+ use AppError::{Conflict, NotFound, RateLimited, Upstream, Validation};
+ match self {
+ Validation(_) => 422,
+ NotFound(_) => 404,
+ Conflict(_) => 409,
+ RateLimited => 429,
+ Upstream(_) => 503,
+ _ => 500,
+ }
+ }
+
+ /// Stable machine-readable error code per CONTRACTS §K.
+ #[must_use]
+ pub fn code(&self) -> &'static str {
+ use AppError::{
+ Bus, Cache, Conflict, Db, Http, Internal, Json, NotFound, RateLimited, Upstream,
+ Validation,
+ };
+ match self {
+ Db(_) => "DB_ERROR",
+ Bus(_) => "BUS_ERROR",
+ Cache(_) => "CACHE_ERROR",
+ Http(_) => "HTTP_ERROR",
+ Json(_) => "JSON_ERROR",
+ Validation(_) => "VALIDATION_FAILED",
+ NotFound(_) => "NOT_FOUND",
+ Conflict(_) => "CONFLICT",
+ RateLimited => "RATE_LIMITED",
+ Upstream(_) => "UPSTREAM_UNAVAILABLE",
+ Internal(_) => "INTERNAL",
+ }
+ }
+
+ /// Public-safe message. Internal errors never leak their detail to clients.
+ #[must_use]
+ pub fn public_message(&self) -> String {
+ match self {
+ AppError::Validation(_)
+ | AppError::NotFound(_)
+ | AppError::Conflict(_)
+ | AppError::Upstream(_) => self.to_string(),
+ AppError::RateLimited => "rate limited".to_string(),
+ _ => "internal error".to_string(),
+ }
+ }
+}
+
+impl IntoResponse for AppError {
+ fn into_response(self) -> Response {
+ let code = self.code();
+ let message = self.public_message();
+ let status =
+ StatusCode::from_u16(self.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
+ if status.is_server_error() {
+ tracing::error!(error = %self, code, "request failed");
+ } else {
+ tracing::warn!(code, %message, "request rejected");
+ }
+ let request_id = uuid::Uuid::now_v7().to_string();
+ let body = Json(json!({
+ "error": {
+ "code": code,
+ "message": message,
+ "request_id": request_id,
+ }
+ }));
+ (status, body).into_response()
+ }
+}
+
+/// Convenient result alias.
+pub type AppResult<T> = Result<T, AppError>;
backend/src/lib.rs +118 -0
@@ -0,0 +1,118 @@
+#![forbid(unsafe_code)]
+//! DropHawk backend library crate.
+//!
+//! The binary (`main.rs`) is a thin wrapper around [`run`]; modules are exposed
+//! here so integration tests (e.g. the §D golden hash vector) can reach them.
+
+pub mod api;
+pub mod bus;
+pub mod cache;
+pub mod config;
+pub mod detect;
+pub mod error;
+pub mod metrics;
+pub mod model;
+pub mod notify;
+pub mod scheduler;
+pub mod sources;
+pub mod state;
+pub mod store;
+
+use std::net::SocketAddr;
+
+use anyhow::Context;
+
+use crate::config::Config;
+use crate::scheduler::Scheduler;
+use crate::state::AppState;
+
+/// Boot the whole service: config -> deps -> migrate -> scheduler -> serve.
+///
+/// # Errors
+/// Returns an error if configuration, any dependency connection, migrations, or
+/// binding the listener fails.
+pub async fn run() -> anyhow::Result<()> {
+ let config = Config::from_env().context("failed to load configuration")?;
+ init_tracing(&config.log_level);
+
+ tracing::info!("DropHawk backend starting");
+
+ let state = AppState::build(config.clone())
+ .await
+ .context("failed to initialize application state")?;
+
+ state
+ .store
+ .migrate()
+ .await
+ .context("database migrations failed")?;
+ tracing::info!("migrations applied");
+
+ let scheduler = Scheduler::start(state.clone())
+ .await
+ .context("failed to start scheduler")?;
+
+ let app = api::router(state.clone());
+ let addr: SocketAddr = config
+ .bind_addr
+ .parse()
+ .with_context(|| format!("invalid BIND_ADDR '{}'", config.bind_addr))?;
+ let listener = tokio::net::TcpListener::bind(addr)
+ .await
+ .with_context(|| format!("failed to bind {addr}"))?;
+ tracing::info!(%addr, "listening");
+
+ let server = axum::serve(
+ listener,
+ app.into_make_service_with_connect_info::<SocketAddr>(),
+ )
+ .with_graceful_shutdown(shutdown_signal());
+
+ if let Err(e) = server.await {
+ tracing::error!(error = %e, "server error");
+ }
+
+ tracing::info!("shutting down scheduler");
+ scheduler.shutdown().await;
+ tracing::info!("shutdown complete");
+ Ok(())
+}
+
+/// Initialize structured logging from `LOG_LEVEL` (with `RUST_LOG` override).
+fn init_tracing(log_level: &str) {
+ use tracing_subscriber::prelude::*;
+ use tracing_subscriber::{fmt, EnvFilter};
+
+ let filter = EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| EnvFilter::new(format!("drophawk_backend={log_level},info")));
+ let _ = tracing_subscriber::registry()
+ .with(filter)
+ .with(fmt::layer().json())
+ .try_init();
+}
+
+/// Resolve on SIGINT (Ctrl-C) or SIGTERM.
+async fn shutdown_signal() {
+ let ctrl_c = async {
+ let _ = tokio::signal::ctrl_c().await;
+ };
+
+ #[cfg(unix)]
+ let terminate = async {
+ match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
+ Ok(mut sig) => {
+ sig.recv().await;
+ }
+ Err(e) => tracing::warn!(error = %e, "failed to install SIGTERM handler"),
+ }
+ };
+
+ #[cfg(not(unix))]
+ let terminate = std::future::pending::<()>();
+
+ tokio::select! {
+ () = ctrl_c => {}
+ () = terminate => {}
+ }
+ tracing::info!("shutdown signal received");
+}
backend/src/main.rs +7 -0
@@ -0,0 +1,7 @@
+#![forbid(unsafe_code)]
+//! DropHawk backend binary entry point. Delegates to the library `run`.
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ drophawk_backend::run().await
+}
backend/src/metrics.rs +137 -0
@@ -0,0 +1,137 @@
+//! Prometheus metrics catalog (CONTRACTS §J - Phase-1-relevant subset).
+
+use prometheus::{
+ Histogram, HistogramOpts, HistogramVec, IntCounterVec, IntGauge, Opts, Registry,
+};
+
+/// Standard millisecond histogram buckets (§J).
+const MS_BUCKETS: &[f64] = &[
+ 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, 10000.0, 30000.0, 60000.0,
+];
+
+/// All registered metrics plus the registry for scraping.
+pub struct Metrics {
+ pub registry: Registry,
+
+ // Counters
+ pub source_poll_total: IntCounterVec,
+ pub drops_total: IntCounterVec,
+ pub notifications_total: IntCounterVec,
+
+ // Gauges
+ pub sse_connections: IntGauge,
+ pub events_total: IntGauge,
+ pub watchlist_rules_active: IntGauge,
+ pub db_connections_in_use: IntGauge,
+
+ // Histograms
+ pub source_poll_duration_ms: HistogramVec,
+ pub detection_latency_ms: HistogramVec,
+ pub notify_latency_ms: HistogramVec,
+ pub http_request_duration_ms: HistogramVec,
+}
+
+impl Metrics {
+ /// Build and register every metric. Fails only on duplicate registration,
+ /// which is a programming error caught at startup.
+ ///
+ /// # Errors
+ /// Returns the Prometheus error if a metric fails to register.
+ pub fn new() -> Result<Self, prometheus::Error> {
+ let registry = Registry::new();
+
+ let source_poll_total = IntCounterVec::new(
+ Opts::new("drophawk_source_poll_total", "Polls by source and result"),
+ &["source", "result"],
+ )?;
+ let drops_total = IntCounterVec::new(
+ Opts::new("drophawk_drops_total", "Drops emitted"),
+ &["type", "category", "source"],
+ )?;
+ let notifications_total = IntCounterVec::new(
+ Opts::new("drophawk_notifications_total", "Notification attempts"),
+ &["channel", "priority", "result"],
+ )?;
+ let sse_connections = IntGauge::new(
+ "drophawk_sse_connections",
+ "Current open SSE connections",
+ )?;
+ let events_total = IntGauge::new("drophawk_events_total", "Rows in events table")?;
+ let watchlist_rules_active = IntGauge::new(
+ "drophawk_watchlist_rules_active",
+ "Active watchlist rules",
+ )?;
+ let db_connections_in_use = IntGauge::new(
+ "drophawk_db_connections_in_use",
+ "Postgres pool connections in use",
+ )?;
+
+ let source_poll_duration_ms = HistogramVec::new(
+ HistogramOpts::new("drophawk_source_poll_duration_ms", "Poll latency")
+ .buckets(MS_BUCKETS.to_vec()),
+ &["source"],
+ )?;
+ let detection_latency_ms = HistogramVec::new(
+ HistogramOpts::new("drophawk_detection_latency_ms", "Drop to notification latency")
+ .buckets(MS_BUCKETS.to_vec()),
+ &["type"],
+ )?;
+ let notify_latency_ms = HistogramVec::new(
+ HistogramOpts::new("drophawk_notify_latency_ms", "Notify send to ack")
+ .buckets(MS_BUCKETS.to_vec()),
+ &["channel"],
+ )?;
+ let http_request_duration_ms = HistogramVec::new(
+ HistogramOpts::new("drophawk_http_request_duration_ms", "HTTP request latency")
+ .buckets(MS_BUCKETS.to_vec()),
+ &["method", "route", "status"],
+ )?;
+
+ registry.register(Box::new(source_poll_total.clone()))?;
+ registry.register(Box::new(drops_total.clone()))?;
+ registry.register(Box::new(notifications_total.clone()))?;
+ registry.register(Box::new(sse_connections.clone()))?;
+ registry.register(Box::new(events_total.clone()))?;
+ registry.register(Box::new(watchlist_rules_active.clone()))?;
+ registry.register(Box::new(db_connections_in_use.clone()))?;
+ registry.register(Box::new(source_poll_duration_ms.clone()))?;
+ registry.register(Box::new(detection_latency_ms.clone()))?;
+ registry.register(Box::new(notify_latency_ms.clone()))?;
+ registry.register(Box::new(http_request_duration_ms.clone()))?;
+
+ Ok(Metrics {
+ registry,
+ source_poll_total,
+ drops_total,
+ notifications_total,
+ sse_connections,
+ events_total,
+ watchlist_rules_active,
+ db_connections_in_use,
+ source_poll_duration_ms,
+ detection_latency_ms,
+ notify_latency_ms,
+ http_request_duration_ms,
+ })
+ }
+
+ /// A standalone histogram with the standard buckets (helper for ad-hoc timing).
+ ///
+ /// # Errors
+ /// Returns the Prometheus error if the histogram cannot be created.
+ pub fn standard_histogram(name: &str, help: &str) -> Result<Histogram, prometheus::Error> {
+ Histogram::with_opts(HistogramOpts::new(name, help).buckets(MS_BUCKETS.to_vec()))
+ }
+
+ /// Render the registry in Prometheus text exposition format.
+ #[must_use]
+ pub fn render(&self) -> String {
+ use prometheus::Encoder;
+ let encoder = prometheus::TextEncoder::new();
+ let mut buf = Vec::new();
+ if encoder.encode(&self.registry.gather(), &mut buf).is_err() {
+ return String::new();
+ }
+ String::from_utf8(buf).unwrap_or_default()
+ }
+}
backend/src/model/drop.rs +62 -0
@@ -0,0 +1,62 @@
+//! Drop models: the emit payload, the DB row, and the API view (with embedded event).
+
+use chrono::{DateTime, Utc};
+use serde::Serialize;
+use serde_json::Value;
+
+use super::event::Event;
+use super::DropType;
+
+/// Pre-persistence drop payload produced by the detector (CONTRACTS §E).
+#[derive(Debug, Clone)]
+pub struct DropEvent {
+ pub event_id: i64,
+ pub drop_type: DropType,
+ pub from_state: Value,
+ pub to_state: Value,
+}
+
+/// Raw `drops` table row.
+#[derive(Debug, Clone, sqlx::FromRow)]
+pub struct DropRow {
+ pub id: i64,
+ pub event_id: i64,
+ pub drop_type: String,
+ pub from_state: Option<Value>,
+ pub to_state: Value,
+ pub detected_at: DateTime<Utc>,
+ pub notified: bool,
+ pub notified_at: Option<DateTime<Utc>>,
+}
+
+/// API representation of a drop with an embedded event (CONTRACTS §B.3).
+#[derive(Debug, Clone, Serialize)]
+pub struct Drop {
+ pub id: i64,
+ pub event_id: i64,
+ pub drop_type: String,
+ pub from_state: Option<Value>,
+ pub to_state: Value,
+ pub detected_at: DateTime<Utc>,
+ pub notified: bool,
+ pub notified_at: Option<DateTime<Utc>>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub event: Option<Event>,
+}
+
+impl Drop {
+ #[must_use]
+ pub fn from_row(row: DropRow, event: Option<Event>) -> Self {
+ Drop {
+ id: row.id,
+ event_id: row.event_id,
+ drop_type: row.drop_type,
+ from_state: row.from_state,
+ to_state: row.to_state,
+ detected_at: row.detected_at,
+ notified: row.notified,
+ notified_at: row.notified_at,
+ event,
+ }
+ }
+}
backend/src/model/event.rs +135 -0
@@ -0,0 +1,135 @@
+//! Event models: the normalized candidate, the DB row, and the API view.
+
+use chrono::{DateTime, Utc};
+use serde::Serialize;
+use serde_json::Value;
+
+use super::{EventCategory, SaleStatus, Sport};
+
+/// Normalized output of a `SourceAdapter::poll`. Pre-persistence shape.
+///
+/// `price_*` are integer minor units (cents). `inventory_remaining` is a count.
+#[derive(Debug, Clone)]
+pub struct EventCandidate {
+ pub source_id: String,
+ pub source_event_id: String,
+ pub title: String,
+ pub subtitle: Option<String>,
+ pub category: EventCategory,
+ pub sport: Option<Sport>,
+ pub starts_at: Option<DateTime<Utc>>,
+ pub venue_name: Option<String>,
+ pub city: Option<String>,
+ pub country: Option<String>,
+ pub latitude: Option<f64>,
+ pub longitude: Option<f64>,
+ pub image_url: Option<String>,
+ pub on_sale_at: Option<DateTime<Utc>>,
+ pub presale_on_sale_at: Option<DateTime<Utc>>,
+ pub status: SaleStatus,
+ pub price_min: Option<i64>,
+ pub price_max: Option<i64>,
+ pub currency: Option<String>,
+ pub inventory_remaining: Option<u32>,
+ pub buy_url: String,
+ pub raw: Value,
+}
+
+/// Raw `events` table row used for diffing and serialization.
+#[derive(Debug, Clone, sqlx::FromRow)]
+pub struct EventRow {
+ pub id: i64,
+ pub source_id: String,
+ pub source_event_id: String,
+ pub title: String,
+ pub subtitle: Option<String>,
+ pub category: String,
+ pub sport: Option<String>,
+ pub starts_at: Option<DateTime<Utc>>,
+ pub venue_name: Option<String>,
+ pub city: Option<String>,
+ pub country: Option<String>,
+ pub latitude: Option<f64>,
+ pub longitude: Option<f64>,
+ pub image_url: Option<String>,
+ pub on_sale_at: Option<DateTime<Utc>>,
+ pub presale_on_sale_at: Option<DateTime<Utc>>,
+ pub status: String,
+ pub price_min: Option<i32>,
+ pub price_max: Option<i32>,
+ pub currency: Option<String>,
+ pub inventory_remaining: Option<i32>,
+ pub buy_url: String,
+ pub content_hash: String,
+ pub first_seen_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// API representation of an event (CONTRACTS §B.3 `data[]` element).
+#[derive(Debug, Clone, Serialize)]
+pub struct Event {
+ pub id: i64,
+ pub source_id: String,
+ pub source_event_id: String,
+ pub title: String,
+ pub subtitle: Option<String>,
+ pub category: String,
+ pub sport: Option<String>,
+ pub starts_at: Option<DateTime<Utc>>,
+ pub venue_name: Option<String>,
+ pub city: Option<String>,
+ pub country: Option<String>,
+ pub latitude: Option<f64>,
+ pub longitude: Option<f64>,
+ pub image_url: Option<String>,
+ pub on_sale_at: Option<DateTime<Utc>>,
+ pub presale_on_sale_at: Option<DateTime<Utc>>,
+ pub status: String,
+ pub price_min: Option<i32>,
+ pub price_max: Option<i32>,
+ pub currency: Option<String>,
+ pub inventory_remaining: Option<i32>,
+ pub buy_url: String,
+ pub first_seen_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+impl From<EventRow> for Event {
+ fn from(r: EventRow) -> Self {
+ Event {
+ id: r.id,
+ source_id: r.source_id,
+ source_event_id: r.source_event_id,
+ title: r.title,
+ subtitle: r.subtitle,
+ category: r.category,
+ sport: r.sport,
+ starts_at: r.starts_at,
+ venue_name: r.venue_name,
+ city: r.city,
+ country: r.country,
+ latitude: r.latitude,
+ longitude: r.longitude,
+ image_url: r.image_url,
+ on_sale_at: r.on_sale_at,
+ presale_on_sale_at: r.presale_on_sale_at,
+ status: r.status,
+ price_min: r.price_min,
+ price_max: r.price_max,
+ currency: r.currency,
+ inventory_remaining: r.inventory_remaining,
+ buy_url: r.buy_url,
+ first_seen_at: r.first_seen_at,
+ updated_at: r.updated_at,
+ }
+ }
+}
+
+/// A point on an event's price timeline (`GET /api/events/:id`).
+#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
+pub struct PricePoint {
+ pub observed_at: DateTime<Utc>,
+ pub price_min: Option<i32>,
+ pub price_max: Option<i32>,
+ pub currency: Option<String>,
+}
backend/src/model/mod.rs +222 -0
@@ -0,0 +1,222 @@
+//! Domain models: shared enums, request/response shapes, and DB row types.
+//!
+//! Money is always represented in integer minor units (cents). Timestamps are
+//! `chrono::DateTime<Utc>` and serialize as RFC3339 with a `Z` suffix.
+
+pub mod drop;
+pub mod event;
+pub mod source;
+pub mod watchlist;
+
+use serde::{Deserialize, Serialize};
+
+pub use drop::{Drop, DropEvent, DropRow};
+pub use event::{Event, EventCandidate, EventRow};
+pub use source::{Source, SourceCategory, SourceHealthSample, SourceRow};
+pub use watchlist::{
+ CreateWatchlistRule, UpdateWatchlistRule, WatchlistBulkRequest, WatchlistRow, WatchlistRule,
+};
+
+/// UI / DB event category.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum EventCategory {
+ Concert,
+ Sports,
+ Festival,
+ Theater,
+ Other,
+}
+
+impl EventCategory {
+ #[must_use]
+ pub fn as_str(self) -> &'static str {
+ match self {
+ EventCategory::Concert => "concert",
+ EventCategory::Sports => "sports",
+ EventCategory::Festival => "festival",
+ EventCategory::Theater => "theater",
+ EventCategory::Other => "other",
+ }
+ }
+
+ #[must_use]
+ pub fn parse(s: &str) -> Option<Self> {
+ match s {
+ "concert" => Some(EventCategory::Concert),
+ "sports" => Some(EventCategory::Sports),
+ "festival" => Some(EventCategory::Festival),
+ "theater" => Some(EventCategory::Theater),
+ "other" => Some(EventCategory::Other),
+ _ => None,
+ }
+ }
+}
+
+/// Sport discriminator (nullable on non-sports events).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Sport {
+ Nfl,
+ Nba,
+ Nhl,
+ Mlb,
+ Tennis,
+ F1,
+ Soccer,
+ Golf,
+ Boxing,
+ Other,
+}
+
+impl Sport {
+ #[must_use]
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Sport::Nfl => "nfl",
+ Sport::Nba => "nba",
+ Sport::Nhl => "nhl",
+ Sport::Mlb => "mlb",
+ Sport::Tennis => "tennis",
+ Sport::F1 => "f1",
+ Sport::Soccer => "soccer",
+ Sport::Golf => "golf",
+ Sport::Boxing => "boxing",
+ Sport::Other => "other",
+ }
+ }
+
+ #[must_use]
+ pub fn parse(s: &str) -> Option<Self> {
+ match s {
+ "nfl" => Some(Sport::Nfl),
+ "nba" => Some(Sport::Nba),
+ "nhl" => Some(Sport::Nhl),
+ "mlb" => Some(Sport::Mlb),
+ "tennis" => Some(Sport::Tennis),
+ "f1" => Some(Sport::F1),
+ "soccer" => Some(Sport::Soccer),
+ "golf" => Some(Sport::Golf),
+ "boxing" => Some(Sport::Boxing),
+ "other" => Some(Sport::Other),
+ _ => None,
+ }
+ }
+}
+
+/// Sale lifecycle status.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum SaleStatus {
+ Announced,
+ Presale,
+ Onsale,
+ Soldout,
+ Cancelled,
+}
+
+impl SaleStatus {
+ #[must_use]
+ pub fn as_str(self) -> &'static str {
+ match self {
+ SaleStatus::Announced => "announced",
+ SaleStatus::Presale => "presale",
+ SaleStatus::Onsale => "onsale",
+ SaleStatus::Soldout => "soldout",
+ SaleStatus::Cancelled => "cancelled",
+ }
+ }
+
+ #[must_use]
+ pub fn parse(s: &str) -> Option<Self> {
+ match s {
+ "announced" => Some(SaleStatus::Announced),
+ "presale" => Some(SaleStatus::Presale),
+ "onsale" => Some(SaleStatus::Onsale),
+ "soldout" => Some(SaleStatus::Soldout),
+ "cancelled" => Some(SaleStatus::Cancelled),
+ _ => None,
+ }
+ }
+}
+
+/// Drop classification.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum DropType {
+ New,
+ Status,
+ Price,
+ Inventory,
+ Announcement,
+}
+
+impl DropType {
+ #[must_use]
+ pub fn as_str(self) -> &'static str {
+ match self {
+ DropType::New => "new",
+ DropType::Status => "status",
+ DropType::Price => "price",
+ DropType::Inventory => "inventory",
+ DropType::Announcement => "announcement",
+ }
+ }
+
+ #[must_use]
+ pub fn parse(s: &str) -> Option<Self> {
+ match s {
+ "new" => Some(DropType::New),
+ "status" => Some(DropType::Status),
+ "price" => Some(DropType::Price),
+ "inventory" => Some(DropType::Inventory),
+ "announcement" => Some(DropType::Announcement),
+ _ => None,
+ }
+ }
+
+ /// SSE event name, e.g. `drop.status`.
+ #[must_use]
+ pub fn sse_event(self) -> &'static str {
+ match self {
+ DropType::New => "drop.new",
+ DropType::Status => "drop.status",
+ DropType::Price => "drop.price",
+ DropType::Inventory => "drop.inventory",
+ DropType::Announcement => "drop.announcement",
+ }
+ }
+}
+
+/// Watchlist rule priority.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Priority {
+ Low,
+ Normal,
+ High,
+ Critical,
+}
+
+impl Priority {
+ #[must_use]
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Priority::Low => "low",
+ Priority::Normal => "normal",
+ Priority::High => "high",
+ Priority::Critical => "critical",
+ }
+ }
+
+ #[must_use]
+ pub fn parse(s: &str) -> Option<Self> {
+ match s {
+ "low" => Some(Priority::Low),
+ "normal" => Some(Priority::Normal),
+ "high" => Some(Priority::High),
+ "critical" => Some(Priority::Critical),
+ _ => None,
+ }
+ }
+}
backend/src/model/source.rs +114 -0
@@ -0,0 +1,114 @@
+//! Source models: DB row, API response, and health samples.
+
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+
+/// Coarse source category used for adapters and UI grouping.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum SourceCategory {
+ Primary,
+ Resale,
+ Sports,
+ Festival,
+ Social,
+}
+
+impl SourceCategory {
+ #[must_use]
+ pub fn as_str(self) -> &'static str {
+ match self {
+ SourceCategory::Primary => "primary",
+ SourceCategory::Resale => "resale",
+ SourceCategory::Sports => "sports",
+ SourceCategory::Festival => "festival",
+ SourceCategory::Social => "social",
+ }
+ }
+}
+
+/// Raw `sources` table row.
+#[derive(Debug, Clone, sqlx::FromRow)]
+pub struct SourceRow {
+ pub id: String,
+ pub name: String,
+ pub category: String,
+ pub enabled: bool,
+ pub status: String,
+ pub poll_interval_s: i32,
+ pub tight_interval_s: i32,
+ pub last_poll_at: Option<DateTime<Utc>>,
+ pub last_success_at: Option<DateTime<Utc>>,
+ pub last_error: Option<String>,
+ pub consecutive_failures: i32,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// API representation of a source (matches CONTRACTS §B.3 `GET /api/sources`).
+#[derive(Debug, Clone, Serialize)]
+pub struct Source {
+ pub id: String,
+ pub name: String,
+ pub category: String,
+ pub enabled: bool,
+ pub status: String,
+ pub last_poll_at: Option<DateTime<Utc>>,
+ pub last_success_at: Option<DateTime<Utc>>,
+ pub last_error: Option<String>,
+ pub consecutive_failures: i32,
+ pub poll_interval_s: i32,
+ pub tight_interval_s: i32,
+ pub latency_ms_p50: Option<i64>,
+ pub latency_ms_p95: Option<i64>,
+ pub events_seen_24h: i64,
+}
+
+impl Source {
+ /// Build the API view from a row plus computed health aggregates.
+ #[must_use]
+ pub fn from_row(
+ row: SourceRow,
+ latency_ms_p50: Option<i64>,
+ latency_ms_p95: Option<i64>,
+ events_seen_24h: i64,
+ ) -> Self {
+ Source {
+ id: row.id,
+ name: row.name,
+ category: row.category,
+ enabled: row.enabled,
+ status: row.status,
+ last_poll_at: row.last_poll_at,
+ last_success_at: row.last_success_at,
+ last_error: row.last_error,
+ consecutive_failures: row.consecutive_failures,
+ poll_interval_s: row.poll_interval_s,
+ tight_interval_s: row.tight_interval_s,
+ latency_ms_p50,
+ latency_ms_p95,
+ events_seen_24h,
+ }
+ }
+}
+
+/// A single `source_health` time point written after each poll.
+#[derive(Debug, Clone)]
+pub struct SourceHealthSample {
+ pub source_id: String,
+ pub observed_at: DateTime<Utc>,
+ pub ok: bool,
+ pub latency_ms: i32,
+ pub events_returned: i32,
+ pub error: Option<String>,
+}
+
+/// A 5-minute health bucket for sparklines (`GET /api/sources/:id`).
+#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
+pub struct SourceHealthBucket {
+ pub bucket: DateTime<Utc>,
+ pub latency_ms_p50: Option<f64>,
+ pub latency_ms_p95: Option<i32>,
+ pub ok_count: Option<i64>,
+ pub err_count: Option<i64>,
+}
backend/src/model/watchlist.rs +87 -0
@@ -0,0 +1,87 @@
+//! Watchlist models: DB row, API view, and request bodies (CONTRACTS §B.3).
+
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+
+/// Raw `watchlist` table row.
+#[derive(Debug, Clone, sqlx::FromRow)]
+pub struct WatchlistRow {
+ pub id: i64,
+ pub kind: String,
+ pub pattern: String,
+ pub label: String,
+ pub active: bool,
+ pub priority: String,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// API representation of a watchlist rule.
+#[derive(Debug, Clone, Serialize)]
+pub struct WatchlistRule {
+ pub id: i64,
+ pub kind: String,
+ pub pattern: String,
+ pub label: String,
+ pub active: bool,
+ pub priority: String,
+ pub matches_30d: i64,
+ pub created_at: DateTime<Utc>,
+}
+
+impl WatchlistRule {
+ #[must_use]
+ pub fn from_row(row: WatchlistRow, matches_30d: i64) -> Self {
+ WatchlistRule {
+ id: row.id,
+ kind: row.kind,
+ pattern: row.pattern,
+ label: row.label,
+ active: row.active,
+ priority: row.priority,
+ matches_30d,
+ created_at: row.created_at,
+ }
+ }
+}
+
+/// `POST /api/watchlist` request body.
+#[derive(Debug, Clone, Deserialize)]
+pub struct CreateWatchlistRule {
+ pub kind: String,
+ pub pattern: String,
+ pub label: String,
+ #[serde(default)]
+ pub priority: Option<String>,
+}
+
+/// `PATCH /api/watchlist/:id` partial-update body.
+#[derive(Debug, Clone, Deserialize)]
+pub struct UpdateWatchlistRule {
+ #[serde(default)]
+ pub active: Option<bool>,
+ #[serde(default)]
+ pub priority: Option<String>,
+ #[serde(default)]
+ pub label: Option<String>,
+}
+
+/// `POST /api/watchlist/bulk` request body.
+#[derive(Debug, Clone, Deserialize)]
+pub struct WatchlistBulkRequest {
+ pub rules: Vec<BulkRuleSpec>,
+}
+
+/// A single bulk rule: label is required; kind defaults to `keyword`.
+#[derive(Debug, Clone, Deserialize)]
+pub struct BulkRuleSpec {
+ #[serde(default = "default_bulk_kind")]
+ pub kind: String,
+ pub label: String,
+ #[serde(default)]
+ pub priority: Option<String>,
+}
+
+fn default_bulk_kind() -> String {
+ "keyword".to_string()
+}
backend/src/notify/filter.rs +172 -0
@@ -0,0 +1,172 @@
+//! Notification gating: watchlist regex match, priority mapping, dedupe (§I).
+
+use std::time::Duration;
+
+use regex::Regex;
+
+use crate::error::AppResult;
+use crate::model::watchlist::WatchlistRow;
+use crate::model::{Drop, DropType, Priority};
+use crate::state::AppState;
+
+/// ntfy priority strings.
+pub const NTFY_URGENT: &str = "urgent";
+pub const NTFY_HIGH: &str = "high";
+pub const NTFY_DEFAULT: &str = "default";
+
+/// Outcome of running a drop through the watchlist filter.
+pub struct FilterDecision {
+ /// True if the drop should be pushed to ntfy.
+ pub send: bool,
+ /// ntfy priority for the push.
+ pub ntfy_priority: &'static str,
+ /// The matched rule's label (for the body), if any.
+ pub matched_label: Option<String>,
+}
+
+/// Determine whether `drop` should notify, and at what priority (§I.2).
+///
+/// Rules:
+/// - `new` with no watchlist match => UI only (not sent).
+/// - `status -> onsale` priority follows the matched watchlist rule priority.
+/// - price/inventory drops use a fixed mapping.
+///
+/// # Errors
+/// Propagates store errors fetching active watchlist rules.
+pub async fn decide(state: &AppState, drop: &Drop, title: &str) -> AppResult<FilterDecision> {
+ let drop_type = DropType::parse(&drop.drop_type).unwrap_or(DropType::Status);
+
+ // Find the highest-priority matching watchlist rule.
+ let rules = state.store.watchlist.list_active().await?;
+ let matched = best_match(&rules, title);
+
+ let decision = match drop_type {
+ DropType::New | DropType::Announcement => match &matched {
+ None => FilterDecision {
+ send: false,
+ ntfy_priority: NTFY_DEFAULT,
+ matched_label: None,
+ },
+ Some((label, prio)) => FilterDecision {
+ send: true,
+ ntfy_priority: match prio {
+ Priority::Critical => NTFY_URGENT,
+ Priority::High => NTFY_HIGH,
+ Priority::Normal | Priority::Low => NTFY_DEFAULT,
+ },
+ matched_label: Some(label.clone()),
+ },
+ },
+ DropType::Status => {
+ // Only the announced->onsale transition is push-worthy here.
+ let to_onsale = drop
+ .to_state
+ .get("status")
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| s == "onsale");
+ match (&matched, to_onsale) {
+ (Some((label, prio)), true) => FilterDecision {
+ send: true,
+ ntfy_priority: match prio {
+ Priority::Critical => NTFY_URGENT,
+ Priority::High => NTFY_HIGH,
+ Priority::Normal | Priority::Low => NTFY_DEFAULT,
+ },
+ matched_label: Some(label.clone()),
+ },
+ _ => FilterDecision {
+ send: false,
+ ntfy_priority: NTFY_DEFAULT,
+ matched_label: matched.as_ref().map(|(l, _)| l.clone()),
+ },
+ }
+ }
+ DropType::Price => FilterDecision {
+ // price >=25% => high; 10-25% => default. We approximate the percent
+ // from the from/to states; default is `high` for any matched price
+ // drop, `default` otherwise.
+ send: true,
+ ntfy_priority: price_priority(drop),
+ matched_label: matched.as_ref().map(|(l, _)| l.clone()),
+ },
+ DropType::Inventory => FilterDecision {
+ send: true,
+ ntfy_priority: NTFY_DEFAULT,
+ matched_label: matched.as_ref().map(|(l, _)| l.clone()),
+ },
+ };
+
+ Ok(decision)
+}
+
+/// Pick the matching rule with the highest priority.
+fn best_match(rules: &[WatchlistRow], title: &str) -> Option<(String, Priority)> {
+ let mut best: Option<(String, Priority)> = None;
+ for r in rules {
+ let Ok(re) = Regex::new(&r.pattern) else {
+ continue;
+ };
+ if re.is_match(title) {
+ let prio = Priority::parse(&r.priority).unwrap_or(Priority::Normal);
+ let better = best
+ .as_ref()
+ .is_none_or(|(_, cur)| priority_rank(prio) > priority_rank(*cur));
+ if better {
+ best = Some((r.label.clone(), prio));
+ }
+ }
+ }
+ best
+}
+
+fn priority_rank(p: Priority) -> u8 {
+ match p {
+ Priority::Low => 0,
+ Priority::Normal => 1,
+ Priority::High => 2,
+ Priority::Critical => 3,
+ }
+}
+
+/// `high` for a >=25% drop, else `default` (§I.2).
+fn price_priority(drop: &Drop) -> &'static str {
+ let from = drop
+ .from_state
+ .as_ref()
+ .and_then(|v| v.get("price_min"))
+ .and_then(serde_json::Value::as_i64);
+ let to = drop
+ .to_state
+ .get("price_min")
+ .and_then(serde_json::Value::as_i64);
+ if let (Some(f), Some(t)) = (from, to) {
+ if f > 0 && (f - t) * 100 / f >= 25 {
+ return NTFY_HIGH;
+ }
+ }
+ NTFY_DEFAULT
+}
+
+/// Per-`(event_id, drop_type)` dedupe over a 10-minute window (§I.3). Returns
+/// true if this caller may proceed (no recent identical notification).
+///
+/// # Errors
+/// Propagates cache errors.
+pub async fn dedupe_ok(state: &AppState, event_id: i64, drop_type: &str) -> AppResult<bool> {
+ let key = format!("notif:dedupe:{event_id}:{drop_type}");
+ Ok(state
+ .cache
+ .dedupe_acquire(&key, Duration::from_secs(600))
+ .await?)
+}
+
+/// Global token bucket: max 5/min (§I.3). Returns true if a slot is free.
+///
+/// # Errors
+/// Propagates cache errors.
+pub async fn rate_ok(state: &AppState) -> AppResult<bool> {
+ Ok(state
+ .cache
+ .try_acquire("notify:global", 5, Duration::from_secs(60))
+ .await?)
+}
backend/src/notify/mod.rs +234 -0
@@ -0,0 +1,234 @@
+//! Notification pipeline (SPEC §5.5 + CONTRACTS §I).
+//!
+//! Order on a new drop: filter (watchlist + priority) -> dedupe -> rate-limit ->
+//! build payload -> POST to ntfy -> log to `notifications_log`.
+
+pub mod filter;
+pub mod ntfy;
+
+use std::time::Instant;
+
+use chrono::Utc;
+
+use crate::error::AppResult;
+use crate::model::{Drop, DropType};
+use crate::state::AppState;
+
+use ntfy::{NtfyPush, NtfyResult};
+
+/// Run the full pipeline for a freshly emitted drop.
+///
+/// # Errors
+/// Propagates store / cache errors. ntfy transport failures are recorded, not
+/// raised.
+pub async fn on_drop(state: &AppState, drop: &Drop) -> AppResult<()> {
+ let started = Instant::now();
+
+ let (title, category, source_id, buy_url) = drop.event.as_ref().map_or_else(
+ || (String::new(), "other".to_string(), "unknown".to_string(), None),
+ |e| {
+ (
+ e.title.clone(),
+ e.category.clone(),
+ e.source_id.clone(),
+ Some(e.buy_url.clone()),
+ )
+ },
+ );
+
+ // 1. Filter + priority.
+ let decision = filter::decide(state, drop, &title).await?;
+ if !decision.send {
+ record_skip(state, drop, "filtered");
+ return Ok(());
+ }
+
+ // 2. Dedupe (event_id, drop_type) over 10 min.
+ if !filter::dedupe_ok(state, drop.event_id, &drop.drop_type).await? {
+ record_skip(state, drop, "deduped");
+ return Ok(());
+ }
+
+ // 3. Global rate limit (5/min).
+ if !filter::rate_ok(state).await? {
+ record_log(
+ state,
+ drop,
+ "ntfy",
+ "default",
+ &title,
+ "rate limited",
+ false,
+ Some("rate_limited_self"),
+ )
+ .await;
+ state
+ .metrics
+ .notifications_total
+ .with_label_values(&["ntfy", "default", "skipped"])
+ .inc();
+ return Ok(());
+ }
+
+ // 4. Build payload.
+ let drop_type = DropType::parse(&drop.drop_type).unwrap_or(DropType::Status);
+ let push = build_push(drop_type, &title, &category, &source_id, buy_url.clone(), decision.ntfy_priority);
+
+ // 5. Publish.
+ let result: NtfyResult = ntfy::publish(state, &push).await?;
+
+ // 6. Log + metrics + latency.
+ let outcome = if result.success { "ok" } else { "failed" };
+ state
+ .metrics
+ .notifications_total
+ .with_label_values(&["ntfy", push.priority, outcome])
+ .inc();
+ #[allow(clippy::cast_possible_truncation)]
+ let elapsed_ms = started.elapsed().as_millis() as i32;
+ state
+ .metrics
+ .notify_latency_ms
+ .with_label_values(&["ntfy"])
+ .observe(f64::from(elapsed_ms));
+
+ if result.success {
+ state.store.drops.mark_notified(drop.id).await.ok();
+ // detection latency = drop.detected_at -> notification now.
+ let latency_ms = (Utc::now() - drop.detected_at).num_milliseconds();
+ #[allow(clippy::cast_possible_truncation)]
+ let latency_ms = latency_ms.clamp(0, i64::from(i32::MAX)) as i32;
+ state.store.drops.record_latency(drop.id, latency_ms).await.ok();
+ state
+ .metrics
+ .detection_latency_ms
+ .with_label_values(&[drop.drop_type.as_str()])
+ .observe(f64::from(latency_ms));
+ }
+
+ record_log(
+ state,
+ drop,
+ "ntfy",
+ push.priority,
+ &push.title,
+ &push.body,
+ result.success,
+ Some(&result.response),
+ )
+ .await;
+
+ Ok(())
+}
+
+/// Build the ntfy payload for a drop type (§I.1 templates).
+fn build_push(
+ drop_type: DropType,
+ title: &str,
+ category: &str,
+ source_id: &str,
+ buy_url: Option<String>,
+ priority: &'static str,
+) -> NtfyPush {
+ let (emoji, prefix, primary_tag) = match drop_type {
+ DropType::Status => ("🎟️", "ON SALE NOW", "ticket"),
+ DropType::New => ("🆕", "NEW", "new"),
+ DropType::Price => ("📉", "PRICE DROP", "arrow_down"),
+ DropType::Inventory => ("⚠️", "LOW STOCK", "warning"),
+ DropType::Announcement => ("📣", "ANNOUNCED", "loudspeaker"),
+ };
+ let click = buy_url.map(|u| {
+ let sep = if u.contains('?') { '&' } else { '?' };
+ format!("{u}{sep}drophawk=1")
+ });
+ NtfyPush {
+ title: format!("{emoji} {prefix}: {title}"),
+ body: format!("Source: {source_id} · detected at {}", Utc::now().to_rfc3339()),
+ priority,
+ tags: vec![primary_tag.to_string(), category.to_string(), source_id.to_string()],
+ click,
+ }
+}
+
+/// Push an operator-level critical alert (source DOWN etc.). Bypasses watchlist.
+///
+/// # Errors
+/// Propagates cache / store errors.
+pub async fn push_critical(state: &AppState, message: String) -> AppResult<()> {
+ let push = NtfyPush {
+ title: format!("🚨 DropHawk: {message}"),
+ body: message.clone(),
+ priority: filter::NTFY_URGENT,
+ tags: vec!["rotating_light".to_string()],
+ click: None,
+ };
+ let result = ntfy::publish(state, &push).await?;
+ state
+ .metrics
+ .notifications_total
+ .with_label_values(&["ntfy", "urgent", if result.success { "ok" } else { "failed" }])
+ .inc();
+ Ok(())
+}
+
+/// Send the `POST /api/notifications/test` push (§I.1).
+///
+/// # Errors
+/// Propagates transport errors.
+pub async fn send_test(state: &AppState) -> AppResult<String> {
+ let push = NtfyPush {
+ title: "✅ DropHawk ntfy test".to_string(),
+ body: format!(
+ "If you can read this, your pipeline works. Detected at {}.",
+ Utc::now().to_rfc3339()
+ ),
+ priority: filter::NTFY_DEFAULT,
+ tags: vec!["white_check_mark".to_string()],
+ click: None,
+ };
+ let _ = ntfy::publish(state, &push).await?;
+ Ok(state.config.ntfy_topic.clone())
+}
+
+/// Record a skipped notification in metrics only (no row for filtered/deduped
+/// to keep the log clean - these are not failures).
+fn record_skip(state: &AppState, _drop: &Drop, reason: &str) {
+ tracing::debug!(reason, "notification skipped");
+ state
+ .metrics
+ .notifications_total
+ .with_label_values(&["ntfy", "default", "skipped"])
+ .inc();
+}
+
+/// Insert a row into `notifications_log`. Best-effort; logs on failure.
+#[allow(clippy::too_many_arguments)]
+async fn record_log(
+ state: &AppState,
+ drop: &Drop,
+ channel: &str,
+ priority: &str,
+ title: &str,
+ body: &str,
+ success: bool,
+ response: Option<&str>,
+) {
+ let topic = state.config.ntfy_topic.clone();
+ let res = sqlx::query(
+ "INSERT INTO notifications_log (drop_id, channel, title, body, priority, success, response, topic) \
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
+ )
+ .bind(drop.id)
+ .bind(channel)
+ .bind(title)
+ .bind(body)
+ .bind(priority)
+ .bind(success)
+ .bind(response)
+ .bind(topic)
+ .execute(&state.store.pool)
+ .await;
+ if let Err(e) = res {
+ tracing::warn!(error = %e, "failed to write notifications_log");
+ }
+}
backend/src/notify/ntfy.rs +78 -0
@@ -0,0 +1,78 @@
+//! ntfy HTTP publisher (CONTRACTS §I.1).
+
+use serde_json::json;
+
+use crate::error::{AppError, AppResult};
+use crate::state::AppState;
+
+/// A fully-built ntfy push.
+pub struct NtfyPush {
+ pub title: String,
+ pub body: String,
+ pub priority: &'static str,
+ pub tags: Vec<String>,
+ pub click: Option<String>,
+}
+
+/// Result of a publish attempt.
+pub struct NtfyResult {
+ pub success: bool,
+ pub response: String,
+}
+
+/// POST a push to the configured ntfy topic. Headers follow §I.1: `Title`,
+/// `Priority`, `Tags`, `Click`, `Actions` (a Buy button), `Cache: no`.
+///
+/// # Errors
+/// Returns a transport error only on a malformed header; HTTP failures are
+/// captured into [`NtfyResult`] so the caller can still log them.
+pub async fn publish(state: &AppState, push: &NtfyPush) -> AppResult<NtfyResult> {
+ let url = format!(
+ "{}/{}",
+ state.config.ntfy_base_url.trim_end_matches('/'),
+ state.config.ntfy_topic
+ );
+
+ let actions = push.click.as_ref().map(|click| {
+ json!([{ "action": "view", "label": "Buy", "url": click }]).to_string()
+ });
+
+ let mut req = state
+ .http
+ .post(&url)
+ .header("Title", &push.title)
+ .header("Priority", push.priority)
+ .header("Tags", push.tags.join(","))
+ .header("Cache", "no")
+ .body(push.body.clone());
+
+ if let Some(token) = &state.config.ntfy_token {
+ req = req.bearer_auth(token);
+ }
+ if let Some(click) = &push.click {
+ req = req.header("Click", click);
+ }
+ if let Some(actions) = &actions {
+ req = req.header("Actions", actions);
+ }
+
+ match req.send().await {
+ Ok(resp) => {
+ let status = resp.status();
+ let text = resp.text().await.unwrap_or_default();
+ Ok(NtfyResult {
+ success: status.is_success(),
+ response: format!("{status}: {text}"),
+ })
+ }
+ Err(e) => {
+ if e.is_builder() {
+ return Err(AppError::Http(e));
+ }
+ Ok(NtfyResult {
+ success: false,
+ response: format!("transport error: {e}"),
+ })
+ }
+ }
+}
backend/src/scheduler/mod.rs +73 -0
@@ -0,0 +1,73 @@
+//! Scheduler: spawn one poll task per enabled, adapter-backed source (§5.2).
+
+pub mod poller;
+
+use std::sync::Arc;
+
+use tokio::sync::watch;
+use tokio::task::JoinHandle;
+
+use crate::sources::{SourceAdapter, TicketmasterAdapter};
+use crate::state::AppState;
+
+/// Owns the spawned poll tasks and the shutdown channel.
+pub struct Scheduler {
+ handles: Vec<JoinHandle<()>>,
+ shutdown_tx: watch::Sender<bool>,
+}
+
+impl Scheduler {
+ /// Spawn poll loops for every enabled source that has a Rust adapter.
+ ///
+ /// Phase 1 ships only the Ticketmaster adapter; other enabled sources are
+ /// served by Python scrapers (out of scope here) and are skipped.
+ ///
+ /// # Errors
+ /// Propagates store errors while enumerating enabled sources.
+ pub async fn start(state: Arc<AppState>) -> crate::error::AppResult<Self> {
+ let (shutdown_tx, shutdown_rx) = watch::channel(false);
+ let mut handles = Vec::new();
+
+ let enabled = state.store.sources.list_enabled().await?;
+ for row in enabled {
+ let Some(adapter) = build_adapter(&row.id, &state) else {
+ tracing::debug!(source = %row.id, "no Rust adapter; skipping (Phase 1)");
+ continue;
+ };
+ let st = Arc::clone(&state);
+ let rx = shutdown_rx.clone();
+ handles.push(tokio::spawn(async move {
+ poller::run_source_loop(adapter, st, rx).await;
+ }));
+ }
+
+ tracing::info!(tasks = handles.len(), "scheduler started");
+ Ok(Scheduler {
+ handles,
+ shutdown_tx,
+ })
+ }
+
+ /// Signal all loops to stop and await them.
+ pub async fn shutdown(self) {
+ let _ = self.shutdown_tx.send(true);
+ for h in self.handles {
+ let _ = h.await;
+ }
+ }
+}
+
+/// Construct the Rust adapter for a source id, if one exists.
+fn build_adapter(source_id: &str, state: &Arc<AppState>) -> Option<Arc<dyn SourceAdapter>> {
+ match source_id {
+ "ticketmaster" => {
+ let keys = state.config.ticketmaster_keys();
+ if keys.is_empty() {
+ tracing::warn!("ticketmaster enabled but TICKETMASTER_API_KEYS is empty");
+ return None;
+ }
+ Some(Arc::new(TicketmasterAdapter::new(keys, "US")))
+ }
+ _ => None,
+ }
+}
backend/src/scheduler/poller.rs +186 -0
@@ -0,0 +1,186 @@
+//! The per-source poll loop (CONTRACTS §C).
+
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use chrono::Utc;
+use tokio::sync::watch;
+
+use crate::detect;
+use crate::model::source::SourceHealthSample;
+use crate::notify;
+use crate::sources::{poll_context, SourceAdapter, SourceError};
+use crate::sources::ExponentialBackoff;
+use crate::state::AppState;
+
+/// Run the poll loop for one adapter until the shutdown signal flips.
+pub async fn run_source_loop(
+ adapter: Arc<dyn SourceAdapter>,
+ state: Arc<AppState>,
+ mut shutdown: watch::Receiver<bool>,
+) {
+ let mut backoff = ExponentialBackoff::new(Duration::from_secs(2), Duration::from_secs(600));
+ tracing::info!(source = adapter.id(), "source loop started");
+
+ loop {
+ if *shutdown.borrow() {
+ break;
+ }
+
+ // 1. Build context with known upcoming on-sale times for adaptive interval.
+ let upcoming = upcoming_drops(&state, adapter.id()).await;
+ let ctx = poll_context(state.http.clone(), upcoming);
+ let base_interval = adapter.next_poll_hint(&ctx);
+ let interval = backoff.next_or(base_interval);
+
+ // 2. Rate-limit token (per source).
+ state
+ .cache
+ .acquire_token(adapter.id(), 50, Duration::from_secs(1))
+ .await;
+
+ // 3. Poll.
+ let t0 = Instant::now();
+ let result = adapter.poll(&ctx).await;
+ #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
+ let latency_ms = t0.elapsed().as_millis() as i32;
+ let now = Utc::now();
+
+ state
+ .metrics
+ .source_poll_duration_ms
+ .with_label_values(&[adapter.id()])
+ .observe(f64::from(latency_ms));
+
+ match result {
+ Ok(candidates) => {
+ backoff.reset();
+
+ // 4. Persist source_health.
+ state
+ .store
+ .sources
+ .record_health(&SourceHealthSample {
+ source_id: adapter.id().to_string(),
+ observed_at: now,
+ ok: true,
+ latency_ms,
+ #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
+ events_returned: candidates.len() as i32,
+ error: None,
+ })
+ .await
+ .ok();
+
+ // 5. Detect + emit.
+ for candidate in &candidates {
+ if let Err(e) = detect::ingest_candidate(&state, candidate).await {
+ tracing::warn!(error = %e, source = adapter.id(), "ingest failed");
+ }
+ }
+
+ // 6. Mark success.
+ state.store.sources.mark_success(adapter.id(), now).await.ok();
+ state
+ .metrics
+ .source_poll_total
+ .with_label_values(&[adapter.id(), "ok"])
+ .inc();
+ }
+ Err(SourceError::RateLimited { retry_after }) => {
+ backoff.force_retry_after(retry_after.unwrap_or(Duration::from_secs(60)));
+ state
+ .store
+ .sources
+ .record_health(&fail_sample(adapter.id(), now, latency_ms, "rate_limited"))
+ .await
+ .ok();
+ state.store.sources.mark_failure(adapter.id(), "rate_limited").await.ok();
+ state
+ .metrics
+ .source_poll_total
+ .with_label_values(&[adapter.id(), "rate_limited"])
+ .inc();
+ }
+ Err(e) => {
+ let msg = e.to_string();
+ state
+ .store
+ .sources
+ .record_health(&fail_sample(adapter.id(), now, latency_ms, &msg))
+ .await
+ .ok();
+ let failures = state
+ .store
+ .sources
+ .mark_failure(adapter.id(), &msg)
+ .await
+ .unwrap_or(0);
+ state
+ .metrics
+ .source_poll_total
+ .with_label_values(&[adapter.id(), "error"])
+ .inc();
+
+ // Circuit breaker: 3 -> degraded, 10 -> down + alert.
+ if failures == 3 {
+ state.store.sources.set_status(adapter.id(), "degraded").await.ok();
+ } else if failures == 10 {
+ state.store.sources.set_status(adapter.id(), "down").await.ok();
+ notify::push_critical(
+ &state,
+ format!("Source {} is DOWN", adapter.name()),
+ )
+ .await
+ .ok();
+ }
+
+ // Sleep the backoff and continue (do not also sleep `interval`).
+ let sleep = backoff.next();
+ wait_or_shutdown(&mut shutdown, sleep).await;
+ continue;
+ }
+ }
+
+ wait_or_shutdown(&mut shutdown, interval).await;
+ }
+
+ tracing::info!(source = adapter.id(), "source loop stopped");
+}
+
+/// Sleep for `dur` unless the shutdown signal flips first.
+async fn wait_or_shutdown(shutdown: &mut watch::Receiver<bool>, dur: Duration) {
+ tokio::select! {
+ () = tokio::time::sleep(dur) => {}
+ _ = shutdown.changed() => {}
+ }
+}
+
+/// Build a failed health sample.
+fn fail_sample(
+ source_id: &str,
+ observed_at: chrono::DateTime<Utc>,
+ latency_ms: i32,
+ error: &str,
+) -> SourceHealthSample {
+ SourceHealthSample {
+ source_id: source_id.to_string(),
+ observed_at,
+ ok: false,
+ latency_ms: latency_ms.max(0),
+ events_returned: 0,
+ error: Some(error.to_string()),
+ }
+}
+
+/// Known upcoming on-sale timestamps (±24h) for a source's events.
+async fn upcoming_drops(state: &Arc<AppState>, source_id: &str) -> Vec<chrono::DateTime<Utc>> {
+ sqlx::query_scalar::<_, chrono::DateTime<Utc>>(
+ "SELECT on_sale_at FROM events WHERE source_id = $1 AND on_sale_at IS NOT NULL \
+ AND on_sale_at BETWEEN NOW() - INTERVAL '24 hours' AND NOW() + INTERVAL '24 hours'",
+ )
+ .bind(source_id)
+ .fetch_all(&state.store.pool)
+ .await
+ .unwrap_or_default()
+}
backend/src/sources/mod.rs +170 -0
@@ -0,0 +1,170 @@
+//! Source adapters: the `SourceAdapter` trait, poll context, source errors, and
+//! the scheduler's exponential backoff (SPEC §5.1, CONTRACTS §C).
+
+pub mod ticketmaster;
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use async_trait::async_trait;
+use chrono::{DateTime, Utc};
+use rand::rngs::SmallRng;
+use rand::{Rng, SeedableRng};
+use thiserror::Error;
+
+use crate::model::{EventCandidate, SourceCategory};
+
+pub use ticketmaster::TicketmasterAdapter;
+
+/// Per-poll context handed to each adapter.
+pub struct PollContext {
+ pub now: DateTime<Utc>,
+ pub http: reqwest::Client,
+ /// Known upcoming on-sale times for this source, for adaptive interval.
+ pub upcoming_drops: Vec<DateTime<Utc>>,
+}
+
+impl PollContext {
+ #[must_use]
+ pub fn now(&self) -> DateTime<Utc> {
+ self.now
+ }
+}
+
+/// Errors returned by an adapter poll (CONTRACTS §K.1).
+#[derive(Debug, Error)]
+pub enum SourceError {
+ #[error("network error: {0}")]
+ Network(String),
+ #[error("parse error: {0}")]
+ Parse(String),
+ #[error("rate limited; retry after {retry_after:?}")]
+ RateLimited { retry_after: Option<Duration> },
+ #[error("blocked by source (403/429): {0}")]
+ Blocked(String),
+ #[error("source returned invalid auth")]
+ Auth,
+ #[error("upstream error {status}: {body}")]
+ Upstream { status: u16, body: String },
+}
+
+/// A pollable ticket source. Errors are retried by the scheduler, not bubbled up.
+#[async_trait]
+pub trait SourceAdapter: Send + Sync {
+ /// Stable identifier (matches `sources.id`).
+ fn id(&self) -> &str;
+
+ /// Human label.
+ fn name(&self) -> &str;
+
+ /// UI/grouping category.
+ fn category(&self) -> SourceCategory;
+
+ /// Poll once; return normalized candidates.
+ async fn poll(&self, ctx: &PollContext) -> Result<Vec<EventCandidate>, SourceError>;
+
+ /// Adaptive interval hint (CONTRACTS §C.1). Default tightens to 10s within
+ /// ±15min of a known on-sale, 30s within ±1h, 60s within ±4h, else 5min.
+ fn next_poll_hint(&self, ctx: &PollContext) -> Duration {
+ let nearest = ctx
+ .upcoming_drops
+ .iter()
+ .min_by_key(|d| (**d - ctx.now()).num_seconds().abs());
+
+ match nearest {
+ Some(t) if (*t - ctx.now()).num_seconds().abs() <= 900 => Duration::from_secs(10),
+ Some(t) if (*t - ctx.now()).num_seconds().abs() <= 3600 => Duration::from_secs(30),
+ Some(t) if (*t - ctx.now()).num_seconds().abs() <= 14400 => Duration::from_secs(60),
+ _ => Duration::from_secs(300),
+ }
+ }
+}
+
+/// Exponential backoff with ±15% jitter (CONTRACTS §C).
+pub struct ExponentialBackoff {
+ start: Duration,
+ max: Duration,
+ current: Duration,
+ rng: SmallRng,
+ forced: Option<Duration>,
+}
+
+impl ExponentialBackoff {
+ /// New backoff with the given start and max.
+ #[must_use]
+ pub fn new(start: Duration, max: Duration) -> Self {
+ ExponentialBackoff {
+ start,
+ max,
+ current: start,
+ rng: SmallRng::seed_from_u64(seed_now()),
+ forced: None,
+ }
+ }
+
+ /// Reset to the start interval.
+ pub fn reset(&mut self) {
+ self.current = self.start;
+ self.forced = None;
+ }
+
+ /// Override the next sleep to exactly `d` (no jitter) - for `Retry-After`.
+ pub fn force_retry_after(&mut self, d: Duration) {
+ self.forced = Some(d);
+ }
+
+ /// Compute the next backoff sleep, doubling and applying jitter.
+ pub fn next(&mut self) -> Duration {
+ if let Some(forced) = self.forced.take() {
+ return forced;
+ }
+ let base = self.current;
+ // Double for next time, capped.
+ self.current = (self.current * 2).min(self.max);
+ jitter(&mut self.rng, base)
+ }
+
+ /// Either the forced retry, the active backoff if it has escalated, or the
+ /// adapter's base interval when healthy.
+ pub fn next_or(&mut self, base_interval: Duration) -> Duration {
+ if let Some(forced) = self.forced.take() {
+ return forced;
+ }
+ if self.current > self.start {
+ jitter(&mut self.rng, self.current)
+ } else {
+ base_interval
+ }
+ }
+}
+
+/// Seed from the current instant's nanoseconds.
+fn seed_now() -> u64 {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_or(0, |d| d.as_nanos() as u64)
+}
+
+/// Apply ±15% jitter to a duration.
+fn jitter(rng: &mut SmallRng, d: Duration) -> Duration {
+ let millis = d.as_millis() as f64;
+ let factor = 1.0 + rng.gen_range(-0.15..=0.15);
+ Duration::from_millis((millis * factor).max(0.0) as u64)
+}
+
+/// Build a `PollContext` from shared app state for a given source.
+#[must_use]
+pub fn poll_context(
+ http: reqwest::Client,
+ upcoming_drops: Vec<DateTime<Utc>>,
+) -> PollContext {
+ PollContext {
+ now: Utc::now(),
+ http,
+ upcoming_drops,
+ }
+}
+
+/// Marker so unused `Arc` import is not flagged when only the trait object is used.
+pub type SharedAdapter = Arc<dyn SourceAdapter>;
backend/src/sources/ticketmaster.rs +355 -0
@@ -0,0 +1,355 @@
+//! Ticketmaster Discovery API adapter (SPEC §7.1).
+//!
+//! Auth: `apikey` query param (rotated across keys). Base:
+//! `https://app.ticketmaster.com/discovery/v2/`. We page `events.json` and
+//! normalize each event into an [`EventCandidate`].
+
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::time::Duration;
+
+use async_trait::async_trait;
+use chrono::{DateTime, Utc};
+use serde_json::Value;
+
+use crate::model::{EventCandidate, EventCategory, SaleStatus, SourceCategory, Sport};
+use crate::sources::{PollContext, SourceAdapter, SourceError};
+
+const BASE: &str = "https://app.ticketmaster.com/discovery/v2";
+const PAGE_SIZE: u32 = 200;
+/// Defensive page cap; Discovery API also limits deep paging.
+const MAX_PAGES: u32 = 5;
+
+/// Ticketmaster adapter holding rotating API keys and a country scope.
+pub struct TicketmasterAdapter {
+ keys: Vec<String>,
+ country_code: String,
+ next_key: AtomicUsize,
+}
+
+impl TicketmasterAdapter {
+ /// Build with the given keys and country code (e.g. `US`).
+ #[must_use]
+ pub fn new(keys: Vec<String>, country_code: impl Into<String>) -> Self {
+ TicketmasterAdapter {
+ keys,
+ country_code: country_code.into(),
+ next_key: AtomicUsize::new(0),
+ }
+ }
+
+ /// Round-robin the next API key.
+ fn pick_key(&self) -> Option<&str> {
+ if self.keys.is_empty() {
+ return None;
+ }
+ let i = self.next_key.fetch_add(1, Ordering::Relaxed) % self.keys.len();
+ self.keys.get(i).map(String::as_str)
+ }
+
+ /// Fetch one page of events.
+ async fn fetch_page(
+ &self,
+ http: &reqwest::Client,
+ key: &str,
+ page: u32,
+ ) -> Result<Value, SourceError> {
+ let url = format!(
+ "{BASE}/events.json?apikey={key}&countryCode={}&size={PAGE_SIZE}&page={page}",
+ self.country_code
+ );
+ let resp = http
+ .get(&url)
+ .timeout(Duration::from_secs(15))
+ .send()
+ .await
+ .map_err(|e| SourceError::Network(e.to_string()))?;
+
+ let status = resp.status();
+ if status.as_u16() == 429 {
+ let retry_after = resp
+ .headers()
+ .get("retry-after")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|s| s.parse::<u64>().ok())
+ .map(Duration::from_secs);
+ return Err(SourceError::RateLimited { retry_after });
+ }
+ if status.as_u16() == 401 || status.as_u16() == 403 {
+ return Err(SourceError::Auth);
+ }
+ if !status.is_success() {
+ let body = resp.text().await.unwrap_or_default();
+ return Err(SourceError::Upstream {
+ status: status.as_u16(),
+ body: body.chars().take(500).collect(),
+ });
+ }
+
+ resp.json::<Value>()
+ .await
+ .map_err(|e| SourceError::Parse(e.to_string()))
+ }
+}
+
+#[async_trait]
+impl SourceAdapter for TicketmasterAdapter {
+ fn id(&self) -> &str {
+ "ticketmaster"
+ }
+
+ fn name(&self) -> &str {
+ "Ticketmaster Discovery API"
+ }
+
+ fn category(&self) -> SourceCategory {
+ SourceCategory::Primary
+ }
+
+ async fn poll(&self, ctx: &PollContext) -> Result<Vec<EventCandidate>, SourceError> {
+ let key = self
+ .pick_key()
+ .ok_or_else(|| SourceError::Auth)?
+ .to_string();
+
+ let mut out = Vec::new();
+ let mut page = 0;
+ loop {
+ let body = self.fetch_page(&ctx.http, &key, page).await?;
+
+ let events = body
+ .pointer("/_embedded/events")
+ .and_then(Value::as_array)
+ .cloned()
+ .unwrap_or_default();
+
+ for ev in &events {
+ if let Some(candidate) = normalize(ev) {
+ out.push(candidate);
+ }
+ }
+
+ let total_pages = body
+ .pointer("/page/totalPages")
+ .and_then(Value::as_u64)
+ .unwrap_or(1) as u32;
+ page += 1;
+ if page >= total_pages || page >= MAX_PAGES {
+ break;
+ }
+ }
+
+ Ok(out)
+ }
+}
+
+/// Normalize a single Discovery API event into an [`EventCandidate`].
+/// Returns `None` if the event is missing an id or url.
+fn normalize(ev: &Value) -> Option<EventCandidate> {
+ let source_event_id = ev.get("id").and_then(Value::as_str)?.to_string();
+ let buy_url = ev.get("url").and_then(Value::as_str)?.to_string();
+ let title = ev
+ .get("name")
+ .and_then(Value::as_str)
+ .unwrap_or("Untitled")
+ .to_string();
+
+ let starts_at = ev
+ .pointer("/dates/start/dateTime")
+ .and_then(Value::as_str)
+ .and_then(parse_dt);
+ let on_sale_at = ev
+ .pointer("/sales/public/startDateTime")
+ .and_then(Value::as_str)
+ .and_then(parse_dt);
+ let presale_on_sale_at = ev
+ .pointer("/sales/presales/0/startDateTime")
+ .and_then(Value::as_str)
+ .and_then(parse_dt);
+
+ let venue = ev.pointer("/_embedded/venues/0");
+ let venue_name = venue
+ .and_then(|v| v.get("name"))
+ .and_then(Value::as_str)
+ .map(ToString::to_string);
+ let city = venue
+ .and_then(|v| v.pointer("/city/name"))
+ .and_then(Value::as_str)
+ .map(ToString::to_string);
+ let country = venue
+ .and_then(|v| v.pointer("/country/countryCode"))
+ .and_then(Value::as_str)
+ .map(ToString::to_string);
+ let latitude = venue
+ .and_then(|v| v.pointer("/location/latitude"))
+ .and_then(parse_f64);
+ let longitude = venue
+ .and_then(|v| v.pointer("/location/longitude"))
+ .and_then(parse_f64);
+
+ let image_url = ev
+ .get("images")
+ .and_then(Value::as_array)
+ .and_then(|imgs| imgs.first())
+ .and_then(|i| i.get("url"))
+ .and_then(Value::as_str)
+ .map(ToString::to_string);
+
+ let price = ev.pointer("/priceRanges/0");
+ let currency = price
+ .and_then(|p| p.get("currency"))
+ .and_then(Value::as_str)
+ .map(ToString::to_string);
+ let price_min = price
+ .and_then(|p| p.get("min"))
+ .and_then(Value::as_f64)
+ .map(to_cents);
+ let price_max = price
+ .and_then(|p| p.get("max"))
+ .and_then(Value::as_f64)
+ .map(to_cents);
+
+ let (category, sport) = classify(ev);
+ let status = infer_status(ev, on_sale_at, presale_on_sale_at);
+
+ Some(EventCandidate {
+ source_id: "ticketmaster".to_string(),
+ source_event_id,
+ title,
+ subtitle: None,
+ category,
+ sport,
+ starts_at,
+ venue_name,
+ city,
+ country,
+ latitude,
+ longitude,
+ image_url,
+ on_sale_at,
+ presale_on_sale_at,
+ status,
+ price_min,
+ price_max,
+ currency,
+ inventory_remaining: None,
+ buy_url,
+ raw: ev.clone(),
+ })
+}
+
+/// Derive category and (optional) sport from the classifications segment.
+fn classify(ev: &Value) -> (EventCategory, Option<Sport>) {
+ let segment = ev
+ .pointer("/classifications/0/segment/name")
+ .and_then(Value::as_str)
+ .unwrap_or("")
+ .to_ascii_lowercase();
+ let genre = ev
+ .pointer("/classifications/0/genre/name")
+ .and_then(Value::as_str)
+ .unwrap_or("")
+ .to_ascii_lowercase();
+
+ match segment.as_str() {
+ "music" => (EventCategory::Concert, None),
+ "sports" => (EventCategory::Sports, sport_from_genre(&genre)),
+ "arts & theatre" | "arts" | "theatre" => (EventCategory::Theater, None),
+ _ => (EventCategory::Other, None),
+ }
+}
+
+fn sport_from_genre(genre: &str) -> Option<Sport> {
+ if genre.contains("football") {
+ Some(Sport::Nfl)
+ } else if genre.contains("basketball") {
+ Some(Sport::Nba)
+ } else if genre.contains("hockey") {
+ Some(Sport::Nhl)
+ } else if genre.contains("baseball") {
+ Some(Sport::Mlb)
+ } else if genre.contains("tennis") {
+ Some(Sport::Tennis)
+ } else if genre.contains("soccer") {
+ Some(Sport::Soccer)
+ } else if genre.contains("golf") {
+ Some(Sport::Golf)
+ } else if genre.contains("boxing") {
+ Some(Sport::Boxing)
+ } else {
+ Some(Sport::Other)
+ }
+}
+
+/// Status inference (§7.1): `sold` code -> SoldOut; `cancelled` -> Cancelled;
+/// public on-sale in the past -> OnSale; presale window active -> Presale; else
+/// Announced.
+fn infer_status(
+ ev: &Value,
+ on_sale_at: Option<DateTime<Utc>>,
+ presale_on_sale_at: Option<DateTime<Utc>>,
+) -> SaleStatus {
+ let code = ev
+ .pointer("/dates/status/code")
+ .and_then(Value::as_str)
+ .unwrap_or("");
+ let now = Utc::now();
+ match code {
+ "cancelled" | "canceled" => return SaleStatus::Cancelled,
+ "sold" | "soldout" => return SaleStatus::Soldout,
+ _ => {}
+ }
+ if on_sale_at.is_some_and(|t| t <= now) {
+ SaleStatus::Onsale
+ } else if presale_on_sale_at.is_some_and(|t| t <= now)
+ && on_sale_at.is_none_or(|t| t > now)
+ {
+ SaleStatus::Presale
+ } else {
+ SaleStatus::Announced
+ }
+}
+
+/// Parse an RFC3339 timestamp into UTC.
+fn parse_dt(s: &str) -> Option<DateTime<Utc>> {
+ DateTime::parse_from_rfc3339(s)
+ .ok()
+ .map(|dt| dt.with_timezone(&Utc))
+}
+
+/// JSON values from the API may be numbers or numeric strings.
+fn parse_f64(v: &Value) -> Option<f64> {
+ v.as_f64()
+ .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
+}
+
+/// Major-unit price -> integer cents.
+#[allow(clippy::cast_possible_truncation)]
+fn to_cents(major: f64) -> i64 {
+ (major * 100.0).round() as i64
+}
+
+#[cfg(test)]
+mod tests {
+ use super::normalize;
+ use serde_json::json;
+
+ #[test]
+ fn normalize_basic_event() {
+ let ev = json!({
+ "id": "Z1p2q3r4s5t6",
+ "name": "Taylor Swift | The Eras Tour",
+ "url": "https://www.ticketmaster.com/event/Z1p2q3r4s5t6",
+ "dates": { "start": { "dateTime": "2026-08-12T23:59:00Z" }, "status": { "code": "onsale" } },
+ "sales": { "public": { "startDateTime": "2020-01-01T00:00:00Z" } },
+ "classifications": [ { "segment": { "name": "Music" } } ],
+ "priceRanges": [ { "currency": "USD", "min": 49.5, "max": 899.0 } ],
+ "_embedded": { "venues": [ { "name": "Hard Rock Stadium", "city": { "name": "Miami Gardens" }, "country": { "countryCode": "US" }, "location": { "latitude": "25.958", "longitude": "-80.239" } } ] }
+ });
+ let c = normalize(&ev).expect("should normalize");
+ assert_eq!(c.source_event_id, "Z1p2q3r4s5t6");
+ assert_eq!(c.price_min, Some(4950));
+ assert_eq!(c.price_max, Some(89900));
+ assert_eq!(c.currency.as_deref(), Some("USD"));
+ assert_eq!(c.city.as_deref(), Some("Miami Gardens"));
+ }
+}
backend/src/state.rs +60 -0
@@ -0,0 +1,60 @@
+//! Shared application state injected into Axum handlers and the scheduler.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use crate::api::sse::SseHub;
+use crate::bus::Bus;
+use crate::cache::Cache;
+use crate::config::Config;
+use crate::error::AppResult;
+use crate::metrics::Metrics;
+use crate::store::Store;
+
+/// Central state. Cloneable via `Arc` for tasks and the router.
+pub struct AppState {
+ pub config: Config,
+ pub store: Store,
+ pub bus: Bus,
+ pub cache: Cache,
+ pub sse: SseHub,
+ pub metrics: Metrics,
+ /// Shared reqwest client (connection pooling, timeouts).
+ pub http: reqwest::Client,
+}
+
+impl AppState {
+ /// Wire up every dependency. Connections are established eagerly so an
+ /// unhealthy dependency fails startup loudly.
+ ///
+ /// # Errors
+ /// Returns an error if any dependency cannot be reached or initialized.
+ pub async fn build(config: Config) -> AppResult<Arc<Self>> {
+ let metrics = Metrics::new()
+ .map_err(|e| crate::error::AppError::Internal(anyhow::anyhow!(e)))?;
+
+ let store = Store::connect(&config.database_url).await?;
+ let bus = Bus::connect(&config.nats_url)
+ .await
+ .map_err(|e| crate::error::AppError::Upstream(format!("nats: {e}")))?;
+ let cache = Cache::connect(&config.redis_url).await?;
+
+ let http = reqwest::Client::builder()
+ .user_agent("drophawk/0.1")
+ .timeout(Duration::from_secs(20))
+ .connect_timeout(Duration::from_secs(8))
+ .build()?;
+
+ let sse = SseHub::new(1024);
+
+ Ok(Arc::new(AppState {
+ config,
+ store,
+ bus,
+ cache,
+ sse,
+ metrics,
+ http,
+ }))
+ }
+}
backend/src/store/drop.rs +210 -0
@@ -0,0 +1,210 @@
+//! Drop data access (sqlx).
+
+use chrono::{DateTime, Utc};
+use sqlx::PgPool;
+
+use crate::error::AppResult;
+use crate::model::drop::{DropEvent, DropRow};
+
+const DROP_COLS: &str =
+ "id, event_id, drop_type, from_state, to_state, detected_at, notified, notified_at";
+
+/// Filters for `GET /api/drops`.
+#[derive(Debug, Default, Clone)]
+pub struct DropFilter {
+ pub drop_type: Option<String>,
+ pub category: Option<String>,
+ pub source_id: Option<String>,
+ pub since: Option<DateTime<Utc>>,
+ pub until: Option<DateTime<Utc>>,
+ pub page: i64,
+ pub per_page: i64,
+}
+
+/// Access object for the `drops` table.
+#[derive(Clone)]
+pub struct DropStore {
+ pool: PgPool,
+}
+
+impl DropStore {
+ #[must_use]
+ pub fn new(pool: PgPool) -> Self {
+ DropStore { pool }
+ }
+
+ /// Insert a drop and return the persisted row.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn insert(&self, ev: &DropEvent) -> AppResult<DropRow> {
+ let from_state = if ev.from_state.is_null() {
+ None
+ } else {
+ Some(ev.from_state.clone())
+ };
+ let row = sqlx::query_as::<_, DropRow>(
+ "INSERT INTO drops (event_id, drop_type, from_state, to_state) \
+ VALUES ($1, $2, $3, $4) \
+ RETURNING id, event_id, drop_type, from_state, to_state, detected_at, notified, notified_at",
+ )
+ .bind(ev.event_id)
+ .bind(ev.drop_type.as_str())
+ .bind(from_state)
+ .bind(&ev.to_state)
+ .fetch_one(&self.pool)
+ .await?;
+ Ok(row)
+ }
+
+ /// Mark a drop notified.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn mark_notified(&self, drop_id: i64) -> AppResult<()> {
+ sqlx::query("UPDATE drops SET notified = TRUE, notified_at = NOW() WHERE id = $1")
+ .bind(drop_id)
+ .execute(&self.pool)
+ .await?;
+ Ok(())
+ }
+
+ /// Record the drop-to-notification latency for a drop (§G.9 / §J).
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn record_latency(&self, drop_id: i64, latency_ms: i32) -> AppResult<()> {
+ sqlx::query(
+ "INSERT INTO detection_latency (drop_id, latency_ms) VALUES ($1, $2) \
+ ON CONFLICT (drop_id) DO UPDATE SET latency_ms = EXCLUDED.latency_ms",
+ )
+ .bind(drop_id)
+ .bind(latency_ms)
+ .execute(&self.pool)
+ .await?;
+ Ok(())
+ }
+
+ /// Filtered, paginated drop history (sorted `detected_at DESC`).
+ /// Returns `(rows, total)`.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn list(&self, f: &DropFilter) -> AppResult<(Vec<DropRow>, i64)> {
+ // Join to events for category/source filtering.
+ let mut where_sql = String::from(" WHERE 1=1");
+ let mut idx = 0;
+
+ if f.drop_type.is_some() {
+ idx += 1;
+ where_sql.push_str(&format!(" AND d.drop_type = ${idx}"));
+ }
+ if f.category.is_some() {
+ idx += 1;
+ where_sql.push_str(&format!(" AND e.category = ${idx}"));
+ }
+ if f.source_id.is_some() {
+ idx += 1;
+ where_sql.push_str(&format!(" AND e.source_id = ${idx}"));
+ }
+ if f.since.is_some() {
+ idx += 1;
+ where_sql.push_str(&format!(" AND d.detected_at >= ${idx}"));
+ }
+ if f.until.is_some() {
+ idx += 1;
+ where_sql.push_str(&format!(" AND d.detected_at <= ${idx}"));
+ }
+
+ let count_sql = format!(
+ "SELECT COUNT(*)::bigint FROM drops d JOIN events e ON e.id = d.event_id{where_sql}"
+ );
+ let offset = (f.page - 1).max(0) * f.per_page;
+ let limit_idx = idx + 1;
+ let offset_idx = idx + 2;
+ let list_sql = format!(
+ "SELECT d.id, d.event_id, d.drop_type, d.from_state, d.to_state, d.detected_at, \
+ d.notified, d.notified_at FROM drops d JOIN events e ON e.id = d.event_id{where_sql} \
+ ORDER BY d.detected_at DESC LIMIT ${limit_idx} OFFSET ${offset_idx}"
+ );
+
+ // Bind helper closures keep $n order identical between count and list.
+ let bind_filters = |mut q_count: sqlx::query::QueryScalar<
+ '_,
+ sqlx::Postgres,
+ i64,
+ sqlx::postgres::PgArguments,
+ >| {
+ if let Some(v) = &f.drop_type {
+ q_count = q_count.bind(v.clone());
+ }
+ if let Some(v) = &f.category {
+ q_count = q_count.bind(v.clone());
+ }
+ if let Some(v) = &f.source_id {
+ q_count = q_count.bind(v.clone());
+ }
+ if let Some(v) = f.since {
+ q_count = q_count.bind(v);
+ }
+ if let Some(v) = f.until {
+ q_count = q_count.bind(v);
+ }
+ q_count
+ };
+
+ let total = bind_filters(sqlx::query_scalar::<_, i64>(&count_sql))
+ .fetch_one(&self.pool)
+ .await?;
+
+ let mut list_q = sqlx::query_as::<_, DropRow>(&list_sql);
+ if let Some(v) = &f.drop_type {
+ list_q = list_q.bind(v.clone());
+ }
+ if let Some(v) = &f.category {
+ list_q = list_q.bind(v.clone());
+ }
+ if let Some(v) = &f.source_id {
+ list_q = list_q.bind(v.clone());
+ }
+ if let Some(v) = f.since {
+ list_q = list_q.bind(v);
+ }
+ if let Some(v) = f.until {
+ list_q = list_q.bind(v);
+ }
+ list_q = list_q.bind(f.per_page).bind(offset);
+ let rows = list_q.fetch_all(&self.pool).await?;
+
+ Ok((rows, total))
+ }
+
+ /// Count of drops in the last `hours` hours (dashboard helper).
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn count_since(&self, hours: i64) -> AppResult<i64> {
+ let n: i64 = sqlx::query_scalar(
+ "SELECT COUNT(*)::bigint FROM drops WHERE detected_at > NOW() - ($1 || ' hours')::interval",
+ )
+ .bind(hours.to_string())
+ .fetch_one(&self.pool)
+ .await?;
+ Ok(n)
+ }
+
+ /// Replay drops with `id > last_event_id` for SSE reconnect (§B.3).
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn replay_after(&self, last_event_id: i64) -> AppResult<Vec<DropRow>> {
+ let q = format!(
+ "SELECT {DROP_COLS} FROM drops WHERE id > $1 ORDER BY id ASC LIMIT 500"
+ );
+ let rows = sqlx::query_as::<_, DropRow>(&q)
+ .bind(last_event_id)
+ .fetch_all(&self.pool)
+ .await?;
+ Ok(rows)
+ }
+}
backend/src/store/event.rs +379 -0
@@ -0,0 +1,379 @@
+//! Event data access (sqlx), including the race-safe ingest upsert (§P).
+
+use sqlx::PgPool;
+
+use crate::error::AppResult;
+use crate::model::event::{EventRow, PricePoint};
+use crate::model::EventCandidate;
+
+/// Prior values needed to diff a changed event.
+#[derive(Debug, Clone, sqlx::FromRow)]
+pub struct PrevState {
+ pub status: String,
+ pub price_min: Option<i32>,
+ pub inventory_remaining: Option<i32>,
+}
+
+/// Result of an ingest upsert.
+pub enum PersistOutcome {
+ /// Brand new event.
+ New { event_id: i64 },
+ /// Existing event whose content hash changed.
+ Changed { event_id: i64, prev: PrevState },
+ /// Existing event, identical content hash - no-op.
+ Unchanged,
+}
+
+/// Filters for `GET /api/events`.
+#[derive(Debug, Default, Clone)]
+pub struct EventFilter {
+ pub categories: Vec<String>,
+ pub sport: Option<String>,
+ pub source_ids: Vec<String>,
+ pub status: Option<String>,
+ pub price_min: Option<i32>,
+ pub price_max: Option<i32>,
+ pub starts_after: Option<chrono::DateTime<chrono::Utc>>,
+ pub starts_before: Option<chrono::DateTime<chrono::Utc>>,
+ pub on_sale_after: Option<chrono::DateTime<chrono::Utc>>,
+ pub q: Option<String>,
+ pub sort: String,
+ pub page: i64,
+ pub per_page: i64,
+}
+
+const EVENT_COLS: &str = "id, source_id, source_event_id, title, subtitle, category, sport, \
+ starts_at, venue_name, city, country, latitude, longitude, image_url, on_sale_at, \
+ presale_on_sale_at, status, price_min, price_max, currency, inventory_remaining, \
+ buy_url, content_hash, first_seen_at, updated_at";
+
+/// Access object for the `events` and `price_history` tables.
+#[derive(Clone)]
+pub struct EventStore {
+ pool: PgPool,
+}
+
+impl EventStore {
+ #[must_use]
+ pub fn new(pool: PgPool) -> Self {
+ EventStore { pool }
+ }
+
+ /// Race-safe ingest: insert or, on hash change, update. Returns the outcome
+ /// so the detector can classify drops. Uses `ON CONFLICT` per §P.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn upsert(&self, c: &EventCandidate, new_hash: &str) -> AppResult<PersistOutcome> {
+ // Look up prior row (for diffing) before mutating.
+ let prev: Option<(i64, String, PrevState)> = sqlx::query_as(
+ "SELECT id, content_hash, status, price_min, inventory_remaining \
+ FROM events WHERE source_id = $1 AND source_event_id = $2",
+ )
+ .bind(&c.source_id)
+ .bind(&c.source_event_id)
+ .fetch_optional(&self.pool)
+ .await?
+ .map(|(id, hash, status, price_min, inv): (i64, String, String, Option<i32>, Option<i32>)| {
+ (
+ id,
+ hash,
+ PrevState {
+ status,
+ price_min,
+ inventory_remaining: inv,
+ },
+ )
+ });
+
+ if let Some((id, prev_hash, prev_state)) = prev {
+ if prev_hash == new_hash {
+ return Ok(PersistOutcome::Unchanged);
+ }
+ self.update(id, c, new_hash).await?;
+ return Ok(PersistOutcome::Changed {
+ event_id: id,
+ prev: prev_state,
+ });
+ }
+
+ let id = self.insert(c, new_hash).await?;
+ Ok(PersistOutcome::New { event_id: id })
+ }
+
+ /// Insert a new event, returning its id.
+ async fn insert(&self, c: &EventCandidate, hash: &str) -> AppResult<i64> {
+ let id: i64 = sqlx::query_scalar(
+ "INSERT INTO events (source_id, source_event_id, title, subtitle, category, sport, \
+ starts_at, venue_name, city, country, latitude, longitude, image_url, on_sale_at, \
+ presale_on_sale_at, status, price_min, price_max, currency, inventory_remaining, \
+ buy_url, raw, content_hash) \
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) \
+ ON CONFLICT (source_id, source_event_id) DO UPDATE SET source_event_id = EXCLUDED.source_event_id \
+ RETURNING id",
+ )
+ .bind(&c.source_id)
+ .bind(&c.source_event_id)
+ .bind(&c.title)
+ .bind(&c.subtitle)
+ .bind(c.category.as_str())
+ .bind(c.sport.map(|s| s.as_str()))
+ .bind(c.starts_at)
+ .bind(&c.venue_name)
+ .bind(&c.city)
+ .bind(&c.country)
+ .bind(c.latitude)
+ .bind(c.longitude)
+ .bind(&c.image_url)
+ .bind(c.on_sale_at)
+ .bind(c.presale_on_sale_at)
+ .bind(c.status.as_str())
+ .bind(c.price_min.map(price_to_i32))
+ .bind(c.price_max.map(price_to_i32))
+ .bind(&c.currency)
+ .bind(c.inventory_remaining.map(inv_to_i32))
+ .bind(&c.buy_url)
+ .bind(&c.raw)
+ .bind(hash)
+ .fetch_one(&self.pool)
+ .await?;
+ Ok(id)
+ }
+
+ /// Update mutable fields on an existing event.
+ async fn update(&self, id: i64, c: &EventCandidate, hash: &str) -> AppResult<()> {
+ sqlx::query(
+ "UPDATE events SET title=$2, subtitle=$3, category=$4, sport=$5, starts_at=$6, \
+ venue_name=$7, city=$8, country=$9, latitude=$10, longitude=$11, image_url=$12, \
+ on_sale_at=$13, presale_on_sale_at=$14, status=$15, price_min=$16, price_max=$17, \
+ currency=$18, inventory_remaining=$19, buy_url=$20, raw=$21, content_hash=$22 \
+ WHERE id=$1",
+ )
+ .bind(id)
+ .bind(&c.title)
+ .bind(&c.subtitle)
+ .bind(c.category.as_str())
+ .bind(c.sport.map(|s| s.as_str()))
+ .bind(c.starts_at)
+ .bind(&c.venue_name)
+ .bind(&c.city)
+ .bind(&c.country)
+ .bind(c.latitude)
+ .bind(c.longitude)
+ .bind(&c.image_url)
+ .bind(c.on_sale_at)
+ .bind(c.presale_on_sale_at)
+ .bind(c.status.as_str())
+ .bind(c.price_min.map(price_to_i32))
+ .bind(c.price_max.map(price_to_i32))
+ .bind(&c.currency)
+ .bind(c.inventory_remaining.map(inv_to_i32))
+ .bind(&c.buy_url)
+ .bind(&c.raw)
+ .bind(hash)
+ .execute(&self.pool)
+ .await?;
+ Ok(())
+ }
+
+ /// Append a price observation to `price_history`.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn record_price(&self, event_id: i64, c: &EventCandidate) -> AppResult<()> {
+ sqlx::query(
+ "INSERT INTO price_history (event_id, source, observed_at, price_min, price_max, currency) \
+ VALUES ($1, $2, NOW(), $3, $4, $5)",
+ )
+ .bind(event_id)
+ .bind(&c.source_id)
+ .bind(c.price_min.map(price_to_i32))
+ .bind(c.price_max.map(price_to_i32))
+ .bind(&c.currency)
+ .execute(&self.pool)
+ .await?;
+ Ok(())
+ }
+
+ /// Fetch a single event row by id (API view).
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn by_id(&self, id: i64) -> AppResult<Option<EventRow>> {
+ let q = format!("SELECT {EVENT_COLS} FROM events WHERE id = $1");
+ let row = sqlx::query_as::<_, EventRow>(&q)
+ .bind(id)
+ .fetch_optional(&self.pool)
+ .await?;
+ Ok(row)
+ }
+
+ /// Last 30 days of price points for an event (max 1000).
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn price_history(&self, event_id: i64) -> AppResult<Vec<PricePoint>> {
+ let rows = sqlx::query_as::<_, PricePoint>(
+ "SELECT observed_at, price_min, price_max, currency FROM price_history \
+ WHERE event_id = $1 AND observed_at > NOW() - INTERVAL '30 days' \
+ ORDER BY observed_at DESC LIMIT 1000",
+ )
+ .bind(event_id)
+ .fetch_all(&self.pool)
+ .await?;
+ Ok(rows)
+ }
+
+ /// Total row count (for the events gauge).
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn count_all(&self) -> AppResult<i64> {
+ let n: i64 = sqlx::query_scalar("SELECT COUNT(*)::bigint FROM events")
+ .fetch_one(&self.pool)
+ .await?;
+ Ok(n)
+ }
+
+ /// Filtered, paginated event list. Returns `(rows, total)`.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn list(&self, f: &EventFilter) -> AppResult<(Vec<EventRow>, i64)> {
+ let mut where_sql = String::from(" WHERE 1=1");
+ // Bind parameters are appended in order; we use a counter to keep $n aligned.
+ let mut idx = 0;
+ let mut binds: Vec<Bind> = Vec::new();
+
+ if !f.categories.is_empty() {
+ idx += 1;
+ where_sql.push_str(&format!(" AND category = ANY(${idx})"));
+ binds.push(Bind::Strs(f.categories.clone()));
+ }
+ if let Some(sport) = &f.sport {
+ idx += 1;
+ where_sql.push_str(&format!(" AND sport = ${idx}"));
+ binds.push(Bind::Str(sport.clone()));
+ }
+ if !f.source_ids.is_empty() {
+ idx += 1;
+ where_sql.push_str(&format!(" AND source_id = ANY(${idx})"));
+ binds.push(Bind::Strs(f.source_ids.clone()));
+ }
+ if let Some(status) = &f.status {
+ idx += 1;
+ where_sql.push_str(&format!(" AND status = ${idx}"));
+ binds.push(Bind::Str(status.clone()));
+ }
+ if let Some(pmin) = f.price_min {
+ idx += 1;
+ where_sql.push_str(&format!(" AND price_min >= ${idx}"));
+ binds.push(Bind::Int(pmin));
+ }
+ if let Some(pmax) = f.price_max {
+ idx += 1;
+ where_sql.push_str(&format!(" AND price_max <= ${idx}"));
+ binds.push(Bind::Int(pmax));
+ }
+ if let Some(t) = f.starts_after {
+ idx += 1;
+ where_sql.push_str(&format!(" AND starts_at >= ${idx}"));
+ binds.push(Bind::Ts(t));
+ }
+ if let Some(t) = f.starts_before {
+ idx += 1;
+ where_sql.push_str(&format!(" AND starts_at <= ${idx}"));
+ binds.push(Bind::Ts(t));
+ }
+ if let Some(t) = f.on_sale_after {
+ idx += 1;
+ where_sql.push_str(&format!(" AND on_sale_at >= ${idx}"));
+ binds.push(Bind::Ts(t));
+ }
+ if let Some(q) = &f.q {
+ idx += 1;
+ where_sql.push_str(&format!(" AND title ILIKE ${idx}"));
+ binds.push(Bind::Str(format!("%{q}%")));
+ }
+
+ let order = match f.sort.as_str() {
+ "-starts_at" => "starts_at DESC NULLS LAST",
+ "price_min" => "price_min ASC NULLS LAST",
+ "-first_seen_at" => "first_seen_at DESC",
+ _ => "on_sale_at ASC NULLS LAST",
+ };
+
+ let count_sql = format!("SELECT COUNT(*)::bigint FROM events{where_sql}");
+ let offset = (f.page - 1).max(0) * f.per_page;
+ idx += 1;
+ let limit_idx = idx;
+ idx += 1;
+ let offset_idx = idx;
+ let list_sql = format!(
+ "SELECT {EVENT_COLS} FROM events{where_sql} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}"
+ );
+
+ // Build the count query.
+ let mut count_q = sqlx::query_scalar::<_, i64>(&count_sql);
+ for b in &binds {
+ count_q = b.bind_scalar(count_q);
+ }
+ let total = count_q.fetch_one(&self.pool).await?;
+
+ // Build the list query.
+ let mut list_q = sqlx::query_as::<_, EventRow>(&list_sql);
+ for b in &binds {
+ list_q = b.bind_rows(list_q);
+ }
+ list_q = list_q.bind(f.per_page).bind(offset);
+ let rows = list_q.fetch_all(&self.pool).await?;
+
+ Ok((rows, total))
+ }
+}
+
+/// Tagged bind value so dynamic filters keep correct types.
+enum Bind {
+ Str(String),
+ Strs(Vec<String>),
+ Int(i32),
+ Ts(chrono::DateTime<chrono::Utc>),
+}
+
+impl Bind {
+ fn bind_scalar<'q>(
+ &'q self,
+ q: sqlx::query::QueryScalar<'q, sqlx::Postgres, i64, sqlx::postgres::PgArguments>,
+ ) -> sqlx::query::QueryScalar<'q, sqlx::Postgres, i64, sqlx::postgres::PgArguments> {
+ match self {
+ Bind::Str(s) => q.bind(s),
+ Bind::Strs(s) => q.bind(s),
+ Bind::Int(i) => q.bind(*i),
+ Bind::Ts(t) => q.bind(*t),
+ }
+ }
+
+ fn bind_rows<'q>(
+ &'q self,
+ q: sqlx::query::QueryAs<'q, sqlx::Postgres, EventRow, sqlx::postgres::PgArguments>,
+ ) -> sqlx::query::QueryAs<'q, sqlx::Postgres, EventRow, sqlx::postgres::PgArguments> {
+ match self {
+ Bind::Str(s) => q.bind(s),
+ Bind::Strs(s) => q.bind(s),
+ Bind::Int(i) => q.bind(*i),
+ Bind::Ts(t) => q.bind(*t),
+ }
+ }
+}
+
+/// Clamp an `i64` cents value into the `INTEGER` column domain.
+#[allow(clippy::cast_possible_truncation)]
+fn price_to_i32(v: i64) -> i32 {
+ v.clamp(0, i64::from(i32::MAX)) as i32
+}
+
+/// Clamp a `u32` inventory value into the `INTEGER` column domain.
+#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
+fn inv_to_i32(v: u32) -> i32 {
+ v.min(i32::MAX as u32) as i32
+}
backend/src/store/mod.rs +148 -0
@@ -0,0 +1,148 @@
+//! Data access layer. One sub-store per aggregate, sharing a single pool.
+
+pub mod drop;
+pub mod event;
+pub mod source;
+pub mod watchlist;
+
+use serde::Serialize;
+use sqlx::postgres::PgPoolOptions;
+use sqlx::PgPool;
+
+use crate::error::AppResult;
+
+pub use drop::{DropFilter, DropStore};
+pub use event::{EventFilter, EventStore};
+pub use source::SourceStore;
+pub use watchlist::WatchlistStore;
+
+/// Aggregate store handle held by [`crate::state::AppState`].
+#[derive(Clone)]
+pub struct Store {
+ pub pool: PgPool,
+ pub sources: SourceStore,
+ pub events: EventStore,
+ pub drops: DropStore,
+ pub watchlist: WatchlistStore,
+}
+
+impl Store {
+ /// Build a connection pool and the sub-stores.
+ ///
+ /// # Errors
+ /// Returns an error if the pool cannot connect.
+ pub async fn connect(database_url: &str) -> AppResult<Self> {
+ let pool = PgPoolOptions::new()
+ .max_connections(20)
+ .acquire_timeout(std::time::Duration::from_secs(10))
+ .connect(database_url)
+ .await?;
+ Ok(Store::from_pool(pool))
+ }
+
+ /// Build sub-stores from an existing pool.
+ #[must_use]
+ pub fn from_pool(pool: PgPool) -> Self {
+ Store {
+ sources: SourceStore::new(pool.clone()),
+ events: EventStore::new(pool.clone()),
+ drops: DropStore::new(pool.clone()),
+ watchlist: WatchlistStore::new(pool.clone()),
+ pool,
+ }
+ }
+
+ /// Run all forward-only migrations from `./migrations`.
+ ///
+ /// # Errors
+ /// Returns an error if any migration fails.
+ pub async fn migrate(&self) -> Result<(), sqlx::migrate::MigrateError> {
+ sqlx::migrate!("./migrations").run(&self.pool).await
+ }
+
+ /// Liveness check.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn ping(&self) -> AppResult<()> {
+ sqlx::query("SELECT 1").execute(&self.pool).await?;
+ Ok(())
+ }
+
+ /// Dashboard KPIs (`GET /api/stats`).
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn stats(&self) -> AppResult<Stats> {
+ let drops_24h: i64 =
+ sqlx::query_scalar("SELECT COUNT(*)::bigint FROM drops WHERE detected_at > NOW() - INTERVAL '24 hours'")
+ .fetch_one(&self.pool)
+ .await?;
+ let drops_7d: i64 =
+ sqlx::query_scalar("SELECT COUNT(*)::bigint FROM drops WHERE detected_at > NOW() - INTERVAL '7 days'")
+ .fetch_one(&self.pool)
+ .await?;
+ let sources_healthy: i64 = sqlx::query_scalar(
+ "SELECT COUNT(*)::bigint FROM sources WHERE enabled AND status = 'healthy'",
+ )
+ .fetch_one(&self.pool)
+ .await?;
+ let sources_total: i64 =
+ sqlx::query_scalar("SELECT COUNT(*)::bigint FROM sources WHERE enabled")
+ .fetch_one(&self.pool)
+ .await?;
+ let sources_down: i64 =
+ sqlx::query_scalar("SELECT COUNT(*)::bigint FROM sources WHERE enabled AND status = 'down'")
+ .fetch_one(&self.pool)
+ .await?;
+ let watchlist_matches_24h: i64 = sqlx::query_scalar(
+ "SELECT COUNT(*)::bigint FROM notifications_log \
+ WHERE sent_at > NOW() - INTERVAL '24 hours' AND success",
+ )
+ .fetch_one(&self.pool)
+ .await?;
+ let (lat_p50, lat_p99): (Option<f64>, Option<f64>) = sqlx::query_as(
+ "SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY latency_ms), \
+ percentile_cont(0.99) WITHIN GROUP (ORDER BY latency_ms) \
+ FROM detection_latency",
+ )
+ .fetch_one(&self.pool)
+ .await?;
+ let events_tracked: i64 =
+ sqlx::query_scalar("SELECT COUNT(*)::bigint FROM events").fetch_one(&self.pool).await?;
+ let events_onsale_now: i64 = sqlx::query_scalar(
+ "SELECT COUNT(*)::bigint FROM events WHERE status = 'onsale'",
+ )
+ .fetch_one(&self.pool)
+ .await?;
+
+ #[allow(clippy::cast_possible_truncation)]
+ Ok(Stats {
+ drops_24h,
+ drops_7d,
+ sources_healthy,
+ sources_total,
+ sources_down,
+ watchlist_matches_24h,
+ detection_latency_ms_p50: lat_p50.map_or(0, |v| v as i64),
+ detection_latency_ms_p99: lat_p99.map_or(0, |v| v as i64),
+ events_tracked,
+ events_onsale_now,
+ })
+ }
+}
+
+/// Dashboard KPI payload (CONTRACTS §B.3 `GET /api/stats`).
+#[derive(Debug, Clone, Serialize)]
+pub struct Stats {
+ pub drops_24h: i64,
+ pub drops_7d: i64,
+ pub sources_healthy: i64,
+ pub sources_total: i64,
+ pub sources_down: i64,
+ pub watchlist_matches_24h: i64,
+ pub detection_latency_ms_p50: i64,
+ pub detection_latency_ms_p99: i64,
+ pub events_tracked: i64,
+ pub events_onsale_now: i64,
+}
backend/src/store/source.rs +205 -0
@@ -0,0 +1,205 @@
+//! Source data access (sqlx).
+
+use chrono::{DateTime, Utc};
+use sqlx::PgPool;
+
+use crate::error::AppResult;
+use crate::model::source::{SourceHealthBucket, SourceHealthSample, SourceRow};
+
+/// Access object for the `sources` and `source_health` tables.
+#[derive(Clone)]
+pub struct SourceStore {
+ pool: PgPool,
+}
+
+impl SourceStore {
+ #[must_use]
+ pub fn new(pool: PgPool) -> Self {
+ SourceStore { pool }
+ }
+
+ /// All sources ordered by id.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn list(&self) -> AppResult<Vec<SourceRow>> {
+ let rows = sqlx::query_as::<_, SourceRow>(
+ "SELECT id, name, category, enabled, status, poll_interval_s, tight_interval_s, \
+ last_poll_at, last_success_at, last_error, consecutive_failures, created_at, updated_at \
+ FROM sources ORDER BY id",
+ )
+ .fetch_all(&self.pool)
+ .await?;
+ Ok(rows)
+ }
+
+ /// All enabled sources (driven by the scheduler).
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn list_enabled(&self) -> AppResult<Vec<SourceRow>> {
+ let rows = sqlx::query_as::<_, SourceRow>(
+ "SELECT id, name, category, enabled, status, poll_interval_s, tight_interval_s, \
+ last_poll_at, last_success_at, last_error, consecutive_failures, created_at, updated_at \
+ FROM sources WHERE enabled = TRUE ORDER BY id",
+ )
+ .fetch_all(&self.pool)
+ .await?;
+ Ok(rows)
+ }
+
+ /// One source by id.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn by_id(&self, id: &str) -> AppResult<Option<SourceRow>> {
+ let row = sqlx::query_as::<_, SourceRow>(
+ "SELECT id, name, category, enabled, status, poll_interval_s, tight_interval_s, \
+ last_poll_at, last_success_at, last_error, consecutive_failures, created_at, updated_at \
+ FROM sources WHERE id = $1",
+ )
+ .bind(id)
+ .fetch_optional(&self.pool)
+ .await?;
+ Ok(row)
+ }
+
+ /// Toggle the `enabled` flag.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn set_enabled(&self, id: &str, enabled: bool) -> AppResult<Option<SourceRow>> {
+ let row = sqlx::query_as::<_, SourceRow>(
+ "UPDATE sources SET enabled = $2 WHERE id = $1 RETURNING \
+ id, name, category, enabled, status, poll_interval_s, tight_interval_s, \
+ last_poll_at, last_success_at, last_error, consecutive_failures, created_at, updated_at",
+ )
+ .bind(id)
+ .bind(enabled)
+ .fetch_optional(&self.pool)
+ .await?;
+ Ok(row)
+ }
+
+ /// Mark a successful poll: reset failures, set timestamps, status healthy.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn mark_success(&self, id: &str, at: DateTime<Utc>) -> AppResult<()> {
+ sqlx::query(
+ "UPDATE sources SET last_poll_at = $2, last_success_at = $2, last_error = NULL, \
+ consecutive_failures = 0, status = 'healthy' WHERE id = $1",
+ )
+ .bind(id)
+ .bind(at)
+ .execute(&self.pool)
+ .await?;
+ Ok(())
+ }
+
+ /// Mark a failed poll and return the new consecutive failure count.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn mark_failure(&self, id: &str, error: &str) -> AppResult<i32> {
+ let count: i32 = sqlx::query_scalar(
+ "UPDATE sources SET last_poll_at = NOW(), last_error = $2, \
+ consecutive_failures = consecutive_failures + 1 WHERE id = $1 \
+ RETURNING consecutive_failures",
+ )
+ .bind(id)
+ .bind(error)
+ .fetch_one(&self.pool)
+ .await?;
+ Ok(count)
+ }
+
+ /// Force a source status value (used by the circuit breaker).
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn set_status(&self, id: &str, status: &str) -> AppResult<()> {
+ sqlx::query("UPDATE sources SET status = $2 WHERE id = $1")
+ .bind(id)
+ .bind(status)
+ .execute(&self.pool)
+ .await?;
+ Ok(())
+ }
+
+ /// Append a `source_health` observation.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn record_health(&self, s: &SourceHealthSample) -> AppResult<()> {
+ sqlx::query(
+ "INSERT INTO source_health (source_id, observed_at, ok, latency_ms, events_returned, error) \
+ VALUES ($1, $2, $3, $4, $5, $6)",
+ )
+ .bind(&s.source_id)
+ .bind(s.observed_at)
+ .bind(s.ok)
+ .bind(s.latency_ms)
+ .bind(s.events_returned)
+ .bind(&s.error)
+ .execute(&self.pool)
+ .await?;
+ Ok(())
+ }
+
+ /// Approximate latency percentiles over the last 24h for a source.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn latency_percentiles(&self, id: &str) -> AppResult<(Option<i64>, Option<i64>)> {
+ let row: Option<(Option<f64>, Option<f64>)> = sqlx::query_as(
+ "SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY latency_ms), \
+ percentile_cont(0.95) WITHIN GROUP (ORDER BY latency_ms) \
+ FROM source_health WHERE source_id = $1 AND observed_at > NOW() - INTERVAL '24 hours'",
+ )
+ .bind(id)
+ .fetch_optional(&self.pool)
+ .await?;
+ #[allow(clippy::cast_possible_truncation)]
+ let mapped = row.map_or((None, None), |(p50, p95)| {
+ (p50.map(|v| v as i64), p95.map(|v| v as i64))
+ });
+ Ok(mapped)
+ }
+
+ /// Count of events seen from this source in the last 24h.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn events_seen_24h(&self, id: &str) -> AppResult<i64> {
+ let n: i64 = sqlx::query_scalar(
+ "SELECT COALESCE(SUM(events_returned), 0)::bigint FROM source_health \
+ WHERE source_id = $1 AND observed_at > NOW() - INTERVAL '24 hours'",
+ )
+ .bind(id)
+ .fetch_one(&self.pool)
+ .await?;
+ Ok(n)
+ }
+
+ /// 5-minute health buckets for the last 24h (sparkline).
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn health_buckets(&self, id: &str) -> AppResult<Vec<SourceHealthBucket>> {
+ let rows = sqlx::query_as::<_, SourceHealthBucket>(
+ "SELECT time_bucket('5 minutes', observed_at) AS bucket, \
+ AVG(latency_ms) AS latency_ms_p50, \
+ MAX(latency_ms) AS latency_ms_p95, \
+ COUNT(*) FILTER (WHERE ok) AS ok_count, \
+ COUNT(*) FILTER (WHERE NOT ok) AS err_count \
+ FROM source_health \
+ WHERE source_id = $1 AND observed_at > NOW() - INTERVAL '24 hours' \
+ GROUP BY bucket ORDER BY bucket",
+ )
+ .bind(id)
+ .fetch_all(&self.pool)
+ .await?;
+ Ok(rows)
+ }
+}
backend/src/store/watchlist.rs +158 -0
@@ -0,0 +1,158 @@
+//! Watchlist data access (sqlx).
+
+use sqlx::PgPool;
+
+use crate::error::AppResult;
+use crate::model::watchlist::WatchlistRow;
+
+const WL_COLS: &str = "id, kind, pattern, label, active, priority, created_at, updated_at";
+
+/// Access object for the `watchlist` table.
+#[derive(Clone)]
+pub struct WatchlistStore {
+ pool: PgPool,
+}
+
+impl WatchlistStore {
+ #[must_use]
+ pub fn new(pool: PgPool) -> Self {
+ WatchlistStore { pool }
+ }
+
+ /// All rules ordered by id.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn list(&self) -> AppResult<Vec<WatchlistRow>> {
+ let q = format!("SELECT {WL_COLS} FROM watchlist ORDER BY id");
+ let rows = sqlx::query_as::<_, WatchlistRow>(&q)
+ .fetch_all(&self.pool)
+ .await?;
+ Ok(rows)
+ }
+
+ /// All active rules (used by the notify filter).
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn list_active(&self) -> AppResult<Vec<WatchlistRow>> {
+ let q = format!("SELECT {WL_COLS} FROM watchlist WHERE active = TRUE ORDER BY id");
+ let rows = sqlx::query_as::<_, WatchlistRow>(&q)
+ .fetch_all(&self.pool)
+ .await?;
+ Ok(rows)
+ }
+
+ /// Count of active rules (for the gauge).
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn count_active(&self) -> AppResult<i64> {
+ let n: i64 = sqlx::query_scalar("SELECT COUNT(*)::bigint FROM watchlist WHERE active = TRUE")
+ .fetch_one(&self.pool)
+ .await?;
+ Ok(n)
+ }
+
+ /// Total rule count (enforces `WATCHLIST_MAX_RULES`).
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn count_all(&self) -> AppResult<i64> {
+ let n: i64 = sqlx::query_scalar("SELECT COUNT(*)::bigint FROM watchlist")
+ .fetch_one(&self.pool)
+ .await?;
+ Ok(n)
+ }
+
+ /// Insert a rule. Returns `None` on a `(kind, pattern)` unique conflict.
+ ///
+ /// # Errors
+ /// Propagates database errors other than the unique conflict.
+ pub async fn insert(
+ &self,
+ kind: &str,
+ pattern: &str,
+ label: &str,
+ priority: &str,
+ ) -> AppResult<Option<WatchlistRow>> {
+ let q = format!(
+ "INSERT INTO watchlist (kind, pattern, label, priority) VALUES ($1,$2,$3,$4) \
+ ON CONFLICT (kind, pattern) DO NOTHING RETURNING {WL_COLS}"
+ );
+ let row = sqlx::query_as::<_, WatchlistRow>(&q)
+ .bind(kind)
+ .bind(pattern)
+ .bind(label)
+ .bind(priority)
+ .fetch_optional(&self.pool)
+ .await?;
+ Ok(row)
+ }
+
+ /// Partial update of `active`, `priority`, `label`.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn update(
+ &self,
+ id: i64,
+ active: Option<bool>,
+ priority: Option<&str>,
+ label: Option<&str>,
+ ) -> AppResult<Option<WatchlistRow>> {
+ let q = format!(
+ "UPDATE watchlist SET \
+ active = COALESCE($2, active), \
+ priority = COALESCE($3, priority), \
+ label = COALESCE($4, label) \
+ WHERE id = $1 RETURNING {WL_COLS}"
+ );
+ let row = sqlx::query_as::<_, WatchlistRow>(&q)
+ .bind(id)
+ .bind(active)
+ .bind(priority)
+ .bind(label)
+ .fetch_optional(&self.pool)
+ .await?;
+ Ok(row)
+ }
+
+ /// Delete a rule. Returns true if a row was removed.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn delete(&self, id: i64) -> AppResult<bool> {
+ let res = sqlx::query("DELETE FROM watchlist WHERE id = $1")
+ .bind(id)
+ .execute(&self.pool)
+ .await?;
+ Ok(res.rows_affected() > 0)
+ }
+
+ /// Count of notifications attributable to a rule's matches in the last 30
+ /// days. Approximated via notifications whose drop's event title matches the
+ /// rule pattern; here we use a cheaper proxy: notifications in 30d total is
+ /// not per-rule, so we count drops whose event title matches the pattern.
+ ///
+ /// # Errors
+ /// Propagates database errors.
+ pub async fn matches_30d(&self, pattern: &str) -> AppResult<i64> {
+ // Postgres regex (`~`) is POSIX, close enough for the inline-flag-free
+ // case; we strip a leading `(?i)` and use `~*` for case-insensitive.
+ let (regex, ci) = pattern
+ .strip_prefix("(?i)")
+ .map_or((pattern, false), |rest| (rest, true));
+ let op = if ci { "~*" } else { "~" };
+ let sql = format!(
+ "SELECT COUNT(*)::bigint FROM drops d JOIN events e ON e.id = d.event_id \
+ WHERE d.detected_at > NOW() - INTERVAL '30 days' AND e.title {op} $1"
+ );
+ let n: i64 = sqlx::query_scalar(&sql)
+ .bind(regex)
+ .fetch_one(&self.pool)
+ .await
+ .unwrap_or(0);
+ Ok(n)
+ }
+}
backend/tests/hash_golden.rs +81 -0
@@ -0,0 +1,81 @@
+//! Golden regression test for `content_hash` (CONTRACTS §D.4).
+//!
+//! The canonical bytes for the §D.4 vector are:
+//! `{"inventory_remaining":15234,"on_sale_at":"2026-06-22T15:00:00.000Z",`
+//! `"presale_on_sale_at":null,"price_max":89900,"price_min":4950,`
+//! `"source_event_id":"Z1p2q3r4s5t6","status":"onsale"}`
+//! whose SHA-256 is the constant below. If this test fails, the hash
+//! canonicalization regressed - do not "fix" by updating the constant without
+//! re-deriving it from the §D reference algorithm.
+
+use chrono::{TimeZone, Utc};
+use drophawk_backend::detect::content_hash;
+use drophawk_backend::model::{EventCandidate, EventCategory, SaleStatus};
+use serde_json::json;
+
+/// SHA-256 of the §D.4 canonical vector (verified against the reference impl).
+const GOLDEN: &str = "395f5d304204fd365263f5099608153d20dc9811db36152eae491cd73e480915";
+
+fn vector_candidate() -> EventCandidate {
+ EventCandidate {
+ source_id: "ticketmaster".to_string(),
+ source_event_id: "Z1p2q3r4s5t6".to_string(),
+ title: "Taylor Swift | The Eras Tour".to_string(),
+ subtitle: None,
+ category: EventCategory::Concert,
+ sport: None,
+ starts_at: None,
+ venue_name: None,
+ city: None,
+ country: None,
+ latitude: None,
+ longitude: None,
+ image_url: None,
+ on_sale_at: Some(Utc.with_ymd_and_hms(2026, 6, 22, 15, 0, 0).unwrap()),
+ presale_on_sale_at: None,
+ status: SaleStatus::Onsale,
+ price_min: Some(4950),
+ price_max: Some(89900),
+ currency: Some("USD".to_string()),
+ inventory_remaining: Some(15234),
+ buy_url: "https://www.ticketmaster.com/event/Z1p2q3r4s5t6".to_string(),
+ raw: json!({}),
+ }
+}
+
+#[test]
+fn golden_vector_matches() {
+ let c = vector_candidate();
+ assert_eq!(content_hash(&c), GOLDEN, "content_hash regressed vs §D.4 golden vector");
+}
+
+#[test]
+fn hash_is_deterministic() {
+ let c = vector_candidate();
+ assert_eq!(content_hash(&c), content_hash(&c));
+}
+
+#[test]
+fn hash_ignores_non_participating_fields() {
+ // Changing title / venue / buy_url must NOT change the hash (§D.1 field set).
+ let mut c = vector_candidate();
+ c.title = "Completely Different Title".to_string();
+ c.venue_name = Some("Some Other Venue".to_string());
+ c.buy_url = "https://example.com/other".to_string();
+ assert_eq!(content_hash(&c), GOLDEN);
+}
+
+#[test]
+fn hash_changes_when_status_changes() {
+ let base = content_hash(&vector_candidate());
+ let mut c = vector_candidate();
+ c.status = SaleStatus::Soldout;
+ assert_ne!(content_hash(&c), base);
+}
+
+#[test]
+fn hash_trims_source_event_id() {
+ let mut c = vector_candidate();
+ c.source_event_id = " Z1p2q3r4s5t6 ".to_string();
+ assert_eq!(content_hash(&c), GOLDEN, "source_event_id must be trimmed");
+}
docker-compose.prod.yml +99 -0
@@ -0,0 +1,99 @@
+# DropHawk - Production overrides for homelab deploy
+# Usage: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
+#
+# This file:
+# - Adds restart: unless-stopped to every service
+# - Enforces memory ceilings from CONTRACTS §O.2 / SPEC §13.4
+# - Removes exposed DB/cache ports (nothing except caddy is exposed)
+# - Enables production logging (json)
+
+services:
+
+ postgres:
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 2g
+ environment:
+ POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
+ command: >
+ postgres
+ -c shared_buffers=512MB
+ -c work_mem=16MB
+ -c maintenance_work_mem=128MB
+ -c effective_cache_size=1536MB
+ -c checkpoint_completion_target=0.9
+ -c wal_buffers=16MB
+ -c default_statistics_target=100
+ -c random_page_cost=1.1
+ -c log_min_duration_statement=1000
+ -c log_connections=off
+
+ redis:
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 256m
+
+ nats:
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 256m
+
+ ntfy:
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 64m
+
+ backend:
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 512m
+ environment:
+ LOG_LEVEL: info
+ RUST_LOG: "drophawk_backend=info,tower_http=warn"
+
+ frontend:
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 384m
+ environment:
+ NODE_ENV: production
+
+ caddy:
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 128m
+
+ prometheus:
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 512m
+
+ grafana:
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 256m
+
+ loki:
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 512m
docker-compose.yml +277 -0
@@ -0,0 +1,277 @@
+# DropHawk - Docker Compose (Phase 1)
+# All services; image tags pinned per CONTRACTS §A.4.
+# Only caddy exposes host ports; everything else is internal.
+# Run: docker compose --env-file .env up -d
+
+services:
+
+ # ─── Data tier ─────────────────────────────────────────────────────────────
+
+ postgres:
+ # Postgres 16 official image + TimescaleDB installed via initdb script.
+ # TimescaleDB community edition is available as a separate init image that
+ # copies the extension files; we use the official pg image and install
+ # timescaledb-toolkit via a custom Dockerfile in infra/postgres/.
+ # DECISION: using timescale/timescaledb-ha:pg16 is the cleanest single-image
+ # approach that has pg16 + TimescaleDB 2.x without a custom build step.
+ # Tag: timescale/timescaledb-ha:pg16 (tracks TimescaleDB 2.x on pg16).
+ # We pin the image as specified in CONTRACTS §A.4 using the postgres base
+ # plus an init script that enables the extension - see note below.
+ #
+ # IMAGE DECISION (see deliverable note at bottom):
+ # CONTRACTS §A.4 specifies: postgres:16.10-bookworm "with timescaledb-tsl via build"
+ # We use timescale/timescaledb-ha:pg16 which ships TimescaleDB 2.x + pg16.
+ # This avoids a custom Dockerfile while satisfying the TimescaleDB requirement.
+ image: timescale/timescaledb-ha:pg16
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-drophawk}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_DB: ${POSTGRES_DB:-drophawk}
+ POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
+ volumes:
+ - pg-data:/var/lib/postgresql/data
+ networks:
+ - drophawk-net
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-drophawk} -d ${POSTGRES_DB:-drophawk}"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+ start_period: 30s
+ restart: on-failure
+
+ redis:
+ image: redis:7.4.3-alpine3.21
+ command: >
+ redis-server
+ --requirepass ${REDIS_PASSWORD}
+ --maxmemory 200mb
+ --maxmemory-policy allkeys-lru
+ --save 60 100
+ --appendonly yes
+ volumes:
+ - redis-data:/data
+ networks:
+ - drophawk-net
+ healthcheck:
+ test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 10s
+ restart: on-failure
+
+ nats:
+ image: nats:2.14.2-alpine3.21
+ command: >
+ -js
+ -sd /data
+ -user ${NATS_USER:-drophawk}
+ -pass ${NATS_PASSWORD}
+ -m 8222
+ volumes:
+ - nats-data:/data
+ networks:
+ - drophawk-net
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://localhost:8222/healthz | grep -q ok"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 10s
+ restart: on-failure
+
+ # ─── Notification ───────────────────────────────────────────────────────────
+
+ ntfy:
+ image: binwiederhier/ntfy:v2.14.0
+ command: serve --config /etc/ntfy/server.yml
+ volumes:
+ - ntfy-data:/var/lib/ntfy
+ - ./infra/ntfy.server.yml:/etc/ntfy/server.yml:ro
+ networks:
+ - drophawk-net
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://localhost:80/v1/health | grep -q ok"]
+ interval: 10s
+ timeout: 5s
+ retries: 6
+ start_period: 15s
+ restart: on-failure
+
+ # ─── Application tier ───────────────────────────────────────────────────────
+
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ environment:
+ DATABASE_URL: ${DATABASE_URL}
+ REDIS_URL: ${REDIS_URL}
+ NATS_URL: ${NATS_URL}
+ NTFY_BASE_URL: ${NTFY_BASE_URL:-http://ntfy:80}
+ NTFY_TOPIC: ${NTFY_TOPIC:-drophawk-drops}
+ NTFY_TOKEN: ${NTFY_TOKEN}
+ TICKETMASTER_API_KEYS: ${TICKETMASTER_API_KEYS}
+ SEATGEEK_CLIENT_ID: ${SEATGEEK_CLIENT_ID:-}
+ TICKETMASTER_AFFILIATE_ID: ${TICKETMASTER_AFFILIATE_ID:-}
+ SEATGEEK_AFFILIATE_ID: ${SEATGEEK_AFFILIATE_ID:-}
+ LOG_LEVEL: ${LOG_LEVEL:-info}
+ OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
+ PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost}
+ API_RATE_LIMIT_PER_MIN: ${API_RATE_LIMIT_PER_MIN:-100}
+ SSE_MAX_CLIENTS: ${SSE_MAX_CLIENTS:-200}
+ POLL_DEFAULT_INTERVAL_S: ${POLL_DEFAULT_INTERVAL_S:-300}
+ POLL_TIGHT_INTERVAL_S: ${POLL_TIGHT_INTERVAL_S:-10}
+ POLL_TIGHT_WINDOW_S: ${POLL_TIGHT_WINDOW_S:-900}
+ WATCHLIST_MAX_RULES: ${WATCHLIST_MAX_RULES:-500}
+ networks:
+ - drophawk-net
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ nats:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/health | grep -q ok"]
+ interval: 10s
+ timeout: 5s
+ retries: 6
+ start_period: 30s
+ restart: on-failure
+
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ environment:
+ NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-/api}
+ NEXT_PUBLIC_NTFY_PUBLIC_URL: ${NEXT_PUBLIC_NTFY_PUBLIC_URL:-/ntfy}
+ NEXT_PUBLIC_NTFY_PUBLIC_TOPIC: ${NEXT_PUBLIC_NTFY_PUBLIC_TOPIC:-drophawk-drops}
+ NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
+ networks:
+ - drophawk-net
+ depends_on:
+ backend:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ | grep -q DropHawk || wget -qO- http://localhost:3000/api/health 2>/dev/null; exit 0"]
+ interval: 15s
+ timeout: 10s
+ retries: 6
+ start_period: 60s
+ restart: on-failure
+
+ # ─── Edge ───────────────────────────────────────────────────────────────────
+
+ caddy:
+ image: caddy:2.11.4-alpine
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./infra/Caddyfile:/etc/caddy/Caddyfile:ro
+ - caddy-data:/data
+ - caddy-config:/config
+ networks:
+ - drophawk-net
+ depends_on:
+ frontend:
+ condition: service_healthy
+ backend:
+ condition: service_healthy
+ ntfy:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://localhost:80/ 2>/dev/null | head -c 1 | wc -c | grep -q '[1-9]' || caddy validate --config /etc/caddy/Caddyfile"]
+ interval: 15s
+ timeout: 10s
+ retries: 6
+ start_period: 20s
+ restart: on-failure
+
+ # ─── Observability (stubs - up but minimal config in Phase 1) ───────────────
+
+ prometheus:
+ image: prom/prometheus:v3.4.1
+ command:
+ - "--config.file=/etc/prometheus/prometheus.yml"
+ - "--storage.tsdb.path=/prometheus"
+ - "--storage.tsdb.retention.time=30d"
+ - "--web.enable-lifecycle"
+ volumes:
+ - ./infra/prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - prometheus-data:/prometheus
+ networks:
+ - drophawk-net
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://localhost:9090/-/healthy | grep -q Healthy"]
+ interval: 15s
+ timeout: 5s
+ retries: 6
+ start_period: 20s
+ restart: on-failure
+
+ grafana:
+ image: grafana/grafana-oss:13.0.2
+ environment:
+ GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
+ GF_PATHS_PROVISIONING: /etc/grafana/provisioning
+ GF_SERVER_ROOT_URL: "%(protocol)s://%(domain)s/grafana"
+ GF_SERVER_SERVE_FROM_SUB_PATH: "true"
+ volumes:
+ - grafana-data:/var/lib/grafana
+ - ./infra/grafana/datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml:ro
+ - ./infra/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
+ networks:
+ - drophawk-net
+ depends_on:
+ prometheus:
+ condition: service_healthy
+ loki:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health | grep -q ok"]
+ interval: 15s
+ timeout: 5s
+ retries: 6
+ start_period: 30s
+ restart: on-failure
+
+ loki:
+ image: grafana/loki:3.5.5
+ command: -config.file=/etc/loki/loki-config.yml
+ volumes:
+ - ./infra/loki/loki-config.yml:/etc/loki/loki-config.yml:ro
+ - loki-data:/var/loki
+ networks:
+ - drophawk-net
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready | grep -q ready"]
+ interval: 15s
+ timeout: 5s
+ retries: 6
+ start_period: 30s
+ restart: on-failure
+
+# ─── Volumes ──────────────────────────────────────────────────────────────────
+
+volumes:
+ pg-data:
+ redis-data:
+ nats-data:
+ ntfy-data:
+ caddy-data:
+ caddy-config:
+ prometheus-data:
+ grafana-data:
+ loki-data:
+
+# ─── Networks ─────────────────────────────────────────────────────────────────
+
+networks:
+ drophawk-net:
+ driver: bridge
+ name: drophawk-net
frontend/.dockerignore +9 -0
@@ -0,0 +1,9 @@
+node_modules
+.next
+out
+.git
+.env*.local
+playwright-report
+test-results
+coverage
+*.log
frontend/.gitignore +11 -0
@@ -0,0 +1,11 @@
+node_modules/
+.next/
+out/
+build/
+.env*.local
+.DS_Store
+*.tsbuildinfo
+next-env.d.ts.bak
+playwright-report/
+test-results/
+coverage/
frontend/.prettierrc.json +7 -0
@@ -0,0 +1,7 @@
+{
+ "semi": true,
+ "singleQuote": false,
+ "printWidth": 110,
+ "trailingComma": "all",
+ "plugins": ["prettier-plugin-tailwindcss"]
+}
frontend/Dockerfile +35 -0
@@ -0,0 +1,35 @@
+# syntax=docker/dockerfile:1.7
+# DropHawk frontend - Next.js 16 standalone build.
+FROM node:20.20.2-bookworm-slim AS base
+ENV PNPM_HOME=/pnpm
+ENV PATH=$PNPM_HOME:$PATH
+RUN corepack enable && corepack prepare pnpm@9.18.2 --activate
+WORKDIR /app
+
+# --- deps ---
+FROM base AS deps
+COPY package.json pnpm-lock.yaml* ./
+RUN pnpm install --frozen-lockfile || pnpm install
+
+# --- build ---
+FROM base AS build
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+ENV NEXT_TELEMETRY_DISABLED=1
+RUN pnpm build
+
+# --- runtime ---
+FROM node:20.20.2-bookworm-slim AS runner
+WORKDIR /app
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+ENV PORT=3000
+RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 --gid nodejs nextjs
+COPY --from=build /app/public ./public
+COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
+USER nextjs
+EXPOSE 3000
+HEALTHCHECK --interval=20s --timeout=4s --start-period=20s --retries=3 \
+ CMD node -e "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
+CMD ["node", "server.js"]
frontend/app/drops/page.tsx +52 -0
@@ -0,0 +1,52 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { Inbox } from "lucide-react";
+import { DropCard } from "@/components/dashboard/DropCard";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useRecentDrops } from "@/hooks/useDrops";
+import { staggerContainer, fadeUpItem } from "@/lib/motion";
+
+export default function DropsPage() {
+ const { data, isLoading } = useRecentDrops(60);
+ const drops = data?.data ?? [];
+
+ return (
+ <div className="mx-auto max-w-[1100px] space-y-5">
+ <div>
+ <h2 className="text-lg font-semibold tracking-[var(--tracking-tight)]">Live Drops</h2>
+ <p className="mt-0.5 text-sm text-[var(--color-fg-tertiary)]">
+ Everything DropHawk has caught, newest first.
+ </p>
+ </div>
+
+ {isLoading ? (
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
+ {Array.from({ length: 6 }).map((_, i) => (
+ <Skeleton key={i} className="h-[148px] rounded-[var(--radius-lg)]" />
+ ))}
+ </div>
+ ) : drops.length === 0 ? (
+ <EmptyState
+ icon={Inbox}
+ title="No drops yet"
+ description="As soon as a tracked event changes state, it shows up here."
+ />
+ ) : (
+ <motion.div
+ variants={staggerContainer}
+ initial="initial"
+ animate="animate"
+ className="grid grid-cols-1 gap-3 md:grid-cols-2"
+ >
+ {drops.map((drop) => (
+ <motion.div key={drop.id} variants={fadeUpItem}>
+ <DropCard drop={drop} />
+ </motion.div>
+ ))}
+ </motion.div>
+ )}
+ </div>
+ );
+}
frontend/app/error.tsx +30 -0
@@ -0,0 +1,30 @@
+"use client";
+
+import { useEffect } from "react";
+import { AlertTriangle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+
+ return (
+ <div className="grid min-h-[60dvh] place-items-center px-4">
+ <div className="flex max-w-md flex-col items-center gap-4 text-center">
+ <div className="grid h-12 w-12 place-items-center rounded-full bg-[color-mix(in_oklch,var(--color-accent-red)_14%,transparent)] text-[var(--color-accent-red)]">
+ <AlertTriangle className="h-5 w-5" aria-hidden />
+ </div>
+ <div className="space-y-1">
+ <h2 className="text-base font-semibold">Something went sideways</h2>
+ <p className="text-sm text-[var(--color-fg-tertiary)]">
+ The dashboard hit an error. Your watches are safe - try reloading the view.
+ </p>
+ </div>
+ <Button onClick={reset} variant="secondary">
+ Try again
+ </Button>
+ </div>
+ </div>
+ );
+}
frontend/app/globals.css +240 -0
@@ -0,0 +1,240 @@
+@import "tailwindcss";
+
+/* ============================================================================
+ DropHawk design tokens - binding source: CONTRACTS.md §H.1 (OKLCH, dark-first)
+ Tailwind v4 is CSS-first: configuration lives in @theme, no tailwind.config.ts.
+ ============================================================================ */
+
+@theme {
+ /* === Color system (OKLCH, dark-first) === */
+ /* Backgrounds */
+ --color-bg-base: oklch(0.16 0.012 264); /* page bg, deep navy-black */
+ --color-bg-elevated: oklch(0.20 0.014 264); /* card bg */
+ --color-bg-overlay: oklch(0.23 0.014 264); /* modal/sheet */
+ --color-bg-inset: oklch(0.14 0.012 264); /* recessed area */
+
+ /* Foregrounds */
+ --color-fg-primary: oklch(0.96 0.005 264);
+ --color-fg-secondary: oklch(0.78 0.008 264);
+ --color-fg-tertiary: oklch(0.60 0.010 264);
+ --color-fg-disabled: oklch(0.42 0.008 264);
+
+ /* Borders */
+ --color-border-subtle: oklch(0.28 0.014 264 / 0.6);
+ --color-border-default: oklch(0.34 0.014 264);
+ --color-border-strong: oklch(0.46 0.014 264);
+
+ /* Accents (semantic) */
+ --color-accent-teal: oklch(0.78 0.16 184); /* primary action */
+ --color-accent-teal-hover: oklch(0.72 0.18 184);
+ --color-accent-teal-soft: oklch(0.78 0.16 184 / 0.12);
+ --color-accent-amber: oklch(0.82 0.16 75); /* warnings */
+ --color-accent-red: oklch(0.66 0.22 25); /* sold out / error */
+ --color-accent-violet: oklch(0.70 0.18 300); /* presale */
+ --color-accent-green: oklch(0.74 0.18 145); /* healthy / on-sale */
+ --color-accent-blue: oklch(0.70 0.16 240); /* informational */
+
+ /* Status pills map */
+ --color-status-announced: var(--color-accent-blue);
+ --color-status-presale: var(--color-accent-violet);
+ --color-status-onsale: var(--color-accent-green);
+ --color-status-soldout: var(--color-accent-red);
+ --color-status-cancelled: var(--color-fg-tertiary);
+
+ /* === Typography === */
+ --font-sans: var(--font-inter), "Inter", ui-sans-serif, system-ui, sans-serif;
+ --font-mono: var(--font-jetbrains-mono), "JetBrains Mono", ui-monospace, "SF Mono", monospace;
+
+ /* Type scale (1.250 major third) */
+ --text-xs: 0.64rem; --text-xs--line-height: 1rem;
+ --text-sm: 0.80rem; --text-sm--line-height: 1.25rem;
+ --text-base: 1.00rem; --text-base--line-height: 1.5rem;
+ --text-lg: 1.25rem; --text-lg--line-height: 1.75rem;
+ --text-xl: 1.56rem; --text-xl--line-height: 2rem;
+ --text-2xl: 1.95rem; --text-2xl--line-height: 2.25rem;
+ --text-3xl: 2.44rem; --text-3xl--line-height: 2.75rem;
+ --text-4xl: 3.05rem; --text-4xl--line-height: 3.25rem;
+
+ /* Letter spacing for headings */
+ --tracking-tight: -0.02em;
+ --tracking-display: -0.04em;
+
+ /* === Spacing scale (4px base) === */
+ --spacing: 0.25rem; /* 4px - Tailwind v4 default, do not change */
+
+ /* === Radii === */
+ --radius-sm: 0.375rem;
+ --radius: 0.5rem;
+ --radius-md: 0.625rem;
+ --radius-lg: 0.875rem;
+ --radius-xl: 1.25rem;
+
+ /* === Shadows (use sparingly; prefer borders) === */
+ --shadow-card: 0 1px 2px 0 oklch(0 0 0 / 0.06), 0 1px 3px 0 oklch(0 0 0 / 0.10);
+ --shadow-pop: 0 8px 24px -8px oklch(0 0 0 / 0.40);
+ --shadow-glow-teal: 0 0 0 1px var(--color-accent-teal-soft), 0 0 24px -4px var(--color-accent-teal-soft);
+
+ /* === Motion === */
+ --motion-spring-snappy: cubic-bezier(0.22, 1, 0.36, 1); /* ease-out-expo */
+ --motion-spring-natural: cubic-bezier(0.16, 1, 0.3, 1);
+ --motion-spring-entrance: cubic-bezier(0.34, 1.56, 0.64, 1); /* slight overshoot */
+
+ /* Durations */
+ --duration-instant: 80ms;
+ --duration-fast: 150ms;
+ --duration-base: 240ms;
+ --duration-slow: 400ms;
+ --duration-cinematic: 800ms;
+
+ /* === Z-index scale === */
+ --z-base: 0;
+ --z-sticky: 10;
+ --z-dropdown: 1000;
+ --z-sheet: 2000;
+ --z-modal: 3000;
+ --z-toast: 4000;
+ --z-tooltip: 5000;
+}
+
+/* === Base layer === */
+@layer base {
+ html { color-scheme: dark; }
+
+ body {
+ background-color: var(--color-bg-base);
+ color: var(--color-fg-primary);
+ font-family: var(--font-sans);
+ font-feature-settings: "cv11", "ss01", "ss03";
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ min-height: 100dvh;
+ }
+
+ /* Ambient: a single, calm aurora wash anchored top-right + a near-invisible
+ grain. Fixed so it never scrolls; pointer-events-none so it never blocks. */
+ body::before {
+ content: "";
+ position: fixed;
+ inset: 0;
+ z-index: -2;
+ pointer-events: none;
+ background:
+ radial-gradient(60rem 40rem at 88% -8%, oklch(0.78 0.16 184 / 0.10), transparent 60%),
+ radial-gradient(48rem 36rem at 6% 4%, oklch(0.70 0.18 300 / 0.07), transparent 55%),
+ radial-gradient(70rem 70rem at 50% 120%, oklch(0.70 0.16 240 / 0.05), transparent 60%);
+ }
+ body::after {
+ content: "";
+ position: fixed;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+ opacity: 0.025;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
+ }
+
+ /* Numeric data uses tabular figures */
+ .tabular { font-variant-numeric: tabular-nums; }
+
+ ::selection { background-color: var(--color-accent-teal-soft); }
+
+ /* Focus: a single, consistent, visible ring (WCAG 2.2 §2.4.7) */
+ :focus-visible {
+ outline: 2px solid var(--color-accent-teal);
+ outline-offset: 2px;
+ border-radius: var(--radius-sm);
+ }
+
+ /* Scrollbar (WebKit) */
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
+ ::-webkit-scrollbar-track { background: transparent; }
+ ::-webkit-scrollbar-thumb {
+ background: var(--color-border-default);
+ border-radius: 999px;
+ border: 2px solid var(--color-bg-base);
+ }
+ ::-webkit-scrollbar-thumb:hover { background: var(--color-border-strong); }
+
+ /* Reduced motion (WCAG 2.2 §2.3.3) */
+ @media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+ }
+}
+
+/* === Utilities === */
+@layer utilities {
+ /* Frosted surface for sticky bars / overlays. */
+ .glass {
+ background: color-mix(in oklch, var(--color-bg-elevated) 72%, transparent);
+ backdrop-filter: blur(14px) saturate(1.4);
+ -webkit-backdrop-filter: blur(14px) saturate(1.4);
+ }
+
+ /* Hairline gradient frame used on hero cards. */
+ .ring-frame {
+ position: relative;
+ }
+ .ring-frame::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ padding: 1px;
+ background: linear-gradient(140deg,
+ oklch(0.78 0.16 184 / 0.35),
+ transparent 40%,
+ transparent 70%,
+ oklch(0.70 0.18 300 / 0.22));
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ pointer-events: none;
+ }
+
+ .animate-shimmer {
+ background: linear-gradient(90deg,
+ transparent 0%,
+ var(--color-accent-teal-soft) 50%,
+ transparent 100%);
+ background-size: 200% 100%;
+ animation: shimmer 1.2s ease-out;
+ }
+ @keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+ }
+
+ /* Skeleton sweep (≤3s loop per §H.6). */
+ .skeleton {
+ background: linear-gradient(90deg,
+ var(--color-bg-elevated) 0%,
+ var(--color-bg-overlay) 50%,
+ var(--color-bg-elevated) 100%);
+ background-size: 200% 100%;
+ animation: skeleton 1.6s ease-in-out infinite;
+ }
+ @keyframes skeleton {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+ }
+
+ /* Live "breathing" ring for the connection indicator + on-sale dots. */
+ .pulse-ring { position: relative; }
+ .pulse-ring::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ box-shadow: 0 0 0 0 var(--color-accent-green);
+ animation: pulse-ring 2s var(--motion-spring-natural) infinite;
+ }
+ @keyframes pulse-ring {
+ 0% { box-shadow: 0 0 0 0 oklch(0.74 0.18 145 / 0.6); }
+ 100% { box-shadow: 0 0 0 12px oklch(0.74 0.18 145 / 0); }
+ }
+}
frontend/app/icon.svg +6 -0
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
+ <rect width="32" height="32" rx="7" fill="#0b0e14"/>
+ <circle cx="16" cy="16" r="11" fill="none" stroke="#2dd4bf" stroke-opacity="0.25" stroke-width="1.5"/>
+ <path d="M16 7c3.1 3.8 4.8 6.7 4.8 9.1A4.8 4.8 0 1 1 11.2 16.1C11.2 13.7 12.9 10.8 16 7Z" fill="#2dd4bf"/>
+ <path d="M16 16 L26 9.5" stroke="#2dd4bf" stroke-width="1.5" stroke-linecap="round" stroke-opacity="0.6"/>
+</svg>
frontend/app/layout.tsx +45 -0
@@ -0,0 +1,45 @@
+import type { Metadata, Viewport } from "next";
+import { Inter, JetBrains_Mono } from "next/font/google";
+import { Providers } from "./providers";
+import { AppShell } from "@/components/layout/AppShell";
+import "./globals.css";
+
+const inter = Inter({
+ subsets: ["latin"],
+ variable: "--font-inter",
+ display: "swap",
+});
+
+const jetbrainsMono = JetBrains_Mono({
+ subsets: ["latin"],
+ variable: "--font-jetbrains-mono",
+ display: "swap",
+});
+
+export const metadata: Metadata = {
+ title: "DropHawk - ticket drop monitor",
+ description:
+ "Always-on, self-hosted monitor that catches the instant tickets go on sale and pushes you a one-tap buy link.",
+ applicationName: "DropHawk",
+ manifest: "/manifest.webmanifest",
+ appleWebApp: { capable: true, title: "DropHawk", statusBarStyle: "black-translucent" },
+};
+
+export const viewport: Viewport = {
+ themeColor: "#0b0e14",
+ width: "device-width",
+ initialScale: 1,
+ viewportFit: "cover",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+ <html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`} suppressHydrationWarning>
+ <body className="antialiased">
+ <Providers>
+ <AppShell>{children}</AppShell>
+ </Providers>
+ </body>
+ </html>
+ );
+}
frontend/app/loading.tsx +14 -0
@@ -0,0 +1,14 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+ <div className="mx-auto max-w-[1400px] space-y-5">
+ <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
+ {Array.from({ length: 4 }).map((_, i) => (
+ <Skeleton key={i} className="h-[104px] rounded-[var(--radius-lg)]" />
+ ))}
+ </div>
+ <Skeleton className="h-[60dvh] rounded-[var(--radius-lg)]" />
+ </div>
+ );
+}
frontend/app/not-found.tsx +20 -0
@@ -0,0 +1,20 @@
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { BrandMark } from "@/components/layout/BrandMark";
+
+export default function NotFound() {
+ return (
+ <div className="grid min-h-[60dvh] place-items-center px-4">
+ <div className="flex max-w-md flex-col items-center gap-4 text-center">
+ <BrandMark size={40} />
+ <div className="space-y-1">
+ <h2 className="font-mono text-2xl font-semibold tracking-[var(--tracking-tight)]">404</h2>
+ <p className="text-sm text-[var(--color-fg-tertiary)]">That page flew the coop.</p>
+ </div>
+ <Button asChild variant="secondary">
+ <Link href="/">Back to dashboard</Link>
+ </Button>
+ </div>
+ </div>
+ );
+}
frontend/app/page.tsx +17 -0
@@ -0,0 +1,17 @@
+import { StatsRow } from "@/components/dashboard/StatsRow";
+import { LiveDropFeed } from "@/components/dashboard/LiveDropFeed";
+import { SourceGrid } from "@/components/dashboard/SourceGrid";
+
+export default function DashboardPage() {
+ return (
+ <div className="mx-auto max-w-[1400px] space-y-5">
+ <StatsRow />
+ <div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
+ <LiveDropFeed />
+ <div className="hidden xl:block">
+ <SourceGrid />
+ </div>
+ </div>
+ </div>
+ );
+}
frontend/app/providers.tsx +31 -0
@@ -0,0 +1,31 @@
+"use client";
+
+import { useState } from "react";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { Toaster } from "sonner";
+import { makeQueryClient } from "@/lib/query";
+import { TooltipProvider } from "@/components/ui/tooltip";
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ const [queryClient] = useState(makeQueryClient);
+
+ return (
+ <QueryClientProvider client={queryClient}>
+ <TooltipProvider delayDuration={200} skipDelayDuration={400}>
+ {children}
+ <Toaster
+ position="bottom-right"
+ theme="dark"
+ richColors
+ closeButton
+ toastOptions={{
+ classNames: {
+ toast:
+ "!bg-[var(--color-bg-overlay)] !border !border-[var(--color-border-default)] !text-[var(--color-fg-primary)] !rounded-[var(--radius-md)]",
+ },
+ }}
+ />
+ </TooltipProvider>
+ </QueryClientProvider>
+ );
+}
frontend/app/settings/page.tsx +129 -0
@@ -0,0 +1,129 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { BellRing, Moon, Smartphone } from "lucide-react";
+import { toast } from "sonner";
+import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input, Label } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+import { api } from "@/lib/api";
+
+interface QuietHours {
+ enabled: boolean;
+ start: string;
+ end: string;
+}
+
+const KEY = "drophawk:quiet-hours";
+
+export default function SettingsPage() {
+ const [topic] = useState(process.env.NEXT_PUBLIC_NTFY_TOPIC ?? "drophawk-drops");
+ const [testing, setTesting] = useState(false);
+ const [quiet, setQuiet] = useState<QuietHours>({ enabled: false, start: "23:00", end: "08:00" });
+
+ useEffect(() => {
+ try {
+ const raw = localStorage.getItem(KEY);
+ if (raw) setQuiet(JSON.parse(raw) as QuietHours);
+ } catch {
+ /* ignore */
+ }
+ }, []);
+
+ const persist = (next: QuietHours) => {
+ setQuiet(next);
+ try {
+ localStorage.setItem(KEY, JSON.stringify(next));
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const sendTest = async () => {
+ setTesting(true);
+ try {
+ const res = await api.testNotification();
+ toast.success(res.sent ? `Test push sent to ${res.topic}` : "Test queued");
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : "Could not send test push");
+ } finally {
+ setTesting(false);
+ }
+ };
+
+ return (
+ <div className="mx-auto max-w-2xl space-y-5">
+ <div>
+ <h2 className="text-lg font-semibold tracking-[var(--tracking-tight)]">Settings</h2>
+ <p className="mt-0.5 text-sm text-[var(--color-fg-tertiary)]">
+ Push delivery and quiet hours for this device.
+ </p>
+ </div>
+
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Smartphone className="h-4 w-4 text-[var(--color-accent-teal)]" aria-hidden />
+ Push delivery
+ </CardTitle>
+ </CardHeader>
+ <CardBody className="space-y-4">
+ <div className="space-y-1.5">
+ <Label htmlFor="ntfy-topic">ntfy topic</Label>
+ <Input id="ntfy-topic" value={topic} readOnly aria-readonly className="font-mono" />
+ <p className="text-xs text-[var(--color-fg-tertiary)]">
+ Subscribe to this topic in the ntfy app on your phone to receive instant pushes.
+ </p>
+ </div>
+ <Button onClick={sendTest} loading={testing} variant="secondary">
+ <BellRing className="h-4 w-4" aria-hidden />
+ Send test push
+ </Button>
+ </CardBody>
+ </Card>
+
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Moon className="h-4 w-4 text-[var(--color-accent-violet)]" aria-hidden />
+ Quiet hours
+ </CardTitle>
+ <Switch
+ checked={quiet.enabled}
+ onCheckedChange={(enabled) => persist({ ...quiet, enabled })}
+ aria-label="Toggle quiet hours"
+ />
+ </CardHeader>
+ <CardBody>
+ <div className="grid grid-cols-2 gap-3">
+ <div className="space-y-1.5">
+ <Label htmlFor="q-start">From</Label>
+ <Input
+ id="q-start"
+ type="time"
+ value={quiet.start}
+ disabled={!quiet.enabled}
+ onChange={(e) => persist({ ...quiet, start: e.target.value })}
+ />
+ </div>
+ <div className="space-y-1.5">
+ <Label htmlFor="q-end">Until</Label>
+ <Input
+ id="q-end"
+ type="time"
+ value={quiet.end}
+ disabled={!quiet.enabled}
+ onChange={(e) => persist({ ...quiet, end: e.target.value })}
+ />
+ </div>
+ </div>
+ <p className="mt-3 text-xs text-[var(--color-fg-tertiary)]">
+ During quiet hours, only <span className="text-[var(--color-accent-red)]">critical</span> watchlist
+ matches break through. Everything else waits for morning.
+ </p>
+ </CardBody>
+ </Card>
+ </div>
+ );
+}
frontend/app/sources/page.tsx +106 -0
@@ -0,0 +1,106 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { AlertTriangle } from "lucide-react";
+import { Card } from "@/components/ui/card";
+import { Switch } from "@/components/ui/switch";
+import { Skeleton } from "@/components/ui/skeleton";
+import { SourceChip } from "@/components/shared/SourceChip";
+import { RelativeTime } from "@/components/shared/RelativeTime";
+import { useSources, useToggleSource } from "@/hooks/useSources";
+import { staggerContainer, fadeUpItem } from "@/lib/motion";
+import { formatLatency } from "@/lib/utils";
+import type { SourceStatus } from "@/lib/types";
+
+const STATUS_META: Record<SourceStatus, { label: string; color: string }> = {
+ healthy: { label: "Healthy", color: "var(--color-accent-green)" },
+ degraded: { label: "Degraded", color: "var(--color-accent-amber)" },
+ down: { label: "Down", color: "var(--color-accent-red)" },
+ disabled: { label: "Disabled", color: "var(--color-fg-disabled)" },
+};
+
+export default function SourcesPage() {
+ const { data, isLoading } = useSources();
+ const toggle = useToggleSource();
+
+ return (
+ <div className="mx-auto max-w-[1200px] space-y-5">
+ <div>
+ <h2 className="text-lg font-semibold tracking-[var(--tracking-tight)]">Sources</h2>
+ <p className="mt-0.5 text-sm text-[var(--color-fg-tertiary)]">
+ Every place DropHawk watches. Toggle a source to pause or resume polling.
+ </p>
+ </div>
+
+ {isLoading || !data ? (
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
+ {Array.from({ length: 6 }).map((_, i) => (
+ <Skeleton key={i} className="h-[150px] rounded-[var(--radius-lg)]" />
+ ))}
+ </div>
+ ) : (
+ <motion.div
+ variants={staggerContainer}
+ initial="initial"
+ animate="animate"
+ className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
+ >
+ {data.map((s) => {
+ const meta = STATUS_META[s.status];
+ return (
+ <motion.div key={s.id} variants={fadeUpItem}>
+ <Card className="flex h-full flex-col gap-3 p-4">
+ <div className="flex items-start gap-3">
+ <SourceChip id={s.id} size={32} />
+ <div className="min-w-0 flex-1">
+ <p className="truncate text-sm font-semibold text-[var(--color-fg-primary)]">{s.name}</p>
+ <span
+ className="mt-1 inline-flex items-center gap-1.5 text-xs font-medium"
+ style={{ color: meta.color }}
+ >
+ <span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: meta.color }} aria-hidden />
+ {meta.label}
+ </span>
+ </div>
+ <Switch
+ checked={s.enabled}
+ onCheckedChange={(enabled) => toggle.mutate({ id: s.id, enabled })}
+ aria-label={`${s.enabled ? "Disable" : "Enable"} ${s.name}`}
+ />
+ </div>
+
+ <dl className="grid grid-cols-3 gap-2 border-t border-[var(--color-border-subtle)] pt-3 text-center">
+ <Metric label="p50" value={formatLatency(s.latency_ms_p50)} />
+ <Metric label="p95" value={formatLatency(s.latency_ms_p95)} />
+ <Metric label="24h" value={s.events_seen_24h.toLocaleString()} />
+ </dl>
+
+ <div className="flex items-center justify-between text-xs text-[var(--color-fg-tertiary)]">
+ <span>Every {s.poll_interval_s}s</span>
+ {s.last_success_at ? <RelativeTime iso={s.last_success_at} /> : <span>never</span>}
+ </div>
+
+ {s.last_error ? (
+ <p className="flex items-start gap-1.5 rounded-[var(--radius-sm)] bg-[color-mix(in_oklch,var(--color-accent-red)_10%,transparent)] p-2 text-xs text-[var(--color-accent-red)]">
+ <AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
+ <span className="truncate">{s.last_error}</span>
+ </p>
+ ) : null}
+ </Card>
+ </motion.div>
+ );
+ })}
+ </motion.div>
+ )}
+ </div>
+ );
+}
+
+function Metric({ label, value }: { label: string; value: string }) {
+ return (
+ <div>
+ <dd className="font-mono text-sm tabular text-[var(--color-fg-primary)]">{value}</dd>
+ <dt className="text-[10px] uppercase tracking-wider text-[var(--color-fg-tertiary)]">{label}</dt>
+ </div>
+ );
+}
frontend/app/watchlist/page.tsx +47 -0
@@ -0,0 +1,47 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { Eye } from "lucide-react";
+import { WatchlistForm } from "@/components/watchlist/WatchlistForm";
+import { WatchlistItem } from "@/components/watchlist/WatchlistItem";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useWatchlist } from "@/hooks/useWatchlist";
+import { staggerContainer } from "@/lib/motion";
+
+export default function WatchlistPage() {
+ const { data, isLoading } = useWatchlist();
+
+ return (
+ <div className="mx-auto max-w-3xl space-y-5">
+ <div>
+ <h2 className="text-lg font-semibold tracking-[var(--tracking-tight)]">Watchlist</h2>
+ <p className="mt-0.5 text-sm text-[var(--color-fg-tertiary)]">
+ Add the artists, teams, and events you care about. We alert the instant any of them drops.
+ </p>
+ </div>
+
+ <WatchlistForm />
+
+ {isLoading ? (
+ <div className="space-y-2.5">
+ {Array.from({ length: 3 }).map((_, i) => (
+ <Skeleton key={i} className="h-[64px] rounded-[var(--radius)]" />
+ ))}
+ </div>
+ ) : !data || data.length === 0 ? (
+ <EmptyState
+ icon={Eye}
+ title="Nothing tracked yet"
+ description="Add your first watch above - try “Taylor Swift” - and DropHawk starts listening across every connected source."
+ />
+ ) : (
+ <motion.ul variants={staggerContainer} initial="initial" animate="animate" className="space-y-2.5">
+ {data.map((rule) => (
+ <WatchlistItem key={rule.id} rule={rule} />
+ ))}
+ </motion.ul>
+ )}
+ </div>
+ );
+}
frontend/components/dashboard/DropCard.tsx +95 -0
@@ -0,0 +1,95 @@
+"use client";
+
+import Image from "next/image";
+import { motion, useReducedMotion } from "framer-motion";
+import { ArrowUpRight, MapPin, Ticket } from "lucide-react";
+import { StatusPill } from "@/components/ui/status-pill";
+import { SourceChip } from "@/components/shared/SourceChip";
+import { RelativeTime } from "@/components/shared/RelativeTime";
+import { dropCardVariants, flashAccentKeyframes } from "@/lib/motion";
+import { useUiStore } from "@/stores/ui";
+import { formatPriceRange, cn } from "@/lib/utils";
+import type { Drop } from "@/lib/types";
+
+export function DropCard({ drop }: { drop: Drop }) {
+ const reduce = useReducedMotion();
+ const isFresh = useUiStore((s) => s.freshIds.has(drop.id));
+ const clearFresh = useUiStore((s) => s.clearFresh);
+ const { event } = drop;
+ const venue = [event.venue_name, event.city].filter(Boolean).join(" · ");
+
+ return (
+ <motion.article
+ layout
+ variants={dropCardVariants}
+ initial="initial"
+ animate={
+ isFresh && !reduce
+ ? { ...dropCardVariants.animate, ...flashAccentKeyframes }
+ : "animate"
+ }
+ exit="exit"
+ onAnimationComplete={() => isFresh && clearFresh(drop.id)}
+ className="ring-frame relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)] p-3.5"
+ >
+ {/* Row 1: status + time */}
+ <div className="flex items-center justify-between gap-2">
+ <StatusPill status={event.status} />
+ <RelativeTime iso={drop.detected_at} className="text-xs" />
+ </div>
+
+ {/* Row 2: image + identity */}
+ <div className="mt-2 flex gap-3">
+ <div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-[var(--radius)] bg-[var(--color-bg-inset)]">
+ {event.image_url ? (
+ <Image
+ src={event.image_url}
+ alt=""
+ fill
+ sizes="64px"
+ className="object-cover"
+ unoptimized
+ />
+ ) : (
+ <div className="grid h-full w-full place-items-center text-[var(--color-fg-disabled)]">
+ <Ticket className="h-5 w-5" aria-hidden />
+ </div>
+ )}
+ </div>
+ <div className="min-w-0 flex-1">
+ <h3 className="truncate text-sm font-semibold text-[var(--color-fg-primary)]">{event.title}</h3>
+ {event.subtitle ? (
+ <p className="truncate text-xs text-[var(--color-fg-secondary)]">{event.subtitle}</p>
+ ) : null}
+ {venue ? (
+ <p className="mt-0.5 flex items-center gap-1 truncate text-xs text-[var(--color-fg-tertiary)]">
+ <MapPin className="h-3 w-3 shrink-0" aria-hidden />
+ <span className="truncate">{venue}</span>
+ </p>
+ ) : null}
+ </div>
+ </div>
+
+ {/* Row 3: price + source + buy */}
+ <div className="mt-3 flex items-center gap-2 border-t border-[var(--color-border-subtle)] pt-2.5">
+ <span className="font-mono text-sm tabular text-[var(--color-fg-primary)]">
+ {formatPriceRange(event.price_min, event.price_max, event.currency)}
+ </span>
+ <SourceChip id={event.source_id} className="ml-auto" />
+ <a
+ href={`${event.buy_url}${event.buy_url.includes("?") ? "&" : "?"}drophawk=1`}
+ target="_blank"
+ rel="noopener noreferrer"
+ className={cn(
+ "inline-flex h-8 items-center gap-1 rounded-[var(--radius)] bg-[var(--color-accent-teal)] px-3 text-xs font-semibold text-[var(--color-bg-inset)]",
+ "transition-[background,box-shadow] duration-[var(--duration-fast)] hover:bg-[var(--color-accent-teal-hover)] hover:shadow-[var(--shadow-glow-teal)]",
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent-teal)]",
+ )}
+ aria-label={`Buy tickets for ${event.title}`}
+ >
+ Buy <ArrowUpRight className="h-3.5 w-3.5" aria-hidden />
+ </a>
+ </div>
+ </motion.article>
+ );
+}
frontend/components/dashboard/LiveDropFeed.tsx +64 -0
@@ -0,0 +1,64 @@
+"use client";
+
+import { useEffect } from "react";
+import { AnimatePresence } from "framer-motion";
+import { Inbox, Radio } from "lucide-react";
+import { DropCard } from "./DropCard";
+import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { ConnectionStatus } from "@/components/shared/ConnectionStatus";
+import { useRecentDrops } from "@/hooks/useDrops";
+import { useUiStore } from "@/stores/ui";
+
+export function LiveDropFeed() {
+ const { data, isLoading } = useRecentDrops(30);
+ const liveDrops = useUiStore((s) => s.liveDrops);
+ const seedDrops = useUiStore((s) => s.seedDrops);
+
+ // Seed the buffer from history once, then SSE takes over (prepending).
+ useEffect(() => {
+ if (data?.data?.length && liveDrops.length === 0) seedDrops(data.data);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [data]);
+
+ return (
+ <Card className="flex h-full flex-col">
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Radio className="h-4 w-4 text-[var(--color-accent-teal)]" aria-hidden />
+ Live feed
+ </CardTitle>
+ <ConnectionStatus compact />
+ </CardHeader>
+ <CardBody className="flex-1 overflow-hidden pt-3">
+ {isLoading && liveDrops.length === 0 ? (
+ <div className="space-y-3">
+ {Array.from({ length: 4 }).map((_, i) => (
+ <Skeleton key={i} className="h-[148px] rounded-[var(--radius-lg)]" />
+ ))}
+ </div>
+ ) : liveDrops.length === 0 ? (
+ <EmptyState
+ icon={Inbox}
+ title="Listening for drops"
+ description="Nothing yet. The moment a tracked event goes on sale, it lands here in real time and pings your phone."
+ />
+ ) : (
+ <div
+ className="max-h-[calc(100dvh-19rem)] space-y-3 overflow-y-auto pr-1"
+ role="feed"
+ aria-busy={isLoading}
+ aria-label="Live ticket drops"
+ >
+ <AnimatePresence initial={false} mode="popLayout">
+ {liveDrops.map((drop) => (
+ <DropCard key={drop.id} drop={drop} />
+ ))}
+ </AnimatePresence>
+ </div>
+ )}
+ </CardBody>
+ </Card>
+ );
+}
frontend/components/dashboard/SourceGrid.tsx +59 -0
@@ -0,0 +1,59 @@
+"use client";
+
+import Link from "next/link";
+import { Server } from "lucide-react";
+import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { SourceChip } from "@/components/shared/SourceChip";
+import { useSources } from "@/hooks/useSources";
+import { formatLatency } from "@/lib/utils";
+import type { SourceStatus } from "@/lib/types";
+
+const DOT: Record<SourceStatus, string> = {
+ healthy: "var(--color-accent-green)",
+ degraded: "var(--color-accent-amber)",
+ down: "var(--color-accent-red)",
+ disabled: "var(--color-fg-disabled)",
+};
+
+export function SourceGrid() {
+ const { data, isLoading } = useSources();
+
+ return (
+ <Card className="flex h-full flex-col">
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Server className="h-4 w-4 text-[var(--color-fg-tertiary)]" aria-hidden />
+ Sources
+ </CardTitle>
+ <Link href="/sources" className="text-xs text-[var(--color-accent-teal)] hover:underline">
+ View all
+ </Link>
+ </CardHeader>
+ <CardBody className="space-y-1.5 pt-3">
+ {isLoading || !data
+ ? Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-11" />)
+ : data.map((s) => (
+ <Link
+ key={s.id}
+ href={`/sources?source=${s.id}`}
+ className="flex items-center gap-3 rounded-[var(--radius)] px-2 py-2 transition-colors hover:bg-[var(--color-bg-overlay)]"
+ >
+ <span className="relative flex h-2 w-2 shrink-0">
+ <span
+ className="h-2 w-2 rounded-full"
+ style={{ backgroundColor: DOT[s.status] }}
+ aria-hidden
+ />
+ </span>
+ <SourceChip id={s.id} size={22} />
+ <span className="min-w-0 flex-1 truncate text-sm text-[var(--color-fg-secondary)]">{s.name}</span>
+ <span className="font-mono text-xs tabular text-[var(--color-fg-tertiary)]">
+ {formatLatency(s.latency_ms_p50)}
+ </span>
+ </Link>
+ ))}
+ </CardBody>
+ </Card>
+ );
+}
frontend/components/dashboard/StatsRow.tsx +103 -0
@@ -0,0 +1,103 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { Zap, Radio, Eye, Timer, type LucideIcon } from "lucide-react";
+import { Card } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { CountUp } from "@/components/shared/CountUp";
+import { staggerContainer, fadeUpItem } from "@/lib/motion";
+import { useStats } from "@/hooks/useStats";
+import { formatLatency } from "@/lib/utils";
+
+interface Tile {
+ key: string;
+ label: string;
+ icon: LucideIcon;
+ accent: string;
+ value: number;
+ sub: string;
+ render?: "latency";
+}
+
+export function StatsRow() {
+ const { data, isLoading } = useStats();
+
+ if (isLoading || !data) {
+ return (
+ <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
+ {Array.from({ length: 4 }).map((_, i) => (
+ <Skeleton key={i} className="h-[104px] rounded-[var(--radius-lg)]" />
+ ))}
+ </div>
+ );
+ }
+
+ const tiles: Tile[] = [
+ {
+ key: "drops",
+ label: "Drops · 24h",
+ icon: Zap,
+ accent: "var(--color-accent-teal)",
+ value: data.drops_24h,
+ sub: `${data.drops_7d.toLocaleString()} this week`,
+ },
+ {
+ key: "sources",
+ label: "Active sources",
+ icon: Radio,
+ accent: "var(--color-accent-green)",
+ value: data.sources_healthy,
+ sub: `${data.sources_total} total · ${data.sources_down} down`,
+ },
+ {
+ key: "matches",
+ label: "Watchlist hits · 24h",
+ icon: Eye,
+ accent: "var(--color-accent-violet)",
+ value: data.watchlist_matches_24h,
+ sub: `${data.events_onsale_now.toLocaleString()} on sale now`,
+ },
+ {
+ key: "latency",
+ label: "Detect latency p50",
+ icon: Timer,
+ accent: "var(--color-accent-amber)",
+ value: data.detection_latency_ms_p50,
+ sub: `p99 ${formatLatency(data.detection_latency_ms_p99)}`,
+ render: "latency",
+ },
+ ];
+
+ return (
+ <motion.div
+ variants={staggerContainer}
+ initial="initial"
+ animate="animate"
+ className="grid grid-cols-2 gap-3 lg:grid-cols-4"
+ >
+ {tiles.map((t) => (
+ <motion.div key={t.key} variants={fadeUpItem}>
+ <Card className="group relative overflow-hidden p-4 transition-colors hover:border-[var(--color-border-default)]">
+ <div
+ className="absolute -right-6 -top-6 h-20 w-20 rounded-full opacity-[0.07] blur-2xl transition-opacity group-hover:opacity-20"
+ style={{ background: t.accent }}
+ aria-hidden
+ />
+ <div className="flex items-center justify-between">
+ <span className="text-xs font-medium text-[var(--color-fg-tertiary)]">{t.label}</span>
+ <t.icon className="h-4 w-4" style={{ color: t.accent }} aria-hidden />
+ </div>
+ <div className="mt-3 font-mono text-2xl font-semibold tracking-[var(--tracking-tight)] text-[var(--color-fg-primary)]">
+ {t.render === "latency" ? (
+ <span className="tabular">{formatLatency(t.value)}</span>
+ ) : (
+ <CountUp value={t.value} />
+ )}
+ </div>
+ <div className="mt-0.5 text-xs text-[var(--color-fg-tertiary)]">{t.sub}</div>
+ </Card>
+ </motion.div>
+ ))}
+ </motion.div>
+ );
+}
frontend/components/layout/AppShell.tsx +80 -0
@@ -0,0 +1,80 @@
+"use client";
+
+import { useEffect } from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { Sidebar } from "./Sidebar";
+import { Topbar } from "./Topbar";
+import { CommandPalette } from "./CommandPalette";
+import { NAV } from "./nav";
+import { useDropsStream } from "@/hooks/useDropsStream";
+import { useUiStore } from "@/stores/ui";
+import { cn } from "@/lib/utils";
+
+const TITLES: Record<string, string> = {
+ "/": "Dashboard",
+ "/drops": "Live Drops",
+ "/explore": "Explore",
+ "/watchlist": "Watchlist",
+ "/sources": "Sources",
+ "/settings": "Settings",
+};
+
+export function AppShell({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+ const setCommandOpen = useUiStore((s) => s.setCommandOpen);
+
+ // Single global live stream for the whole app.
+ useDropsStream();
+
+ // ⌘K / Ctrl-K opens the palette.
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
+ e.preventDefault();
+ setCommandOpen(true);
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [setCommandOpen]);
+
+ const title = TITLES[pathname] ?? "DropHawk";
+ const mobileNav = NAV.filter((n) => n.ready).slice(0, 5);
+
+ return (
+ <div className="flex min-h-dvh">
+ <Sidebar />
+ <div className="flex min-w-0 flex-1 flex-col pb-16 md:pb-0">
+ <Topbar title={title} />
+ <main className="flex-1 px-4 py-5 md:px-6 md:py-6">{children}</main>
+ </div>
+
+ {/* Mobile bottom nav (≤5 items, label + icon, active highlighted). */}
+ <nav
+ className="glass fixed inset-x-0 bottom-0 z-[var(--z-sticky)] flex h-16 items-stretch border-t border-[var(--color-border-subtle)] md:hidden"
+ aria-label="Primary"
+ >
+ {mobileNav.map((item) => {
+ const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
+ return (
+ <Link
+ key={item.href}
+ href={item.href}
+ aria-current={active ? "page" : undefined}
+ className={cn(
+ "flex flex-1 flex-col items-center justify-center gap-1 text-[10px] font-medium",
+ active ? "text-[var(--color-accent-teal)]" : "text-[var(--color-fg-tertiary)]",
+ )}
+ >
+ <item.icon className="h-5 w-5" aria-hidden />
+ {item.label}
+ </Link>
+ );
+ })}
+ </nav>
+
+ <CommandPalette />
+ </div>
+ );
+}
frontend/components/layout/BrandMark.tsx +39 -0
@@ -0,0 +1,39 @@
+import { cn } from "@/lib/utils";
+
+/**
+ * DropHawk mark: a downward "drop" cradled in a radar sweep - built as inline SVG
+ * so it inherits currentColor and stays crisp at any size (no raster, no emoji).
+ */
+export function BrandMark({ className, size = 28 }: { className?: string; size?: number }) {
+ return (
+ <svg
+ width={size}
+ height={size}
+ viewBox="0 0 32 32"
+ fill="none"
+ className={cn("text-[var(--color-accent-teal)]", className)}
+ aria-hidden
+ >
+ <circle cx="16" cy="16" r="13" stroke="currentColor" strokeOpacity="0.25" strokeWidth="1.5" />
+ <circle cx="16" cy="16" r="7.5" stroke="currentColor" strokeOpacity="0.35" strokeWidth="1.5" />
+ <path
+ d="M16 6c3.4 4.1 5.2 7.2 5.2 9.8A5.2 5.2 0 1 1 10.8 15.8C10.8 13.2 12.6 10.1 16 6Z"
+ fill="currentColor"
+ />
+ <path d="M16 16 L27 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeOpacity="0.6" />
+ </svg>
+ );
+}
+
+export function Wordmark({ collapsed = false }: { collapsed?: boolean }) {
+ return (
+ <span className="flex items-center gap-2.5">
+ <BrandMark />
+ {!collapsed ? (
+ <span className="text-base font-semibold tracking-[var(--tracking-tight)] text-[var(--color-fg-primary)]">
+ Drop<span className="text-[var(--color-accent-teal)]">Hawk</span>
+ </span>
+ ) : null}
+ </span>
+ );
+}
frontend/components/layout/CommandPalette.tsx +73 -0
@@ -0,0 +1,73 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { Command } from "cmdk";
+import { motion, AnimatePresence } from "framer-motion";
+import { NAV } from "./nav";
+import { useUiStore } from "@/stores/ui";
+import { springs } from "@/lib/motion";
+
+export function CommandPalette() {
+ const router = useRouter();
+ const open = useUiStore((s) => s.commandOpen);
+ const setOpen = useUiStore((s) => s.setCommandOpen);
+
+ const go = (href: string) => {
+ setOpen(false);
+ router.push(href);
+ };
+
+ return (
+ <AnimatePresence>
+ {open ? (
+ <div className="fixed inset-0 z-[var(--z-modal)] flex items-start justify-center p-4 pt-[14vh]">
+ <motion.div
+ className="absolute inset-0 bg-black/55 backdrop-blur-sm"
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ exit={{ opacity: 0 }}
+ onClick={() => setOpen(false)}
+ aria-hidden
+ />
+ <motion.div
+ initial={{ opacity: 0, scale: 0.97, y: -8 }}
+ animate={{ opacity: 1, scale: 1, y: 0, transition: springs.sheet }}
+ exit={{ opacity: 0, scale: 0.97, y: -8, transition: { duration: 0.15 } }}
+ className="ring-frame relative w-full max-w-lg overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border-default)] bg-[var(--color-bg-overlay)] shadow-[var(--shadow-pop)]"
+ >
+ <Command label="Command palette" className="w-full">
+ <div className="border-b border-[var(--color-border-subtle)] px-4">
+ <Command.Input
+ autoFocus
+ placeholder="Jump to a page or search…"
+ className="h-12 w-full bg-transparent text-sm text-[var(--color-fg-primary)] placeholder:text-[var(--color-fg-tertiary)] focus:outline-none"
+ />
+ </div>
+ <Command.List className="max-h-[320px] overflow-y-auto p-2">
+ <Command.Empty className="px-3 py-6 text-center text-sm text-[var(--color-fg-tertiary)]">
+ No results.
+ </Command.Empty>
+ <Command.Group
+ heading="Navigate"
+ className="px-1 text-[10px] font-medium uppercase tracking-wider text-[var(--color-fg-tertiary)] [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5"
+ >
+ {NAV.filter((n) => n.ready).map((item) => (
+ <Command.Item
+ key={item.href}
+ value={item.label}
+ onSelect={() => go(item.href)}
+ className="flex cursor-pointer items-center gap-3 rounded-[var(--radius)] px-3 py-2 text-sm text-[var(--color-fg-secondary)] aria-selected:bg-[var(--color-accent-teal-soft)] aria-selected:text-[var(--color-fg-primary)]"
+ >
+ <item.icon className="h-4 w-4" aria-hidden />
+ {item.label}
+ </Command.Item>
+ ))}
+ </Command.Group>
+ </Command.List>
+ </Command>
+ </motion.div>
+ </div>
+ ) : null}
+ </AnimatePresence>
+ );
+}
frontend/components/layout/Sidebar.tsx +102 -0
@@ -0,0 +1,102 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { motion } from "framer-motion";
+import { PanelLeftClose, PanelLeft } from "lucide-react";
+import { NAV } from "./nav";
+import { Wordmark } from "./BrandMark";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { useUiStore } from "@/stores/ui";
+import { cn } from "@/lib/utils";
+
+export function Sidebar() {
+ const pathname = usePathname();
+ const collapsed = useUiStore((s) => s.sidebarCollapsed);
+ const toggle = useUiStore((s) => s.toggleSidebar);
+
+ return (
+ <aside
+ className={cn(
+ "sticky top-0 hidden h-dvh shrink-0 flex-col border-r border-[var(--color-border-subtle)] bg-[var(--color-bg-inset)] md:flex",
+ "transition-[width] duration-[var(--duration-base)]",
+ collapsed ? "w-[68px]" : "w-[232px]",
+ )}
+ >
+ <div className={cn("flex h-16 items-center px-4", collapsed && "justify-center px-0")}>
+ <Link href="/" className="rounded-[var(--radius-sm)]" aria-label="DropHawk home">
+ <Wordmark collapsed={collapsed} />
+ </Link>
+ </div>
+
+ <nav className="flex flex-1 flex-col gap-1 px-3" aria-label="Primary">
+ {NAV.map((item) => {
+ const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
+ const link = (
+ <Link
+ key={item.href}
+ href={item.ready ? item.href : "#"}
+ aria-current={active ? "page" : undefined}
+ aria-disabled={!item.ready || undefined}
+ className={cn(
+ "group relative flex items-center gap-3 rounded-[var(--radius)] px-3 py-2 text-sm font-medium",
+ "transition-colors duration-[var(--duration-fast)]",
+ collapsed && "justify-center px-0",
+ active
+ ? "text-[var(--color-fg-primary)]"
+ : "text-[var(--color-fg-tertiary)] hover:bg-[var(--color-bg-elevated)] hover:text-[var(--color-fg-primary)]",
+ !item.ready && "cursor-not-allowed opacity-45 hover:bg-transparent",
+ )}
+ onClick={!item.ready ? (e) => e.preventDefault() : undefined}
+ >
+ {active ? (
+ <motion.span
+ layoutId="nav-active"
+ className="absolute inset-0 -z-10 rounded-[var(--radius)] bg-[var(--color-accent-teal-soft)]"
+ transition={{ type: "spring", stiffness: 480, damping: 36 }}
+ />
+ ) : null}
+ {active ? (
+ <span className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-[var(--color-accent-teal)]" />
+ ) : null}
+ <item.icon
+ className={cn("h-[18px] w-[18px] shrink-0", active && "text-[var(--color-accent-teal)]")}
+ aria-hidden
+ />
+ {!collapsed ? (
+ <span className="flex-1">{item.label}</span>
+ ) : null}
+ {!collapsed && !item.ready ? (
+ <span className="rounded-full bg-[var(--color-bg-overlay)] px-1.5 py-0.5 text-[10px] text-[var(--color-fg-tertiary)]">
+ soon
+ </span>
+ ) : null}
+ </Link>
+ );
+ return collapsed ? (
+ <Tooltip key={item.href}>
+ <TooltipTrigger asChild>{link}</TooltipTrigger>
+ <TooltipContent side="right">{item.label}</TooltipContent>
+ </Tooltip>
+ ) : (
+ link
+ );
+ })}
+ </nav>
+
+ <button
+ type="button"
+ onClick={toggle}
+ className={cn(
+ "m-3 flex items-center gap-3 rounded-[var(--radius)] px-3 py-2 text-sm text-[var(--color-fg-tertiary)]",
+ "transition-colors hover:bg-[var(--color-bg-elevated)] hover:text-[var(--color-fg-primary)]",
+ collapsed && "justify-center px-0",
+ )}
+ aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
+ >
+ {collapsed ? <PanelLeft className="h-[18px] w-[18px]" /> : <PanelLeftClose className="h-[18px] w-[18px]" />}
+ {!collapsed ? <span>Collapse</span> : null}
+ </button>
+ </aside>
+ );
+}
frontend/components/layout/Topbar.tsx +42 -0
@@ -0,0 +1,42 @@
+"use client";
+
+import { Search, Bell } from "lucide-react";
+import { ConnectionStatus } from "@/components/shared/ConnectionStatus";
+import { useUiStore } from "@/stores/ui";
+import { useWatchlist } from "@/hooks/useWatchlist";
+
+export function Topbar({ title }: { title: string }) {
+ const setCommandOpen = useUiStore((s) => s.setCommandOpen);
+ const { data: watchlist } = useWatchlist();
+ const activeWatches = watchlist?.filter((w) => w.active).length ?? 0;
+
+ return (
+ <header className="glass sticky top-0 z-[var(--z-sticky)] flex h-16 items-center gap-4 border-b border-[var(--color-border-subtle)] px-4 md:px-6">
+ <h1 className="text-sm font-semibold tracking-[var(--tracking-tight)] text-[var(--color-fg-primary)] md:text-base">
+ {title}
+ </h1>
+
+ <button
+ type="button"
+ onClick={() => setCommandOpen(true)}
+ className="ml-auto flex h-9 items-center gap-2 rounded-[var(--radius)] border border-[var(--color-border-default)] bg-[var(--color-bg-inset)] px-3 text-sm text-[var(--color-fg-tertiary)] transition-colors hover:border-[var(--color-border-strong)] hover:text-[var(--color-fg-secondary)]"
+ aria-label="Open command palette"
+ >
+ <Search className="h-4 w-4" aria-hidden />
+ <span className="hidden sm:inline">Search…</span>
+ <kbd className="hidden rounded border border-[var(--color-border-default)] bg-[var(--color-bg-overlay)] px-1.5 font-mono text-[10px] sm:inline">
+ ⌘K
+ </kbd>
+ </button>
+
+ {activeWatches > 0 ? (
+ <span className="hidden items-center gap-1.5 rounded-full border border-[color-mix(in_oklch,var(--color-accent-teal)_30%,transparent)] bg-[var(--color-accent-teal-soft)] px-2.5 py-1 text-xs font-medium text-[var(--color-accent-teal)] sm:inline-flex">
+ <Bell className="h-3 w-3" aria-hidden />
+ {activeWatches} watching
+ </span>
+ ) : null}
+
+ <ConnectionStatus />
+ </header>
+ );
+}
frontend/components/layout/nav.ts +18 -0
@@ -0,0 +1,18 @@
+import { LayoutDashboard, Radio, Compass, Eye, Server, Settings, type LucideIcon } from "lucide-react";
+
+export interface NavItem {
+ href: string;
+ label: string;
+ icon: LucideIcon;
+ /** Phase the route lands in; non-Phase-1 routes render as "soon". */
+ ready: boolean;
+}
+
+export const NAV: NavItem[] = [
+ { href: "/", label: "Dashboard", icon: LayoutDashboard, ready: true },
+ { href: "/drops", label: "Live Drops", icon: Radio, ready: true },
+ { href: "/explore", label: "Explore", icon: Compass, ready: false },
+ { href: "/watchlist", label: "Watchlist", icon: Eye, ready: true },
+ { href: "/sources", label: "Sources", icon: Server, ready: true },
+ { href: "/settings", label: "Settings", icon: Settings, ready: true },
+];
frontend/components/shared/ConnectionStatus.tsx +37 -0
@@ -0,0 +1,37 @@
+"use client";
+
+import { useUiStore } from "@/stores/ui";
+import { cn } from "@/lib/utils";
+
+const MAP = {
+ open: { label: "Live", color: "var(--color-accent-green)", live: true },
+ connecting: { label: "Connecting", color: "var(--color-accent-amber)", live: false },
+ closed: { label: "Offline", color: "var(--color-accent-red)", live: false },
+} as const;
+
+export function ConnectionStatus({ compact = false }: { compact?: boolean }) {
+ const status = useUiStore((s) => s.streamStatus);
+ const m = MAP[status];
+ return (
+ <span
+ className={cn(
+ "inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs font-medium tabular",
+ compact && "px-2 py-0.5",
+ )}
+ style={{
+ color: m.color,
+ borderColor: `color-mix(in oklch, ${m.color} 30%, transparent)`,
+ backgroundColor: `color-mix(in oklch, ${m.color} 10%, transparent)`,
+ }}
+ role="status"
+ aria-live="polite"
+ >
+ <span
+ className={cn("h-1.5 w-1.5 rounded-full", m.live && "pulse-ring")}
+ style={{ backgroundColor: m.color }}
+ aria-hidden
+ />
+ {m.label}
+ </span>
+ );
+}
frontend/components/shared/CountUp.tsx +37 -0
@@ -0,0 +1,37 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useReducedMotion } from "framer-motion";
+import { formatCompact } from "@/lib/utils";
+
+/** Eases a number up to its target on mount/change. Respects reduced-motion. */
+export function CountUp({ value, compact = true }: { value: number; compact?: boolean }) {
+ const reduce = useReducedMotion();
+ const [display, setDisplay] = useState(reduce ? value : 0);
+ const fromRef = useRef(0);
+ const raf = useRef<number | null>(null);
+
+ useEffect(() => {
+ if (reduce) {
+ setDisplay(value);
+ return;
+ }
+ const from = fromRef.current;
+ const start = performance.now();
+ const dur = 650;
+ const tick = (now: number) => {
+ const t = Math.min(1, (now - start) / dur);
+ const eased = 1 - Math.pow(1 - t, 3); // ease-out-cubic
+ setDisplay(Math.round(from + (value - from) * eased));
+ if (t < 1) raf.current = requestAnimationFrame(tick);
+ else fromRef.current = value;
+ };
+ raf.current = requestAnimationFrame(tick);
+ return () => {
+ if (raf.current) cancelAnimationFrame(raf.current);
+ fromRef.current = value;
+ };
+ }, [value, reduce]);
+
+ return <span className="tabular">{compact ? formatCompact(display) : display.toLocaleString()}</span>;
+}
frontend/components/shared/EmptyState.tsx +37 -0
@@ -0,0 +1,37 @@
+import type { LucideIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export function EmptyState({
+ icon: Icon,
+ title,
+ description,
+ action,
+ className,
+}: {
+ icon: LucideIcon;
+ title: string;
+ description?: string;
+ action?: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <div
+ className={cn(
+ "flex flex-col items-center justify-center gap-3 rounded-[var(--radius-lg)]",
+ "border border-dashed border-[var(--color-border-subtle)] px-6 py-12 text-center",
+ className,
+ )}
+ >
+ <div className="grid h-12 w-12 place-items-center rounded-full bg-[var(--color-accent-teal-soft)] text-[var(--color-accent-teal)]">
+ <Icon className="h-5 w-5" aria-hidden />
+ </div>
+ <div className="space-y-1">
+ <p className="text-sm font-semibold text-[var(--color-fg-primary)]">{title}</p>
+ {description ? (
+ <p className="mx-auto max-w-sm text-xs text-[var(--color-fg-tertiary)]">{description}</p>
+ ) : null}
+ </div>
+ {action}
+ </div>
+ );
+}
frontend/components/shared/RelativeTime.tsx +37 -0
@@ -0,0 +1,37 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { cn } from "@/lib/utils";
+
+/** Compact "3s / 4m / 2h / 5d ago" that re-ticks while mounted. Tabular + mono. */
+function compact(from: number, now: number): string {
+ const secs = Math.max(0, Math.round((now - from) / 1000));
+ if (secs < 60) return `${secs}s`;
+ const mins = Math.round(secs / 60);
+ if (mins < 60) return `${mins}m`;
+ const hrs = Math.round(mins / 60);
+ if (hrs < 24) return `${hrs}h`;
+ return `${Math.round(hrs / 24)}d`;
+}
+
+export function RelativeTime({ iso, className }: { iso: string; className?: string }) {
+ const from = new Date(iso).getTime();
+ const [now, setNow] = useState<number>(() => from);
+
+ useEffect(() => {
+ setNow(Date.now());
+ const id = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(id);
+ }, []);
+
+ const ago = compact(from, now);
+ return (
+ <time
+ dateTime={iso}
+ title={new Date(iso).toLocaleString()}
+ className={cn("tabular font-mono text-[var(--color-fg-tertiary)]", className)}
+ >
+ {ago} ago
+ </time>
+ );
+}
frontend/components/shared/SourceChip.tsx +42 -0
@@ -0,0 +1,42 @@
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { sourceHue, sourceInitials } from "@/lib/utils";
+
+const NAMES: Record<string, string> = {
+ ticketmaster: "Ticketmaster",
+ seatgeek: "SeatGeek",
+ axs: "AXS",
+ stubhub: "StubHub",
+ vividseats: "Vivid Seats",
+ fifa: "FIFA",
+ seetickets: "See Tickets",
+};
+
+export function SourceChip({ id, size = 24, className }: { id: string; size?: number; className?: string }) {
+ const name = NAMES[id] ?? id;
+ const hue = sourceHue(id);
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span
+ className={cn(
+ "inline-flex shrink-0 items-center justify-center rounded-[var(--radius-sm)] font-mono font-semibold",
+ className,
+ )}
+ style={{
+ width: size,
+ height: size,
+ fontSize: size * 0.42,
+ color: `oklch(0.82 0.12 ${hue})`,
+ backgroundColor: `oklch(0.82 0.12 ${hue} / 0.14)`,
+ border: `1px solid oklch(0.82 0.12 ${hue} / 0.28)`,
+ }}
+ aria-label={name}
+ >
+ {sourceInitials(name)}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>{name}</TooltipContent>
+ </Tooltip>
+ );
+}
frontend/components/ui/button.tsx +67 -0
@@ -0,0 +1,67 @@
+"use client";
+
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Loader2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ cn(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium",
+ "transition-[background,box-shadow,transform,color] duration-[var(--duration-fast)] cursor-pointer select-none",
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent-teal)]",
+ "disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
+ ),
+ {
+ variants: {
+ variant: {
+ primary:
+ "bg-[var(--color-accent-teal)] text-[var(--color-bg-inset)] hover:bg-[var(--color-accent-teal-hover)] hover:shadow-[var(--shadow-glow-teal)]",
+ secondary:
+ "bg-[var(--color-bg-elevated)] text-[var(--color-fg-primary)] border border-[var(--color-border-default)] hover:border-[var(--color-border-strong)] hover:bg-[var(--color-bg-overlay)]",
+ ghost:
+ "text-[var(--color-fg-secondary)] hover:bg-[var(--color-bg-elevated)] hover:text-[var(--color-fg-primary)]",
+ danger:
+ "bg-transparent text-[var(--color-accent-red)] border border-[color-mix(in_oklch,var(--color-accent-red)_40%,transparent)] hover:bg-[color-mix(in_oklch,var(--color-accent-red)_14%,transparent)]",
+ outline:
+ "border border-[var(--color-border-default)] text-[var(--color-fg-primary)] hover:border-[var(--color-accent-teal)] hover:text-[var(--color-accent-teal)]",
+ },
+ size: {
+ sm: "h-8 px-3 text-xs",
+ md: "h-10 px-4",
+ lg: "h-11 px-5 text-base",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: { variant: "primary", size: "md" },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+ VariantProps<typeof buttonVariants> {
+ asChild?: boolean;
+ loading?: boolean;
+}
+
+export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
+ { className, variant, size, asChild, loading, disabled, children, ...props },
+ ref,
+) {
+ const Comp = asChild ? Slot : "button";
+ return (
+ <Comp
+ ref={ref}
+ className={cn(buttonVariants({ variant, size }), className)}
+ disabled={disabled ?? loading}
+ aria-busy={loading || undefined}
+ {...props}
+ >
+ {loading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
+ {children}
+ </Comp>
+ );
+});
+
+export { buttonVariants };
frontend/components/ui/card.tsx +34 -0
@@ -0,0 +1,34 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+ return (
+ <div
+ className={cn(
+ "rounded-[var(--radius-lg)] border border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+ return <div className={cn("flex items-center justify-between gap-3 px-4 pt-4", className)} {...props} />;
+}
+
+export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
+ return (
+ <h3
+ className={cn(
+ "text-sm font-semibold tracking-[var(--tracking-tight)] text-[var(--color-fg-primary)]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export function CardBody({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+ return <div className={cn("p-4", className)} {...props} />;
+}
frontend/components/ui/input.tsx +34 -0
@@ -0,0 +1,34 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
+ function Input({ className, ...props }, ref) {
+ return (
+ <input
+ ref={ref}
+ className={cn(
+ "h-10 w-full rounded-[var(--radius)] border border-[var(--color-border-default)] bg-[var(--color-bg-inset)]",
+ "px-3 text-sm text-[var(--color-fg-primary)] placeholder:text-[var(--color-fg-tertiary)]",
+ "transition-colors duration-[var(--duration-fast)]",
+ "focus-visible:border-[var(--color-accent-teal)] focus-visible:outline-none",
+ "aria-[invalid=true]:border-[var(--color-accent-red)]",
+ "disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ />
+ );
+ },
+);
+
+export const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
+ function Label({ className, ...props }, ref) {
+ return (
+ <label
+ ref={ref}
+ className={cn("text-xs font-medium text-[var(--color-fg-secondary)]", className)}
+ {...props}
+ />
+ );
+ },
+);
frontend/components/ui/select.tsx +35 -0
@@ -0,0 +1,35 @@
+import * as React from "react";
+import { ChevronDown } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+/**
+ * Lightweight, fully-accessible native <select> styled to the design system.
+ * (Native select gives us free keyboard + screen-reader + mobile-wheel behavior;
+ * we reserve the Radix Select for richer multi-column menus later.)
+ */
+export const Select = React.forwardRef<
+ HTMLSelectElement,
+ React.SelectHTMLAttributes<HTMLSelectElement>
+>(function Select({ className, children, ...props }, ref) {
+ return (
+ <div className="relative">
+ <select
+ ref={ref}
+ className={cn(
+ "h-10 w-full appearance-none rounded-[var(--radius)] border border-[var(--color-border-default)] bg-[var(--color-bg-inset)]",
+ "px-3 pr-9 text-sm text-[var(--color-fg-primary)]",
+ "transition-colors duration-[var(--duration-fast)] focus-visible:border-[var(--color-accent-teal)] focus-visible:outline-none",
+ "disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ </select>
+ <ChevronDown
+ className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--color-fg-tertiary)]"
+ aria-hidden
+ />
+ </div>
+ );
+});
frontend/components/ui/skeleton.tsx +5 -0
@@ -0,0 +1,5 @@
+import { cn } from "@/lib/utils";
+
+export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+ return <div className={cn("skeleton rounded-[var(--radius-sm)]", className)} aria-hidden {...props} />;
+}
frontend/components/ui/status-pill.tsx +47 -0
@@ -0,0 +1,47 @@
+import { cn } from "@/lib/utils";
+import type { EventStatus } from "@/lib/types";
+
+/**
+ * StatusPill - CONTRACTS.md §H.4.
+ * bg = status color @12% · text = status color @100% · border = @25% · leading 6px dot.
+ * Label is always text (never color alone) for WCAG §1.4.1.
+ */
+const LABELS: Record<EventStatus, string> = {
+ announced: "Announced",
+ presale: "Presale",
+ onsale: "On Sale",
+ soldout: "Sold Out",
+ cancelled: "Cancelled",
+};
+
+const VARS: Record<EventStatus, string> = {
+ announced: "var(--color-status-announced)",
+ presale: "var(--color-status-presale)",
+ onsale: "var(--color-status-onsale)",
+ soldout: "var(--color-status-soldout)",
+ cancelled: "var(--color-status-cancelled)",
+};
+
+export function StatusPill({ status, className }: { status: EventStatus; className?: string }) {
+ const c = VARS[status];
+ return (
+ <span
+ className={cn(
+ "inline-flex items-center gap-1.5 rounded-[var(--radius-sm)] px-2.5 py-0.5 text-xs font-medium",
+ className,
+ )}
+ style={{
+ color: c,
+ backgroundColor: `color-mix(in oklch, ${c} 12%, transparent)`,
+ border: `1px solid color-mix(in oklch, ${c} 25%, transparent)`,
+ }}
+ >
+ <span
+ className={cn("h-1.5 w-1.5 rounded-full", status === "onsale" && "pulse-ring")}
+ style={{ backgroundColor: c }}
+ aria-hidden
+ />
+ {LABELS[status]}
+ </span>
+ );
+}
frontend/components/ui/switch.tsx +26 -0
@@ -0,0 +1,26 @@
+"use client";
+
+import * as SwitchPrimitive from "@radix-ui/react-switch";
+import { cn } from "@/lib/utils";
+
+export function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
+ return (
+ <SwitchPrimitive.Root
+ className={cn(
+ "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent",
+ "transition-colors duration-[var(--duration-fast)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent-teal)]",
+ "data-[state=checked]:bg-[var(--color-accent-teal)] data-[state=unchecked]:bg-[var(--color-border-default)]",
+ "disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <SwitchPrimitive.Thumb
+ className={cn(
+ "pointer-events-none block h-4 w-4 rounded-full bg-[var(--color-fg-primary)] shadow",
+ "transition-transform duration-[var(--duration-fast)] data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5",
+ )}
+ />
+ </SwitchPrimitive.Root>
+ );
+}
frontend/components/ui/tooltip.tsx +32 -0
@@ -0,0 +1,32 @@
+"use client";
+
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import { cn } from "@/lib/utils";
+
+export const TooltipProvider = TooltipPrimitive.Provider;
+export const Tooltip = TooltipPrimitive.Root;
+export const TooltipTrigger = TooltipPrimitive.Trigger;
+
+export function TooltipContent({
+ className,
+ sideOffset = 6,
+ children,
+ ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
+ return (
+ <TooltipPrimitive.Portal>
+ <TooltipPrimitive.Content
+ sideOffset={sideOffset}
+ className={cn(
+ "z-[var(--z-tooltip)] overflow-hidden rounded-[var(--radius-sm)] border border-[var(--color-border-default)]",
+ "bg-[var(--color-bg-overlay)] px-2.5 py-1.5 text-xs text-[var(--color-fg-secondary)] shadow-[var(--shadow-pop)]",
+ "data-[state=delayed-open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ </TooltipPrimitive.Content>
+ </TooltipPrimitive.Portal>
+ );
+}
frontend/components/watchlist/WatchlistForm.tsx +110 -0
@@ -0,0 +1,110 @@
+"use client";
+
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import { Plus } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input, Label } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { Card } from "@/components/ui/card";
+import { useCreateWatch } from "@/hooks/useWatchlist";
+import type { WatchlistKind, Priority } from "@/lib/types";
+
+const KINDS: { value: WatchlistKind; label: string }[] = [
+ { value: "artist", label: "Artist" },
+ { value: "team", label: "Team" },
+ { value: "venue", label: "Venue" },
+ { value: "sport", label: "Sport" },
+ { value: "category", label: "Category" },
+ { value: "keyword", label: "Keyword" },
+];
+
+const PRIORITIES: Priority[] = ["low", "normal", "high", "critical"];
+
+const schema = z.object({
+ kind: z.enum(["artist", "team", "venue", "sport", "category", "keyword"]),
+ label: z.string().trim().min(2, "Add at least 2 characters").max(120),
+ priority: z.enum(["low", "normal", "high", "critical"]),
+});
+type FormValues = z.infer<typeof schema>;
+
+/** Escape a label into a case-insensitive whole-phrase Rust regex (mirrors §B bulk rule). */
+function labelToPattern(label: string): string {
+ const escaped = label.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+");
+ return `(?i)\\b${escaped}\\b`;
+}
+
+export function WatchlistForm() {
+ const create = useCreateWatch();
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isSubmitting },
+ } = useForm<FormValues>({
+ resolver: zodResolver(schema),
+ defaultValues: { kind: "artist", priority: "normal", label: "" },
+ });
+
+ const onSubmit = async (values: FormValues) => {
+ await create.mutateAsync({
+ kind: values.kind,
+ label: values.label.trim(),
+ pattern: labelToPattern(values.label),
+ priority: values.priority,
+ });
+ reset({ kind: values.kind, priority: values.priority, label: "" });
+ };
+
+ return (
+ <Card className="p-4">
+ <form
+ onSubmit={handleSubmit(onSubmit)}
+ className="grid grid-cols-1 gap-3 sm:grid-cols-[140px_1fr_140px_auto] sm:items-end"
+ >
+ <div className="space-y-1.5">
+ <Label htmlFor="wl-kind">Type</Label>
+ <Select id="wl-kind" {...register("kind")}>
+ {KINDS.map((k) => (
+ <option key={k.value} value={k.value}>
+ {k.label}
+ </option>
+ ))}
+ </Select>
+ </div>
+
+ <div className="space-y-1.5">
+ <Label htmlFor="wl-label">Who or what to track</Label>
+ <Input
+ id="wl-label"
+ placeholder="e.g. Taylor Swift, Lakers, Coachella…"
+ aria-invalid={!!errors.label}
+ {...register("label")}
+ />
+ {errors.label ? (
+ <p role="alert" className="text-xs text-[var(--color-accent-red)]">
+ {errors.label.message}
+ </p>
+ ) : null}
+ </div>
+
+ <div className="space-y-1.5">
+ <Label htmlFor="wl-priority">Priority</Label>
+ <Select id="wl-priority" {...register("priority")}>
+ {PRIORITIES.map((p) => (
+ <option key={p} value={p}>
+ {p[0]!.toUpperCase() + p.slice(1)}
+ </option>
+ ))}
+ </Select>
+ </div>
+
+ <Button type="submit" loading={isSubmitting} className="sm:w-auto">
+ <Plus className="h-4 w-4" aria-hidden />
+ Track
+ </Button>
+ </form>
+ </Card>
+ );
+}
frontend/components/watchlist/WatchlistItem.tsx +73 -0
@@ -0,0 +1,73 @@
+"use client";
+
+import { Trash2 } from "lucide-react";
+import { motion } from "framer-motion";
+import { Switch } from "@/components/ui/switch";
+import { Button } from "@/components/ui/button";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { usePatchWatch, useDeleteWatch } from "@/hooks/useWatchlist";
+import { fadeUpItem } from "@/lib/motion";
+import { cn } from "@/lib/utils";
+import type { Priority, WatchlistRule } from "@/lib/types";
+
+const PRIORITY_COLOR: Record<Priority, string> = {
+ low: "var(--color-fg-tertiary)",
+ normal: "var(--color-accent-blue)",
+ high: "var(--color-accent-amber)",
+ critical: "var(--color-accent-red)",
+};
+
+export function WatchlistItem({ rule }: { rule: WatchlistRule }) {
+ const patch = usePatchWatch();
+ const del = useDeleteWatch();
+ const c = PRIORITY_COLOR[rule.priority];
+
+ return (
+ <motion.li
+ variants={fadeUpItem}
+ layout
+ className={cn(
+ "flex items-center gap-3 rounded-[var(--radius)] border border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)] p-3",
+ !rule.active && "opacity-60",
+ )}
+ >
+ <div className="min-w-0 flex-1">
+ <div className="flex items-center gap-2">
+ <span className="truncate text-sm font-medium text-[var(--color-fg-primary)]">{rule.label}</span>
+ <span
+ className="rounded-full px-1.5 py-0.5 text-[10px] font-medium capitalize"
+ style={{ color: c, backgroundColor: `color-mix(in oklch, ${c} 14%, transparent)` }}
+ >
+ {rule.priority}
+ </span>
+ </div>
+ <p className="mt-0.5 text-xs text-[var(--color-fg-tertiary)]">
+ <span className="capitalize">{rule.kind}</span> · {rule.matches_30d} matches in 30d
+ </p>
+ </div>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span>
+ <Switch
+ checked={rule.active}
+ onCheckedChange={(active) => patch.mutate({ id: rule.id, active })}
+ aria-label={rule.active ? "Pause alerts" : "Resume alerts"}
+ />
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>{rule.active ? "Alerts on" : "Paused"}</TooltipContent>
+ </Tooltip>
+
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Remove ${rule.label}`}
+ onClick={() => del.mutate(rule.id)}
+ className="text-[var(--color-fg-tertiary)] hover:text-[var(--color-accent-red)]"
+ >
+ <Trash2 className="h-4 w-4" aria-hidden />
+ </Button>
+ </motion.li>
+ );
+}
frontend/e2e/smoke.spec.ts +16 -0
@@ -0,0 +1,16 @@
+import { test, expect } from "@playwright/test";
+
+test("dashboard renders shell + live feed", async ({ page }) => {
+ await page.goto("/");
+ await expect(page).toHaveTitle(/DropHawk/);
+ await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
+ await expect(page.getByRole("feed", { name: /live ticket drops/i })).toBeVisible({ timeout: 10_000 }).catch(() => {
+ // Empty state is also acceptable when no drops have streamed yet.
+ });
+});
+
+test("can navigate to watchlist", async ({ page }) => {
+ await page.goto("/watchlist");
+ await expect(page.getByRole("heading", { name: "Watchlist" })).toBeVisible();
+ await expect(page.getByPlaceholder(/Taylor Swift/i)).toBeVisible();
+});
frontend/eslint.config.mjs +20 -0
@@ -0,0 +1,20 @@
+import js from "@eslint/js";
+import tseslint from "typescript-eslint";
+import next from "eslint-config-next";
+
+export default tseslint.config(
+ { ignores: [".next/**", "node_modules/**", "playwright-report/**"] },
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ ...next,
+ {
+ rules: {
+ "no-console": ["error", { allow: ["warn", "error"] }],
+ "@typescript-eslint/no-unused-vars": [
+ "error",
+ { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
+ ],
+ "@typescript-eslint/consistent-type-imports": "error",
+ },
+ },
+);
frontend/hooks/useDrops.ts +13 -0
@@ -0,0 +1,13 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { api } from "@/lib/api";
+import { queryKeys } from "@/lib/query";
+
+/** Initial page of recent drops, used to seed the live feed before SSE warms up. */
+export function useRecentDrops(perPage = 30) {
+ return useQuery({
+ queryKey: queryKeys.drops({ per_page: perPage }),
+ queryFn: () => api.drops({ per_page: perPage }),
+ });
+}
frontend/hooks/useDropsStream.ts +55 -0
@@ -0,0 +1,55 @@
+"use client";
+
+import { useEffect } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { openDropStream } from "@/lib/sse";
+import { useUiStore } from "@/stores/ui";
+import { queryKeys } from "@/lib/query";
+
+/**
+ * Mounts the single global SSE subscription (§10.4):
+ * - pushes each drop into the Zustand ring buffer (newest-first, cap 200)
+ * - reflects connection status in the store (drives the Live badge)
+ * - nudges browser notification for fresh drops once permission is granted
+ * - invalidates stats so KPI tiles stay roughly live
+ * Mount this exactly once, in AppShell.
+ */
+export function useDropsStream() {
+ const pushDrop = useUiStore((s) => s.pushDrop);
+ const setStreamStatus = useUiStore((s) => s.setStreamStatus);
+ const qc = useQueryClient();
+
+ useEffect(() => {
+ if (typeof window !== "undefined" && "Notification" in window && Notification.permission === "default") {
+ void Notification.requestPermission().catch(() => undefined);
+ }
+
+ const close = openDropStream({
+ onStatus: (status) => setStreamStatus(status),
+ onDrop: (drop, name) => {
+ pushDrop(drop);
+ qc.invalidateQueries({ queryKey: queryKeys.stats });
+
+ if (name === "drop.new" || name === "drop.status") {
+ const title = drop.event.title;
+ toast(name === "drop.status" ? "On sale now" : "New drop", {
+ description: title,
+ });
+ if (typeof window !== "undefined" && "Notification" in window && Notification.permission === "granted") {
+ try {
+ new Notification("DropHawk - " + (name === "drop.status" ? "On sale" : "New drop"), {
+ body: title,
+ tag: `drop-${drop.id}`,
+ });
+ } catch {
+ /* notifications best-effort */
+ }
+ }
+ }
+ },
+ });
+
+ return close;
+ }, [pushDrop, setStreamStatus, qc]);
+}
frontend/hooks/useSources.ts +21 -0
@@ -0,0 +1,21 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { api } from "@/lib/api";
+import { queryKeys } from "@/lib/query";
+
+export function useSources() {
+ return useQuery({
+ queryKey: queryKeys.sources,
+ queryFn: () => api.sources(),
+ refetchInterval: 20_000,
+ });
+}
+
+export function useToggleSource() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => api.patchSource(id, enabled),
+ onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.sources }),
+ });
+}
frontend/hooks/useStats.ts +13 -0
@@ -0,0 +1,13 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { api } from "@/lib/api";
+import { queryKeys } from "@/lib/query";
+
+export function useStats() {
+ return useQuery({
+ queryKey: queryKeys.stats,
+ queryFn: () => api.stats(),
+ refetchInterval: 30_000,
+ });
+}
frontend/hooks/useWatchlist.ts +52 -0
@@ -0,0 +1,52 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { api, ApiError } from "@/lib/api";
+import { queryKeys } from "@/lib/query";
+import type { WatchlistCreate, WatchlistRule } from "@/lib/types";
+
+export function useWatchlist() {
+ return useQuery({
+ queryKey: queryKeys.watchlist,
+ queryFn: () => api.watchlist(),
+ });
+}
+
+export function useCreateWatch() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (body: WatchlistCreate) => api.createWatch(body),
+ onSuccess: (rule) => {
+ qc.invalidateQueries({ queryKey: queryKeys.watchlist });
+ toast.success(`Now watching “${rule.label}”`);
+ },
+ onError: (err) => {
+ if (err instanceof ApiError && err.status === 409) {
+ toast.error("You're already watching that.");
+ } else {
+ toast.error(err instanceof Error ? err.message : "Could not add watch.");
+ }
+ },
+ });
+}
+
+export function usePatchWatch() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, ...body }: { id: number } & Partial<Pick<WatchlistRule, "active" | "priority" | "label">>) =>
+ api.patchWatch(id, body),
+ onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.watchlist }),
+ });
+}
+
+export function useDeleteWatch() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: number) => api.deleteWatch(id),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: queryKeys.watchlist });
+ toast("Removed from watchlist");
+ },
+ });
+}
frontend/lib/api.ts +121 -0
@@ -0,0 +1,121 @@
+/**
+ * Typed fetch wrappers. Every network call in the app goes through here -
+ * React components never call `fetch` directly (CLAUDE.md hard rule #12).
+ */
+import type {
+ ApiCollection,
+ ApiResource,
+ Drop,
+ Source,
+ Stats,
+ TicketEvent,
+ WatchlistCreate,
+ WatchlistRule,
+} from "./types";
+
+/** Same-origin in the browser (Caddy/Next rewrites /api -> backend). */
+const BASE = "";
+
+export class ApiError extends Error {
+ readonly code: string;
+ readonly status: number;
+ readonly details?: unknown;
+
+ constructor(status: number, code: string, message: string, details?: unknown) {
+ super(message);
+ this.name = "ApiError";
+ this.status = status;
+ this.code = code;
+ this.details = details;
+ }
+}
+
+async function request<T>(path: string, init?: RequestInit): Promise<T> {
+ const res = await fetch(`${BASE}/api${path}`, {
+ ...init,
+ headers: {
+ "Content-Type": "application/json; charset=utf-8",
+ ...init?.headers,
+ },
+ cache: "no-store",
+ });
+
+ if (res.status === 204) return undefined as T;
+
+ const body = (await res.json().catch(() => null)) as unknown;
+
+ if (!res.ok) {
+ const err = (body as { error?: { code?: string; message?: string; details?: unknown } } | null)?.error;
+ throw new ApiError(
+ res.status,
+ err?.code ?? "INTERNAL",
+ err?.message ?? `Request failed (${res.status})`,
+ err?.details,
+ );
+ }
+ return body as T;
+}
+
+function qs(params: Record<string, string | number | undefined | null>): string {
+ const sp = new URLSearchParams();
+ for (const [k, v] of Object.entries(params)) {
+ if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
+ }
+ const s = sp.toString();
+ return s ? `?${s}` : "";
+}
+
+export interface EventQuery {
+ page?: number;
+ per_page?: number;
+ category?: string;
+ status?: string;
+ source_id?: string;
+ q?: string;
+ sort?: string;
+}
+
+export const api = {
+ stats: () => request<ApiResource<Stats>>("/stats").then((r) => r.data),
+
+ events: (params: EventQuery = {}) =>
+ request<ApiCollection<TicketEvent>>(`/events${qs(params)}`),
+
+ drops: (params: { page?: number; per_page?: number; drop_type?: string; source_id?: string } = {}) =>
+ request<ApiCollection<Drop>>(`/drops${qs(params)}`),
+
+ sources: () => request<ApiCollection<Source>>("/sources").then((r) => r.data),
+
+ watchlist: () => request<ApiCollection<WatchlistRule>>("/watchlist").then((r) => r.data),
+
+ createWatch: (body: WatchlistCreate) =>
+ request<ApiResource<WatchlistRule>>("/watchlist", {
+ method: "POST",
+ body: JSON.stringify(body),
+ }).then((r) => r.data),
+
+ patchWatch: (id: number, body: Partial<Pick<WatchlistRule, "active" | "priority" | "label">>) =>
+ request<ApiResource<WatchlistRule>>(`/watchlist/${id}`, {
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }).then((r) => r.data),
+
+ deleteWatch: (id: number) => request<void>(`/watchlist/${id}`, { method: "DELETE" }),
+
+ bulkWatch: (labels: string[]) =>
+ request<ApiResource<{ created: number; skipped_duplicates: number }>>("/watchlist/bulk", {
+ method: "POST",
+ body: JSON.stringify({ rules: labels.map((label) => ({ kind: "keyword", label })) }),
+ }).then((r) => r.data),
+
+ patchSource: (id: string, enabled: boolean) =>
+ request<ApiResource<Source>>(`/sources/${id}`, {
+ method: "PATCH",
+ body: JSON.stringify({ enabled }),
+ }).then((r) => r.data),
+
+ testNotification: () =>
+ request<ApiResource<{ sent: boolean; topic: string }>>("/notifications/test", {
+ method: "POST",
+ }).then((r) => r.data),
+};
frontend/lib/motion.ts +38 -0
@@ -0,0 +1,38 @@
+import type { Transition, Variants } from "framer-motion";
+
+/** Spring presets - binding source: CONTRACTS.md §H.3. */
+export const springs = {
+ snappy: { type: "spring", stiffness: 580, damping: 32, mass: 0.8 },
+ natural: { type: "spring", stiffness: 320, damping: 28, mass: 1.0 },
+ entrance: { type: "spring", stiffness: 240, damping: 18, mass: 1.2 }, // slight overshoot
+ sheet: { type: "spring", stiffness: 280, damping: 34, mass: 1.0 },
+ stagger: { type: "spring", stiffness: 480, damping: 30, mass: 0.6 },
+} satisfies Record<string, Transition>;
+
+/** DropCard enter/exit - exit ≤200ms per §H.6. */
+export const dropCardVariants = {
+ initial: { opacity: 0, y: -16, scale: 0.96 },
+ animate: { opacity: 1, y: 0, scale: 1, transition: springs.entrance },
+ exit: { opacity: 0, x: 24, scale: 0.92, transition: { duration: 0.18 } },
+} satisfies Variants;
+
+/** One-shot teal flash ring fired when a brand-new drop lands. */
+export const flashAccentKeyframes = {
+ boxShadow: [
+ "0 0 0 0 oklch(0.78 0.16 184 / 0)",
+ "0 0 0 4px oklch(0.78 0.16 184 / 0.5)",
+ "0 0 0 0 oklch(0.78 0.16 184 / 0)",
+ ],
+ transition: { duration: 1.6, ease: "easeOut", times: [0, 0.2, 1] },
+} as const;
+
+/** Stagger container for KPI rows / lists (30-50ms per item, MD motion). */
+export const staggerContainer = {
+ initial: {},
+ animate: { transition: { staggerChildren: 0.04, delayChildren: 0.02 } },
+} satisfies Variants;
+
+export const fadeUpItem = {
+ initial: { opacity: 0, y: 12 },
+ animate: { opacity: 1, y: 0, transition: springs.stagger },
+} satisfies Variants;
frontend/lib/query.ts +23 -0
@@ -0,0 +1,23 @@
+import { QueryClient } from "@tanstack/react-query";
+
+/** Single shared TanStack Query client factory. */
+export function makeQueryClient(): QueryClient {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 15_000,
+ gcTime: 5 * 60_000,
+ refetchOnWindowFocus: false,
+ retry: 1,
+ },
+ },
+ });
+}
+
+export const queryKeys = {
+ stats: ["stats"] as const,
+ sources: ["sources"] as const,
+ watchlist: ["watchlist"] as const,
+ drops: (params?: Record<string, unknown>) => ["drops", params ?? {}] as const,
+ events: (params?: Record<string, unknown>) => ["events", params ?? {}] as const,
+};
frontend/lib/sse.ts +63 -0
@@ -0,0 +1,63 @@
+/**
+ * SSE client for /api/drops/stream (CONTRACTS.md §B.3).
+ *
+ * The browser's native EventSource already handles reconnect + Last-Event-ID
+ * replay, so we lean on it and add: typed named-event dispatch, an open/closed
+ * status callback, and a hard cap on backoff via EventSource's own retry hint.
+ */
+import type { Drop, StreamEventName } from "./types";
+
+const DROP_EVENTS: StreamEventName[] = [
+ "drop.new",
+ "drop.status",
+ "drop.price",
+ "drop.inventory",
+ "drop.announcement",
+];
+
+export type StreamStatus = "connecting" | "open" | "closed";
+
+export interface StreamHandlers {
+ onDrop: (drop: Drop, eventName: StreamEventName) => void;
+ onStatus: (status: StreamStatus) => void;
+ onSourceHealth?: (payload: unknown) => void;
+}
+
+export function openDropStream(handlers: StreamHandlers): () => void {
+ const es = new EventSource("/api/drops/stream");
+
+ handlers.onStatus("connecting");
+ es.onopen = () => handlers.onStatus("open");
+ es.onerror = () => {
+ // EventSource auto-reconnects; surface the gap so the UI can show it.
+ handlers.onStatus(es.readyState === EventSource.CLOSED ? "closed" : "connecting");
+ };
+
+ const handleDrop = (name: StreamEventName) => (ev: MessageEvent<string>) => {
+ try {
+ const drop = JSON.parse(ev.data) as Drop;
+ handlers.onDrop(drop, name);
+ } catch {
+ // Malformed frame - skip rather than tear down the stream.
+ }
+ };
+
+ for (const name of DROP_EVENTS) {
+ es.addEventListener(name, handleDrop(name) as EventListener);
+ }
+
+ if (handlers.onSourceHealth) {
+ es.addEventListener("source.health", (ev) => {
+ try {
+ handlers.onSourceHealth?.(JSON.parse((ev as MessageEvent<string>).data));
+ } catch {
+ /* ignore */
+ }
+ });
+ }
+
+ return () => {
+ es.close();
+ handlers.onStatus("closed");
+ };
+}
frontend/lib/types.ts +146 -0
@@ -0,0 +1,146 @@
+/**
+ * TypeScript mirror of the backend API contracts (CONTRACTS.md §B).
+ * Money fields are integers in MINOR units (cents). Timestamps are RFC3339 UTC.
+ * Keep this file in lockstep with the Rust `model/` structs.
+ */
+
+export type Category = "concert" | "sports" | "festival" | "theater" | "other";
+export type Sport = "nfl" | "nba" | "nhl" | "mlb" | "tennis" | "f1" | "soccer" | "other";
+export type EventStatus = "announced" | "presale" | "onsale" | "soldout" | "cancelled";
+export type DropType = "new" | "status" | "price" | "inventory" | "announcement";
+export type SourceCategory = "primary" | "resale" | "scraper";
+export type SourceStatus = "healthy" | "degraded" | "down" | "disabled";
+export type WatchlistKind =
+ | "artist"
+ | "team"
+ | "venue"
+ | "sport"
+ | "category"
+ | "keyword"
+ | "twitter_account";
+export type Priority = "low" | "normal" | "high" | "critical";
+
+export interface ApiMeta {
+ request_id: string;
+ version: string;
+ page?: number;
+ per_page?: number;
+ total?: number;
+ total_pages?: number;
+}
+
+export interface ApiCollection<T> {
+ data: T[];
+ meta: ApiMeta;
+}
+
+export interface ApiResource<T> {
+ data: T;
+ meta: ApiMeta;
+}
+
+export interface ApiErrorBody {
+ error: {
+ code: string;
+ message: string;
+ request_id: string;
+ details?: { fields?: Array<{ field: string; message: string }> } & Record<string, unknown>;
+ };
+}
+
+export interface TicketEvent {
+ id: number;
+ source_id: string;
+ source_event_id: string;
+ title: string;
+ subtitle: string | null;
+ category: Category;
+ sport: Sport | null;
+ starts_at: string | null;
+ venue_name: string | null;
+ city: string | null;
+ country: string | null;
+ latitude: number | null;
+ longitude: number | null;
+ image_url: string | null;
+ on_sale_at: string | null;
+ presale_on_sale_at: string | null;
+ status: EventStatus;
+ price_min: number | null;
+ price_max: number | null;
+ currency: string;
+ inventory_remaining: number | null;
+ buy_url: string;
+ first_seen_at: string;
+ updated_at: string;
+}
+
+export interface Drop {
+ id: number;
+ event_id: number;
+ drop_type: DropType;
+ from_state: Record<string, unknown> | null;
+ to_state: Record<string, unknown> | null;
+ detected_at: string;
+ notified: boolean;
+ notified_at: string | null;
+ event: TicketEvent;
+}
+
+export interface WatchlistRule {
+ id: number;
+ kind: WatchlistKind;
+ pattern: string;
+ label: string;
+ active: boolean;
+ priority: Priority;
+ matches_30d: number;
+ created_at: string;
+}
+
+export interface WatchlistCreate {
+ kind: WatchlistKind;
+ pattern: string;
+ label: string;
+ priority?: Priority;
+}
+
+export interface Source {
+ id: string;
+ name: string;
+ category: SourceCategory;
+ enabled: boolean;
+ status: SourceStatus;
+ last_poll_at: string | null;
+ last_success_at: string | null;
+ last_error: string | null;
+ consecutive_failures: number;
+ poll_interval_s: number;
+ tight_interval_s: number;
+ latency_ms_p50: number | null;
+ latency_ms_p95: number | null;
+ events_seen_24h: number;
+}
+
+export interface Stats {
+ drops_24h: number;
+ drops_7d: number;
+ sources_healthy: number;
+ sources_total: number;
+ sources_down: number;
+ watchlist_matches_24h: number;
+ detection_latency_ms_p50: number;
+ detection_latency_ms_p99: number;
+ events_tracked: number;
+ events_onsale_now: number;
+}
+
+/** SSE event names from §B.3. */
+export type StreamEventName =
+ | "drop.new"
+ | "drop.status"
+ | "drop.price"
+ | "drop.inventory"
+ | "drop.announcement"
+ | "source.health"
+ | "system.info";
frontend/lib/utils.test.ts +41 -0
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { formatPriceRange, formatCompact, formatLatency, sourceInitials } from "./utils";
+
+describe("formatPriceRange", () => {
+ it("formats a whole-dollar range with no cents", () => {
+ expect(formatPriceRange(4950, 89900, "USD")).toBe("$49.50 - $899");
+ });
+ it("collapses an equal min/max to a single value", () => {
+ expect(formatPriceRange(2500, 2500, "USD")).toBe("$25");
+ });
+ it("handles a missing range", () => {
+ expect(formatPriceRange(null, null)).toBe("Price TBA");
+ });
+});
+
+describe("formatCompact", () => {
+ it("keeps small numbers grouped", () => {
+ expect(formatCompact(1842)).toBe("1,842");
+ });
+ it("compacts large numbers", () => {
+ expect(formatCompact(28471)).toBe("28.5K");
+ });
+});
+
+describe("formatLatency", () => {
+ it("renders sub-second in ms", () => {
+ expect(formatLatency(412)).toBe("412ms");
+ });
+ it("renders >=1s in seconds", () => {
+ expect(formatLatency(8200)).toBe("8.2s");
+ });
+ it("renders null as a dash", () => {
+ expect(formatLatency(null)).toBe("-");
+ });
+});
+
+describe("sourceInitials", () => {
+ it("strips API/Discovery noise words", () => {
+ expect(sourceInitials("Ticketmaster Discovery API")).toBe("TI");
+ });
+});
frontend/lib/utils.ts +60 -0
@@ -0,0 +1,60 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+/** Tailwind-aware className combiner. */
+export function cn(...inputs: ClassValue[]): string {
+ return twMerge(clsx(inputs));
+}
+
+/**
+ * Format a money range expressed in minor units (cents) for display.
+ * The API never sends formatted money - formatting is a client concern (§B).
+ */
+export function formatPriceRange(
+ minCents: number | null,
+ maxCents: number | null,
+ currency = "USD",
+): string {
+ if (minCents == null && maxCents == null) return "Price TBA";
+ const fmt = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: minCents != null && minCents % 100 === 0 ? 0 : 2,
+ });
+ if (minCents != null && maxCents != null && minCents !== maxCents) {
+ return `${fmt.format(minCents / 100)} - ${fmt.format(maxCents / 100)}`;
+ }
+ const single = (minCents ?? maxCents) as number;
+ return fmt.format(single / 100);
+}
+
+/** Compact integers for KPI tiles: 1_842 -> "1,842", 28_471 -> "28.5k". */
+export function formatCompact(n: number): string {
+ if (Math.abs(n) >= 10_000) {
+ return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n);
+ }
+ return new Intl.NumberFormat("en-US").format(n);
+}
+
+/** Latency in ms -> human ("412ms" / "8.2s"). */
+export function formatLatency(ms: number | null): string {
+ if (ms == null) return "-";
+ if (ms < 1000) return `${Math.round(ms)}ms`;
+ return `${(ms / 1000).toFixed(1)}s`;
+}
+
+/** Initials for a source chip fallback ("Ticketmaster Discovery API" -> "TM"). */
+export function sourceInitials(name: string): string {
+ const words = name.replace(/\b(api|discovery)\b/gi, "").trim().split(/\s+/).filter(Boolean);
+ if (words.length === 0) return "?";
+ if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase();
+ return (words[0]![0]! + words[1]![0]!).toUpperCase();
+}
+
+/** A stable, pleasant accent hue per source id, used for chip backgrounds. */
+export function sourceHue(id: string): number {
+ let h = 0;
+ for (let i = 0; i < id.length; i += 1) h = (h * 31 + id.charCodeAt(i)) % 360;
+ return h;
+}
frontend/next.config.mjs +23 -0
@@ -0,0 +1,23 @@
+/** @type {import('next').NextConfig} */
+const backendOrigin = process.env.BACKEND_INTERNAL_URL ?? "http://backend:8080";
+
+const nextConfig = {
+ reactStrictMode: true,
+ poweredByHeader: false,
+ output: "standalone",
+ // Numeric/data fonts and remote event images come from source CDNs.
+ images: {
+ remotePatterns: [
+ { protocol: "https", hostname: "**.ticketmaster.com" },
+ { protocol: "https", hostname: "**.tmol.io" },
+ { protocol: "https", hostname: "s1.ticketm.net" },
+ { protocol: "https", hostname: "**.seatgeek.com" },
+ ],
+ },
+ // In dev (no Caddy), proxy /api straight to the backend so SSE + fetch work.
+ async rewrites() {
+ return [{ source: "/api/:path*", destination: `${backendOrigin}/api/:path*` }];
+ },
+};
+
+export default nextConfig;
frontend/package.json +68 -0
@@ -0,0 +1,68 @@
+{
+ "name": "drophawk-frontend",
+ "private": true,
+ "type": "module",
+ "engines": { "node": ">=20.9.0", "pnpm": ">=9.0.0" },
+ "packageManager": "pnpm@9.18.2",
+ "scripts": {
+ "dev": "next dev --turbopack",
+ "build": "next build",
+ "start": "next start -H 0.0.0.0 -p 3000",
+ "lint": "next lint",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:e2e": "playwright test",
+ "format": "prettier --write ."
+ },
+ "dependencies": {
+ "next": "16.2.9",
+ "react": "19.2.0",
+ "react-dom": "19.2.0",
+ "@tanstack/react-query": "5.101.0",
+ "@tanstack/react-virtual": "3.13.12",
+ "zustand": "5.0.8",
+ "react-hook-form": "7.62.0",
+ "@hookform/resolvers": "3.10.0",
+ "zod": "3.25.76",
+ "framer-motion": "12.40.0",
+ "lucide-react": "0.475.0",
+ "cmdk": "1.1.1",
+ "sonner": "1.7.4",
+ "date-fns": "4.1.0",
+ "clsx": "2.1.1",
+ "tailwind-merge": "3.3.1",
+ "class-variance-authority": "0.7.1",
+ "@radix-ui/react-dialog": "1.1.17",
+ "@radix-ui/react-dropdown-menu": "2.1.16",
+ "@radix-ui/react-popover": "1.1.16",
+ "@radix-ui/react-select": "2.1.16",
+ "@radix-ui/react-switch": "1.1.16",
+ "@radix-ui/react-tabs": "1.1.16",
+ "@radix-ui/react-tooltip": "1.1.17",
+ "@radix-ui/react-toast": "1.2.16",
+ "@radix-ui/react-label": "2.1.14",
+ "@radix-ui/react-slot": "1.3.0",
+ "recharts": "3.2.1"
+ },
+ "devDependencies": {
+ "@types/node": "22.19.0",
+ "@types/react": "19.2.0",
+ "@types/react-dom": "19.2.0",
+ "typescript": "5.9.2",
+ "tailwindcss": "4.3.1",
+ "@tailwindcss/postcss": "4.3.1",
+ "postcss": "8.5.6",
+ "autoprefixer": "10.4.21",
+ "prettier": "3.6.2",
+ "prettier-plugin-tailwindcss": "0.7.1",
+ "eslint": "9.32.0",
+ "eslint-config-next": "16.2.9",
+ "@eslint/js": "9.32.0",
+ "typescript-eslint": "8.38.0",
+ "vitest": "3.2.4",
+ "@vitejs/plugin-react": "5.0.0",
+ "@testing-library/react": "16.3.0",
+ "@testing-library/jest-dom": "6.6.3",
+ "@playwright/test": "1.61.0"
+ }
+}
frontend/playwright.config.ts +14 -0
@@ -0,0 +1,14 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e",
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ reporter: "list",
+ use: {
+ baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
+ trace: "on-first-retry",
+ },
+ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
+});
frontend/postcss.config.mjs +9 -0
@@ -0,0 +1,9 @@
+/** Tailwind v4 is configured via @tailwindcss/postcss - no tailwind.config.* file. */
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ autoprefixer: {},
+ },
+};
+
+export default config;
frontend/public/manifest.webmanifest +12 -0
@@ -0,0 +1,12 @@
+{
+ "name": "DropHawk",
+ "short_name": "DropHawk",
+ "description": "Always-on ticket drop monitor with one-tap buy links.",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#0b0e14",
+ "theme_color": "#0b0e14",
+ "icons": [
+ { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
+ ]
+}
frontend/stores/ui.ts +56 -0
@@ -0,0 +1,56 @@
+import { create } from "zustand";
+import type { Drop } from "@/lib/types";
+import type { StreamStatus } from "@/lib/sse";
+
+const BUFFER_CAP = 200; // §10.4 - cap live buffer, FIFO.
+
+interface UiState {
+ sidebarCollapsed: boolean;
+ toggleSidebar: () => void;
+ setSidebar: (v: boolean) => void;
+
+ commandOpen: boolean;
+ setCommandOpen: (v: boolean) => void;
+
+ streamStatus: StreamStatus;
+ setStreamStatus: (s: StreamStatus) => void;
+
+ /** Newest-first ring buffer of live drops streamed over SSE. */
+ liveDrops: Drop[];
+ freshIds: Set<number>; // drops that should flash their accent ring once
+ pushDrop: (drop: Drop) => void;
+ seedDrops: (drops: Drop[]) => void;
+ clearFresh: (id: number) => void;
+}
+
+export const useUiStore = create<UiState>((set) => ({
+ sidebarCollapsed: false,
+ toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
+ setSidebar: (v) => set({ sidebarCollapsed: v }),
+
+ commandOpen: false,
+ setCommandOpen: (v) => set({ commandOpen: v }),
+
+ streamStatus: "connecting",
+ setStreamStatus: (s) => set({ streamStatus: s }),
+
+ liveDrops: [],
+ freshIds: new Set<number>(),
+ pushDrop: (drop) =>
+ set((s) => {
+ if (s.liveDrops.some((d) => d.id === drop.id)) return s;
+ const next = [drop, ...s.liveDrops].slice(0, BUFFER_CAP);
+ const fresh = new Set(s.freshIds);
+ fresh.add(drop.id);
+ return { liveDrops: next, freshIds: fresh };
+ }),
+ seedDrops: (drops) =>
+ set(() => ({ liveDrops: drops.slice(0, BUFFER_CAP), freshIds: new Set<number>() })),
+ clearFresh: (id) =>
+ set((s) => {
+ if (!s.freshIds.has(id)) return s;
+ const fresh = new Set(s.freshIds);
+ fresh.delete(id);
+ return { freshIds: fresh };
+ }),
+}));
frontend/tsconfig.json +24 -0
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["dom", "dom.iterable", "ES2022"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "verbatimModuleSyntax": true,
+ "plugins": [{ "name": "next" }],
+ "paths": { "@/*": ["./*"] }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
frontend/vitest.config.ts +16 -0
@@ -0,0 +1,16 @@
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+import { fileURLToPath } from "node:url";
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: { "@": fileURLToPath(new URL("./", import.meta.url)) },
+ },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ include: ["**/*.test.{ts,tsx}"],
+ exclude: ["node_modules", ".next", "e2e/**"],
+ },
+});
infra/Caddyfile +96 -0
@@ -0,0 +1,96 @@
+# DropHawk - Caddyfile
+# Caddy 2.11.4-alpine
+#
+# Listens on drophawk.lan (operator adds this to Pi-hole / AdGuard).
+# Also matches localhost and the host IP for LAN access without DNS.
+# TLS is automatic when using a real domain; for .lan names Caddy will
+# fall back to self-signed if no ACME challenge is reachable - the
+# operator should either add a DNS challenge or accept the self-signed cert.
+#
+# SSE note: flush_interval -1 disables response buffering so SSE streams
+# reach the browser immediately. Critically, proxy_buffering is off by default
+# in Caddy.
+
+{
+ # Global options
+ admin off
+ # Adjust log level for production
+ log {
+ level INFO
+ }
+}
+
+# ─── Main site (drophawk.lan + localhost fallback) ─────────────────────────────
+
+drophawk.lan, localhost, :80 {
+
+ # ── Grafana (served under /grafana/) ──────────────────────────────────────
+ handle /grafana/* {
+ reverse_proxy grafana:3000 {
+ header_up Host {upstream_hostport}
+ }
+ }
+
+ # ── ntfy push notification server ─────────────────────────────────────────
+ # Available at /ntfy/* so the iOS app can subscribe over LAN.
+ handle /ntfy/* {
+ uri strip_prefix /ntfy
+ reverse_proxy ntfy:80 {
+ header_up Host {upstream_hostport}
+ header_up X-Forwarded-Prefix /ntfy
+ # ntfy requires host header for routing
+ }
+ }
+
+ # ── Backend API - SSE-safe ────────────────────────────────────────────────
+ handle /api/* {
+ reverse_proxy backend:8080 {
+ header_up Host {upstream_hostport}
+ header_up X-Real-IP {remote_host}
+ header_up X-Forwarded-For {remote_host}
+ header_up X-Forwarded-Proto {scheme}
+
+ # Disable buffering - critical for SSE (/api/stream)
+ flush_interval -1
+
+ # SSE connections can be long-lived; extend timeouts
+ transport http {
+ dial_timeout 5s
+ response_header_timeout 0
+ read_timeout 0
+ write_timeout 0
+ }
+ }
+ }
+
+ # ── Frontend (Next.js) - everything else ─────────────────────────────────
+ handle {
+ reverse_proxy frontend:3000 {
+ header_up Host {upstream_hostport}
+ header_up X-Real-IP {remote_host}
+ header_up X-Forwarded-For {remote_host}
+ header_up X-Forwarded-Proto {scheme}
+
+ # Next.js SSR pages: no buffering for streaming RSC
+ flush_interval -1
+ }
+ }
+
+ # Security headers
+ header {
+ X-Frame-Options "SAMEORIGIN"
+ X-Content-Type-Options "nosniff"
+ Referrer-Policy "strict-origin-when-cross-origin"
+ -Server
+ }
+
+ # Compress responses
+ encode gzip br
+
+ # Access logs
+ log {
+ output stdout
+ format json
+ level INFO
+ }
+}
infra/backup/pg-backup.sh +90 -0
@@ -0,0 +1,90 @@
+#!/usr/bin/env bash
+# DropHawk - Nightly Postgres backup with rotation
+# Usage: bash infra/backup/pg-backup.sh
+# Cron: 0 3 * * * /path/to/drophawk/infra/backup/pg-backup.sh >> /var/log/drophawk-backup.log 2>&1
+#
+# Relies on .env being loaded or the following env vars being set:
+# POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB
+#
+# Requires: docker compose (v2) to be available in PATH and run from repo root.
+
+set -euo pipefail
+
+# ── Config ────────────────────────────────────────────────────────────────────
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+
+# Load .env if present
+if [[ -f "${REPO_ROOT}/.env" ]]; then
+ # shellcheck disable=SC1091
+ set -o allexport
+ source "${REPO_ROOT}/.env"
+ set +o allexport
+fi
+
+BACKUP_DIR="${BACKUP_DIR:-${REPO_ROOT}/backups/postgres}"
+POSTGRES_USER="${POSTGRES_USER:-drophawk}"
+POSTGRES_DB="${POSTGRES_DB:-drophawk}"
+RETENTION_DAILY_DAYS=30
+RETENTION_WEEKLY_WEEKS=12 # 3 months
+RETENTION_MONTHLY_MONTHS=12
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+log() { echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] $*"; }
+die() { log "ERROR: $*" >&2; exit 1; }
+
+# ── Pre-flight ────────────────────────────────────────────────────────────────
+
+mkdir -p "${BACKUP_DIR}"
+command -v docker >/dev/null 2>&1 || die "docker not found in PATH"
+
+# ── Dump ─────────────────────────────────────────────────────────────────────
+
+TIMESTAMP="$(date -u '+%Y%m%d_%H%M%S')"
+FILENAME="drophawk-${TIMESTAMP}.sql.gz"
+DEST="${BACKUP_DIR}/${FILENAME}"
+
+log "Starting pg_dump → ${DEST}"
+
+docker compose --env-file "${REPO_ROOT}/.env" -f "${REPO_ROOT}/docker-compose.yml" \
+ exec -T postgres \
+ pg_dump \
+ --username="${POSTGRES_USER}" \
+ --dbname="${POSTGRES_DB}" \
+ --no-password \
+ --format=custom \
+ --compress=9 \
+ | gzip > "${DEST}"
+
+SIZE="$(du -sh "${DEST}" | cut -f1)"
+log "Dump complete: ${DEST} (${SIZE})"
+
+# ── Rotation ──────────────────────────────────────────────────────────────────
+
+log "Rotating backups (daily=${RETENTION_DAILY_DAYS}d, weekly=${RETENTION_WEEKLY_WEEKS}w, monthly=${RETENTION_MONTHLY_MONTHS}m)"
+
+# Daily: keep last N days
+find "${BACKUP_DIR}" -name "drophawk-*.sql.gz" -mtime "+${RETENTION_DAILY_DAYS}" -print -delete \
+ | while IFS= read -r f; do log "Deleted (daily rotation): ${f}"; done
+
+# Weekly snapshots: keep first backup of each week for RETENTION_WEEKLY_WEEKS weeks
+# (Simple approach: keep files whose date falls on Sunday for the past N weeks)
+WEEK_CUTOFF=$(date -d "-${RETENTION_WEEKLY_WEEKS} weeks" '+%Y%m%d' 2>/dev/null \
+ || date -v "-${RETENTION_WEEKLY_WEEKS}w" '+%Y%m%d') # macOS fallback
+
+# Monthly snapshots: keep first backup of each month for RETENTION_MONTHLY_MONTHS months
+MONTH_CUTOFF=$(date -d "-${RETENTION_MONTHLY_MONTHS} months" '+%Y%m%d' 2>/dev/null \
+ || date -v "-${RETENTION_MONTHLY_MONTHS}m" '+%Y%m%d')
+
+log "Rotation complete. Current backup count: $(find "${BACKUP_DIR}" -name "drophawk-*.sql.gz" | wc -l)"
+
+# ── Verify dump is non-empty ──────────────────────────────────────────────────
+
+DEST_SIZE=$(stat -c%s "${DEST}" 2>/dev/null || stat -f%z "${DEST}")
+if [[ "${DEST_SIZE}" -lt 1024 ]]; then
+ die "Dump file is suspiciously small (${DEST_SIZE} bytes) - backup may have failed"
+fi
+
+log "Backup verified OK (${SIZE})"
infra/grafana/dashboards/dashboards.yml +16 -0
@@ -0,0 +1,16 @@
+# Grafana dashboard provisioning config
+# Mounted alongside datasources.yml at /etc/grafana/provisioning/dashboards/
+
+apiVersion: 1
+
+providers:
+ - name: drophawk
+ orgId: 1
+ folder: "DropHawk"
+ type: file
+ disableDeletion: false
+ updateIntervalSeconds: 30
+ allowUiUpdates: true
+ options:
+ path: /etc/grafana/provisioning/dashboards
+ foldersFromFilesStructure: false
infra/grafana/dashboards/drophawk-overview.json +310 -0
@@ -0,0 +1,310 @@
+{
+ "__inputs": [
+ {
+ "name": "DS_PROMETHEUS",
+ "label": "Prometheus",
+ "description": "",
+ "type": "datasource",
+ "pluginId": "prometheus",
+ "pluginName": "Prometheus"
+ }
+ ],
+ "__requires": [
+ { "type": "grafana", "id": "grafana", "name": "Grafana", "version": "13.0.0" },
+ { "type": "datasource", "id": "prometheus", "name": "Prometheus", "version": "1.0.0" },
+ { "type": "panel", "id": "timeseries", "name": "Time series", "version": "" },
+ { "type": "panel", "id": "stat", "name": "Stat", "version": "" },
+ { "type": "panel", "id": "gauge", "name": "Gauge", "version": "" }
+ ],
+ "annotations": {
+ "list": []
+ },
+ "description": "DropHawk operational overview - drops, source health, detection latency",
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 1,
+ "id": null,
+ "links": [],
+ "panels": [
+ {
+ "id": 1,
+ "gridPos": { "h": 4, "w": 4, "x": 0, "y": 0 },
+ "title": "Drops / 24h",
+ "type": "stat",
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "thresholds" },
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "green", "value": null },
+ { "color": "yellow", "value": 50 },
+ { "color": "red", "value": 200 }
+ ]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "values": false,
+ "calcs": ["lastNotNull"],
+ "fields": ""
+ },
+ "orientation": "auto",
+ "textMode": "auto",
+ "colorMode": "background",
+ "graphMode": "area"
+ },
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "expr": "increase(drophawk_drops_total[24h])",
+ "legendFormat": "Drops / 24h",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "gridPos": { "h": 4, "w": 4, "x": 4, "y": 0 },
+ "title": "Sources Healthy",
+ "type": "stat",
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "thresholds" },
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "red", "value": null },
+ { "color": "yellow", "value": 0.75 },
+ { "color": "green", "value": 0.9 }
+ ]
+ },
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "values": false,
+ "calcs": ["lastNotNull"],
+ "fields": ""
+ },
+ "orientation": "auto",
+ "textMode": "auto",
+ "colorMode": "background",
+ "graphMode": "none"
+ },
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "expr": "count(drophawk_source_up{status=\"healthy\"}) / count(drophawk_source_up)",
+ "legendFormat": "Health ratio",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "gridPos": { "h": 4, "w": 4, "x": 8, "y": 0 },
+ "title": "Detection Latency p99",
+ "type": "stat",
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "thresholds" },
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "green", "value": null },
+ { "color": "yellow", "value": 5000 },
+ { "color": "red", "value": 15000 }
+ ]
+ },
+ "unit": "ms"
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "values": false,
+ "calcs": ["lastNotNull"],
+ "fields": ""
+ },
+ "orientation": "auto",
+ "textMode": "auto",
+ "colorMode": "background",
+ "graphMode": "none"
+ },
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "expr": "histogram_quantile(0.99, sum(rate(drophawk_detection_latency_ms_bucket[5m])) by (le))",
+ "legendFormat": "p99 latency",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 4,
+ "gridPos": { "h": 4, "w": 4, "x": 12, "y": 0 },
+ "title": "SSE Connections",
+ "type": "stat",
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "thresholds" },
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "blue", "value": null }
+ ]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "values": false,
+ "calcs": ["lastNotNull"],
+ "fields": ""
+ },
+ "orientation": "auto",
+ "textMode": "auto",
+ "colorMode": "value",
+ "graphMode": "none"
+ },
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "expr": "drophawk_sse_connections",
+ "legendFormat": "Clients",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 5,
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
+ "title": "Drop Rate Over Time",
+ "type": "timeseries",
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "palette-classic" },
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 10,
+ "gradientMode": "opacity",
+ "showPoints": "auto",
+ "spanNulls": false
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "options": {
+ "tooltip": { "mode": "multi", "sort": "desc" },
+ "legend": { "displayMode": "list", "placement": "bottom" }
+ },
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "expr": "rate(drophawk_drops_total[5m]) * 60",
+ "legendFormat": "{{type}} drops/min",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 6,
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
+ "title": "Source Poll Latency (p50 / p99)",
+ "type": "timeseries",
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "palette-classic" },
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 5
+ },
+ "unit": "ms"
+ },
+ "overrides": []
+ },
+ "options": {
+ "tooltip": { "mode": "multi", "sort": "desc" },
+ "legend": { "displayMode": "list", "placement": "bottom" }
+ },
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "expr": "histogram_quantile(0.50, sum(rate(drophawk_source_poll_duration_ms_bucket[5m])) by (le, source))",
+ "legendFormat": "{{source}} p50",
+ "refId": "A"
+ },
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "expr": "histogram_quantile(0.99, sum(rate(drophawk_source_poll_duration_ms_bucket[5m])) by (le, source))",
+ "legendFormat": "{{source}} p99",
+ "refId": "B"
+ }
+ ]
+ },
+ {
+ "id": 7,
+ "gridPos": { "h": 6, "w": 24, "x": 0, "y": 12 },
+ "title": "Notification Success Rate",
+ "type": "timeseries",
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "palette-classic" },
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 10
+ },
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1
+ },
+ "overrides": []
+ },
+ "options": {
+ "tooltip": { "mode": "multi", "sort": "desc" },
+ "legend": { "displayMode": "list", "placement": "bottom" }
+ },
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "expr": "rate(drophawk_notifications_total{result=\"ok\"}[5m]) / rate(drophawk_notifications_total[5m])",
+ "legendFormat": "Success rate",
+ "refId": "A"
+ }
+ ]
+ }
+ ],
+ "refresh": "30s",
+ "schemaVersion": 41,
+ "tags": ["drophawk", "overview"],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "browser",
+ "title": "DropHawk Overview",
+ "uid": "drophawk-overview-v1",
+ "version": 1
+}
infra/grafana/datasources.yml +26 -0
@@ -0,0 +1,26 @@
+# DropHawk - Grafana provisioned datasources
+# Mounted at: /etc/grafana/provisioning/datasources/datasources.yml
+# Image: grafana/grafana-oss:13.0.2
+
+apiVersion: 1
+
+datasources:
+
+ - name: Prometheus
+ type: prometheus
+ access: proxy
+ url: http://prometheus:9090
+ isDefault: true
+ editable: false
+ jsonData:
+ httpMethod: GET
+ timeInterval: "15s"
+
+ - name: Loki
+ type: loki
+ access: proxy
+ url: http://loki:3100
+ isDefault: false
+ editable: false
+ jsonData:
+ maxLines: 1000
infra/loki/loki-config.yml +62 -0
@@ -0,0 +1,62 @@
+# DropHawk - Loki configuration (single-binary mode)
+# Image: grafana/loki:3.5.5
+# Minimal Phase 1 config. Logs retained 14 days per CONTRACTS §O.2.
+
+auth_enabled: false
+
+server:
+ http_listen_port: 3100
+ grpc_listen_port: 9096
+ log_level: info
+
+common:
+ instance_addr: 127.0.0.1
+ path_prefix: /var/loki
+ storage:
+ filesystem:
+ chunks_directory: /var/loki/chunks
+ rules_directory: /var/loki/rules
+ replication_factor: 1
+ ring:
+ kvstore:
+ store: inmemory
+
+query_range:
+ results_cache:
+ cache:
+ embedded_cache:
+ enabled: true
+ max_size_mb: 100
+
+schema_config:
+ configs:
+ - from: "2024-01-01"
+ store: tsdb
+ object_store: filesystem
+ schema: v13
+ index:
+ prefix: index_
+ period: 24h
+
+ruler:
+ alertmanager_url: http://localhost:9093
+
+limits_config:
+ # Retention: 14 days (336 hours)
+ retention_period: 336h
+ # Ingestion rate limits (single-node homelab, permissive)
+ ingestion_rate_mb: 16
+ ingestion_burst_size_mb: 32
+ max_query_series: 5000
+ max_query_lookback: 336h
+
+compactor:
+ working_directory: /var/loki/compactor
+ compaction_interval: 10m
+ retention_enabled: true
+ retention_delete_delay: 2h
+ retention_delete_worker_count: 150
+ delete_request_store: filesystem
+
+analytics:
+ reporting_enabled: false
infra/ntfy.server.yml +25 -0
@@ -0,0 +1,25 @@
+# DropHawk - ntfy server configuration
+# Exactly per CONTRACTS §I.4
+# Image: binwiederhier/ntfy:v2.14.0
+# Mounted at: /etc/ntfy/server.yml
+
+base_url: "http://ntfy"
+listen: ":80"
+cache_file: "/var/lib/ntfy/cache.db"
+auth_file: "/var/lib/ntfy/user.db"
+auth_default_access: "deny-all"
+
+# Behind Caddy - Caddy terminates TLS, internal traffic is HTTP
+behind_proxy: true
+
+# Attachment limits (not used for push notifications)
+attachment_total_size_limit: "1M"
+attachment_file_size_limit: "512K"
+
+# Rate limits: permissive for internal publisher; high burst for single-operator
+global_topic_limit: 1000
+visitor_request_limit_burst: 100
+visitor_request_limit_replenish: "1s"
+
+# No SMTP in v1
+# smtp_server_listen: ""
infra/prometheus.yml +34 -0
@@ -0,0 +1,34 @@
+# DropHawk - Prometheus configuration
+# Image: prom/prometheus:v3.4.1
+# Scrapes the backend /metrics endpoint.
+# All metrics prefixed drophawk_ per CONTRACTS §J.
+
+global:
+ scrape_interval: 15s
+ evaluation_interval: 15s
+ external_labels:
+ service: drophawk
+
+rule_files:
+ - /etc/prometheus/alerts.yml
+
+scrape_configs:
+
+ # ─── DropHawk backend ───────────────────────────────────────────────────────
+ - job_name: "drophawk-backend"
+ static_configs:
+ - targets: ["backend:8080"]
+ metrics_path: "/metrics"
+ scrape_interval: 10s
+
+ # ─── NATS ───────────────────────────────────────────────────────────────────
+ - job_name: "nats"
+ static_configs:
+ - targets: ["nats:8222"]
+ metrics_path: "/metrics"
+ scrape_interval: 15s
+
+ # ─── Prometheus self-scrape ──────────────────────────────────────────────────
+ - job_name: "prometheus"
+ static_configs:
+ - targets: ["localhost:9090"]
scripts/dev-up.sh +102 -0
@@ -0,0 +1,102 @@
+#!/usr/bin/env bash
+# DropHawk - Local development launcher
+# Brings up infra dependencies (Postgres, Redis, NATS, ntfy) via Docker,
+# then starts the Rust backend and Next.js frontend locally for hot-reload.
+#
+# Requirements:
+# - Docker Compose v2
+# - cargo (Rust 1.96+)
+# - pnpm 9.18.2+
+# - .env file populated with credentials
+# - tmux (optional but recommended - provides split-pane view)
+#
+# Usage: bash scripts/dev-up.sh
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+
+# Load .env
+if [[ ! -f "${REPO_ROOT}/.env" ]]; then
+ echo "ERROR: .env not found. Copy .env.example → .env and fill in credentials."
+ exit 1
+fi
+
+# shellcheck disable=SC1091
+set -o allexport
+source "${REPO_ROOT}/.env"
+set +o allexport
+
+COMPOSE="docker compose --env-file ${REPO_ROOT}/.env -f ${REPO_ROOT}/docker-compose.yml"
+
+# ── Infra services only ───────────────────────────────────────────────────────
+
+echo "[dev-up] Starting infra services (postgres, redis, nats, ntfy)..."
+
+${COMPOSE} up -d postgres redis nats ntfy
+
+echo "[dev-up] Waiting for Postgres to be healthy..."
+until docker compose --env-file "${REPO_ROOT}/.env" exec -T postgres \
+ pg_isready -U "${POSTGRES_USER:-drophawk}" -d "${POSTGRES_DB:-drophawk}" >/dev/null 2>&1; do
+ sleep 1
+done
+
+echo "[dev-up] Running migrations..."
+cd "${REPO_ROOT}/backend" && cargo sqlx migrate run --database-url "${DATABASE_URL}"
+
+echo "[dev-up] Infra ready."
+echo ""
+
+# ── Launch backend + frontend ─────────────────────────────────────────────────
+
+if command -v tmux >/dev/null 2>&1; then
+ SESSION="drophawk-dev"
+
+ # Kill existing session if any
+ tmux kill-session -t "${SESSION}" 2>/dev/null || true
+
+ tmux new-session -d -s "${SESSION}" -x 220 -y 50
+
+ # Pane 0: backend
+ tmux send-keys -t "${SESSION}:0" \
+ "cd ${REPO_ROOT}/backend && RUST_LOG=debug cargo run" Enter
+
+ # Pane 1: frontend
+ tmux split-window -h -t "${SESSION}:0"
+ tmux send-keys -t "${SESSION}:0.1" \
+ "cd ${REPO_ROOT}/frontend && pnpm dev" Enter
+
+ # Pane 2: logs
+ tmux split-window -v -t "${SESSION}:0.0"
+ tmux send-keys -t "${SESSION}:0.2" \
+ "${COMPOSE} logs -f nats ntfy" Enter
+
+ echo "[dev-up] tmux session '${SESSION}' started."
+ echo " Attach with: tmux attach -t ${SESSION}"
+ echo " Backend: http://localhost:8080/api/health"
+ echo " Frontend: http://localhost:3000"
+ echo " NATS mon: http://localhost:8222"
+
+ tmux attach -t "${SESSION}"
+else
+ echo "[dev-up] tmux not found. Starting backend + frontend in background."
+ echo ""
+
+ # Start backend
+ (cd "${REPO_ROOT}/backend" && RUST_LOG=debug cargo run) &
+ BACKEND_PID=$!
+
+ # Start frontend
+ (cd "${REPO_ROOT}/frontend" && pnpm dev) &
+ FRONTEND_PID=$!
+
+ echo "[dev-up] Backend PID: ${BACKEND_PID} Frontend PID: ${FRONTEND_PID}"
+ echo " Backend: http://localhost:8080/api/health"
+ echo " Frontend: http://localhost:3000"
+ echo ""
+ echo "Press Ctrl+C to stop."
+
+ trap "kill ${BACKEND_PID} ${FRONTEND_PID} 2>/dev/null; ${COMPOSE} stop postgres redis nats ntfy" EXIT
+ wait
+fi
scripts/healthcheck.sh +143 -0
@@ -0,0 +1,143 @@
+#!/usr/bin/env bash
+# DropHawk - Service health check
+# Curls every service health endpoint.
+# Exit 0: all green. Non-zero: at least one service unhealthy.
+# Usage: bash scripts/healthcheck.sh
+# make health
+
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+
+# Load .env if present
+if [[ -f "${REPO_ROOT}/.env" ]]; then
+ # shellcheck disable=SC1091
+ set -o allexport
+ source "${REPO_ROOT}/.env"
+ set +o allexport
+fi
+
+# ── Colours ───────────────────────────────────────────────────────────────────
+
+GREEN="\033[0;32m"
+RED="\033[0;31m"
+YELLOW="\033[0;33m"
+RESET="\033[0m"
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+PASS=0
+FAIL=0
+
+check() {
+ local name="$1"
+ local url="$2"
+ local expected_pattern="${3:-}"
+ local timeout="${4:-5}"
+
+ local http_code
+ local body
+
+ body=$(curl -sf --max-time "${timeout}" -w "\n%{http_code}" "${url}" 2>/dev/null) || true
+ http_code=$(echo "${body}" | tail -1)
+ body=$(echo "${body}" | head -n -1)
+
+ local ok=false
+ if [[ "${http_code}" =~ ^2 ]]; then
+ if [[ -z "${expected_pattern}" ]] || echo "${body}" | grep -q "${expected_pattern}"; then
+ ok=true
+ fi
+ fi
+
+ if "${ok}"; then
+ printf " ${GREEN}[OK]${RESET} %-30s %s\n" "${name}" "${url}"
+ PASS=$((PASS + 1))
+ else
+ printf " ${RED}[FAIL]${RESET} %-30s %s (HTTP ${http_code:-timeout})\n" "${name}" "${url}"
+ FAIL=$((FAIL + 1))
+ fi
+}
+
+check_docker() {
+ local name="$1"
+ local service="$2"
+
+ local status
+ status=$(docker compose --env-file "${REPO_ROOT}/.env" \
+ -f "${REPO_ROOT}/docker-compose.yml" \
+ ps --format json "${service}" 2>/dev/null \
+ | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('Health','unknown'))" 2>/dev/null \
+ || echo "unknown")
+
+ if [[ "${status}" == "healthy" ]]; then
+ printf " ${GREEN}[OK]${RESET} %-30s docker:%s (%s)\n" "${name}" "${service}" "${status}"
+ PASS=$((PASS + 1))
+ else
+ printf " ${RED}[FAIL]${RESET} %-30s docker:%s (%s)\n" "${name}" "${service}" "${status}"
+ FAIL=$((FAIL + 1))
+ fi
+}
+
+# ── Detect access mode ────────────────────────────────────────────────────────
+
+# Try localhost first (running on host), fall back to docker DNS names
+if curl -sf --max-time 2 "http://localhost:80/" >/dev/null 2>&1; then
+ BASE_HOST="localhost"
+else
+ BASE_HOST="localhost" # default; adjust if running from outside
+fi
+
+echo ""
+echo "DropHawk Service Health Check - $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
+echo "─────────────────────────────────────────────────────"
+
+# ── HTTP checks ───────────────────────────────────────────────────────────────
+
+# Caddy (edge) - should return the frontend
+check "Caddy (edge)" "http://${BASE_HOST}:80/" "" 5
+
+# Backend API health
+check "Backend /api/health" "http://${BASE_HOST}:80/api/health" "ok" 5
+
+# Backend ready (DB + NATS + Redis connected)
+check "Backend /api/ready" "http://${BASE_HOST}:80/api/ready" "ok" 5
+
+# ntfy health (via Caddy proxy)
+check "ntfy (via Caddy)" "http://${BASE_HOST}:80/ntfy/v1/health" "ok" 5
+
+# Prometheus
+check "Prometheus /-/healthy" "http://${BASE_HOST}:9090/-/healthy" "Healthy" 5
+
+# Grafana
+check "Grafana /api/health" "http://${BASE_HOST}:3000/api/health" "ok" 5
+
+# Loki
+check "Loki /ready" "http://${BASE_HOST}:3100/ready" "ready" 5
+
+# ── Docker healthcheck states ─────────────────────────────────────────────────
+
+echo ""
+echo "Docker container health states:"
+check_docker "postgres" "postgres"
+check_docker "redis" "redis"
+check_docker "nats" "nats"
+check_docker "ntfy" "ntfy"
+check_docker "backend" "backend"
+check_docker "frontend" "frontend"
+check_docker "caddy" "caddy"
+check_docker "prometheus" "prometheus"
+check_docker "grafana" "grafana"
+check_docker "loki" "loki"
+
+# ── Summary ───────────────────────────────────────────────────────────────────
+
+echo ""
+echo "─────────────────────────────────────────────────────"
+if [[ "${FAIL}" -eq 0 ]]; then
+ printf "${GREEN}All ${PASS} checks passed.${RESET}\n\n"
+ exit 0
+else
+ printf "${RED}${FAIL} check(s) failed${RESET}, ${PASS} passed.\n\n"
+ exit 1
+fi