| @@ -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 |
| @@ -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. |
| @@ -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 |
| @@ -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"] |
| @@ -0,0 +1,3 @@ | ||
| + | CREATE EXTENSION IF NOT EXISTS "pg_trgm"; | |
| + | CREATE EXTENSION IF NOT EXISTS timescaledb; | |
| + | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; |
| @@ -0,0 +1,6 @@ | ||
| + | CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$ | |
| + | BEGIN | |
| + | NEW.updated_at := NOW(); | |
| + | RETURN NEW; | |
| + | END; | |
| + | $$ LANGUAGE plpgsql; |
| @@ -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(); |
| @@ -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(); |
| @@ -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'); |
| @@ -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(); |
| @@ -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'); |
| @@ -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'); |
| @@ -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); |
| @@ -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); |
| @@ -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; |
| @@ -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'); |
| @@ -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(¶ms)?; | |
| + | 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"))), | |
| + | } | |
| + | } |
| @@ -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(¶ms)?; | |
| + | 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() | |
| + | } |
| @@ -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 } }))) | |
| + | } |
| @@ -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))) | |
| + | } |
| @@ -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(); | |
| + | } | |
| + | } |
| @@ -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)) | |
| + | } |
| @@ -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(()) | |
| + | } |
| @@ -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() | |
| + | } | |
| + | } |
| @@ -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}") | |
| + | } |
| @@ -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(()) | |
| + | } | |
| + | } |
| @@ -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() | |
| + | } | |
| + | } |
| @@ -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 | |
| + | } | |
| + | } |
| @@ -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()) | |
| + | } |
| @@ -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(()) | |
| + | } |
| @@ -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>; |
| @@ -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"); | |
| + | } |
| @@ -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 | |
| + | } |
| @@ -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() | |
| + | } | |
| + | } |
| @@ -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, | |
| + | } | |
| + | } | |
| + | } |
| @@ -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>, | |
| + | } |
| @@ -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, | |
| + | } | |
| + | } | |
| + | } |
| @@ -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>, | |
| + | } |
| @@ -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() | |
| + | } |
| @@ -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?) | |
| + | } |
| @@ -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"); | |
| + | } | |
| + | } |
| @@ -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}"), | |
| + | }) | |
| + | } | |
| + | } | |
| + | } |
| @@ -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, | |
| + | } | |
| + | } |
| @@ -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() | |
| + | } |
| @@ -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>; |
| @@ -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")); | |
| + | } | |
| + | } |
| @@ -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, | |
| + | })) | |
| + | } | |
| + | } |
| @@ -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) | |
| + | } | |
| + | } |
| @@ -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 | |
| + | } |
| @@ -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, | |
| + | } |
| @@ -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) | |
| + | } | |
| + | } |
| @@ -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) | |
| + | } | |
| + | } |
| @@ -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"); | |
| + | } |
| @@ -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 |
| @@ -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 |
| @@ -0,0 +1,9 @@ | ||
| + | node_modules | |
| + | .next | |
| + | out | |
| + | .git | |
| + | .env*.local | |
| + | playwright-report | |
| + | test-results | |
| + | coverage | |
| + | *.log |
| @@ -0,0 +1,11 @@ | ||
| + | node_modules/ | |
| + | .next/ | |
| + | out/ | |
| + | build/ | |
| + | .env*.local | |
| + | .DS_Store | |
| + | *.tsbuildinfo | |
| + | next-env.d.ts.bak | |
| + | playwright-report/ | |
| + | test-results/ | |
| + | coverage/ |
| @@ -0,0 +1,7 @@ | ||
| + | { | |
| + | "semi": true, | |
| + | "singleQuote": false, | |
| + | "printWidth": 110, | |
| + | "trailingComma": "all", | |
| + | "plugins": ["prettier-plugin-tailwindcss"] | |
| + | } |
| @@ -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"] |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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); } | |
| + | } | |
| + | } |
| @@ -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> |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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 }, | |
| + | ]; |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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>; | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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 }; |
| @@ -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} />; | |
| + | } |
| @@ -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} | |
| + | /> | |
| + | ); | |
| + | }, | |
| + | ); |
| @@ -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> | |
| + | ); | |
| + | }); |
| @@ -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} />; | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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(); | |
| + | }); |
| @@ -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", | |
| + | }, | |
| + | }, | |
| + | ); |
| @@ -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 }), | |
| + | }); | |
| + | } |
| @@ -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]); | |
| + | } |
| @@ -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 }), | |
| + | }); | |
| + | } |
| @@ -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, | |
| + | }); | |
| + | } |
| @@ -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"); | |
| + | }, | |
| + | }); | |
| + | } |
| @@ -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), | |
| + | }; |
| @@ -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; |
| @@ -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, | |
| + | }; |
| @@ -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"); | |
| + | }; | |
| + | } |
| @@ -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"; |
| @@ -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"); | |
| + | }); | |
| + | }); |
| @@ -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; | |
| + | } |
| @@ -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; |
| @@ -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" | |
| + | } | |
| + | } |
| @@ -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"] } }], | |
| + | }); |
| @@ -0,0 +1,9 @@ | ||
| + | /** Tailwind v4 is configured via @tailwindcss/postcss - no tailwind.config.* file. */ | |
| + | const config = { | |
| + | plugins: { | |
| + | "@tailwindcss/postcss": {}, | |
| + | autoprefixer: {}, | |
| + | }, | |
| + | }; | |
| + | ||
| + | export default config; |
| @@ -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" } | |
| + | ] | |
| + | } |
| @@ -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 }; | |
| + | }), | |
| + | })); |
| @@ -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"] | |
| + | } |
| @@ -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/**"], | |
| + | }, | |
| + | }); |
| @@ -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 | |
| + | } | |
| + | } |
| @@ -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})" |
| @@ -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 |
| @@ -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 | |
| + | } |
| @@ -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 |
| @@ -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 |
| @@ -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: "" |
| @@ -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"] |
| @@ -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 |
| @@ -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 |