feat(pricing): convoy for online prices and local stores
Some checks failed
CI / Lint (pull_request) Has been cancelled
CI / Schema map up to date (pull_request) Has been cancelled
CI / Forbidden patterns (9 checks) (pull_request) Has been cancelled
CI / Migrations apply (node-pg-migrate) (pull_request) Has been cancelled
Visual diff / Should run? (pull_request) Has been cancelled
CI / Unit tests (vitest) (pull_request) Has been cancelled
Convoy metrics gate / Require role-event telemetry on convoy PRs (pull_request) Has been cancelled
PR Health rollup / Aggregate gate status (pull_request) Has been cancelled
Preview smoke / Should run? (pull_request) Has been cancelled
Preview smoke / Playwright smoke (pull_request) Has been cancelled
Visual diff / Screenshot diff (pull_request) Has been cancelled
Some checks failed
CI / Lint (pull_request) Has been cancelled
CI / Schema map up to date (pull_request) Has been cancelled
CI / Forbidden patterns (9 checks) (pull_request) Has been cancelled
CI / Migrations apply (node-pg-migrate) (pull_request) Has been cancelled
Visual diff / Should run? (pull_request) Has been cancelled
CI / Unit tests (vitest) (pull_request) Has been cancelled
Convoy metrics gate / Require role-event telemetry on convoy PRs (pull_request) Has been cancelled
PR Health rollup / Aggregate gate status (pull_request) Has been cancelled
Preview smoke / Should run? (pull_request) Has been cancelled
Preview smoke / Playwright smoke (pull_request) Has been cancelled
Visual diff / Screenshot diff (pull_request) Has been cancelled
Add pricing schema, free API integrations (MTG bulk, TCGdex, Foolio), valuation and sort-by-price, card detail pricing UI, and nearby store discovery via home zip and OSM geo clients. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
70be99421d
commit
d0468015eb
69 changed files with 4662 additions and 136 deletions
320
.convoys/pricing-online-local.md
Normal file
320
.convoys/pricing-online-local.md
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
---
|
||||
name: pricing-online-local
|
||||
classification: feature
|
||||
success_metric: Users see honest online prices (MTG/Pokémon/Lorcana) with sync timestamps, can sort collections by price with correct foil-aware totals, and authenticated users with a saved zip see nearby game stores — all on $0 APIs.
|
||||
skip: []
|
||||
status: implementers-complete
|
||||
created: 2026-09-08
|
||||
branch: convoy/pricing-online-local
|
||||
design_direction:
|
||||
version: 1
|
||||
locked_at: 2026-09-08
|
||||
extension: incremental
|
||||
parent_system: liquid-glass
|
||||
product_type: consumer TCG collection + pricing utility
|
||||
pattern: authenticated app — card detail tabs + collection list + settings form
|
||||
style: Liquid Glass — warm ember accent on glass; honesty-first pricing metadata
|
||||
stack: nextjs-pages-router | react-18 | tailwind | css-vars
|
||||
generator: skipped
|
||||
generator_reason: Established Liquid Glass DS; incremental pricing surfaces only
|
||||
model_policy:
|
||||
default_session: auto
|
||||
roles:
|
||||
role-conductor: composer-2.5-fast
|
||||
role-architect: composer-2.5
|
||||
role-ia-architect: composer-2.5-fast
|
||||
role-ux-reviewer: composer-2.5-fast
|
||||
role-ui-designer: composer-2.5-fast
|
||||
role-implementer: composer-2.5-fast
|
||||
role-reviewer: cursor-grok-4.5-high
|
||||
role-security-auditor: gpt-5.6-terra-medium
|
||||
---
|
||||
|
||||
# Convoy: Pricing — Online + Local Near Me (Free APIs Only)
|
||||
|
||||
**Branch:** `convoy/pricing-online-local`
|
||||
**Constraint:** **$0 recurring** — no Google Places, no paid pricing tiers, no scraping.
|
||||
**Goal:** Wire honest online pricing + gentle local store discovery.
|
||||
|
||||
---
|
||||
|
||||
## Why
|
||||
|
||||
Collection valuation, card detail, and purchase flows today show incomplete or wrong prices: Pokémon/Lorcana sync writes null prices, MTG totals use foil `market_price` for nonfoil rows, sort-by-price UI is inert, and Price History / Purchase tabs are stubs. Users need trustworthy online estimates and optional local store discovery without paid APIs.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:** Scryfall (MTG), TCGdex (Pokémon), Foilio (Lorcana) ingest; foil-aware valuation; sort-by-price; pricing + history APIs; Purchase / Price History tabs; home zip + nearby stores (Nominatim + Overpass).
|
||||
|
||||
**Out:** Paid aggregators, scraping, in-store inventory/prices, buylist data, affiliate links.
|
||||
|
||||
## Roles invoked
|
||||
|
||||
1. Conductor ✓
|
||||
2. IA Architect ✓
|
||||
3. UI Designer ✓ (incremental — skipped ui-ux-pro-max)
|
||||
4. UX Reviewer ✓
|
||||
5. Security Auditor ✓ (planning phase)
|
||||
6. Architect ✓ → **Human gate 1** ← you are here
|
||||
7. Implementers (13 briefs, `/multitask` where safe)
|
||||
8. Audit fan-out (reviewer + security + design-system + a11y)
|
||||
|
||||
## Todos
|
||||
|
||||
- [x] Planning roles (IA, UX, UI, Security, Architect)
|
||||
- [ ] Human gate 1 — plan approval
|
||||
- [ ] Phase 0 implementers (Briefs 1–7)
|
||||
- [ ] Phase 1 implementers (Briefs 8–9)
|
||||
- [ ] Phase 2 implementers (Briefs 10–13)
|
||||
- [ ] Audit fan-out + merge
|
||||
|
||||
---
|
||||
|
||||
## Free-only stack (recommended)
|
||||
|
||||
### Online pricing by game
|
||||
|
||||
| Game | Source | Cost | Key? | Notes |
|
||||
|------|--------|------|------|-------|
|
||||
| **MTG** | [Scryfall](https://scryfall.com/docs/api) bulk + card API | Free | No | Already integrated. `prices.usd` / `usd_foil`. Polite rate limits (~10 req/s). |
|
||||
| **Pokemon** | [TCGdex](https://tcgdex.dev) REST | Free | **No** | Catalog + embedded `pricing.tcgplayer` (USD) + `pricing.cardmarket` (EUR). Hourly/daily updates. |
|
||||
| **Lorcana** | [Foilio feed](https://foilio.net/developers) `GET /api/prices/feed` | Free | **No** | JSON, CORS, cached 1h. EUR/USD as reported; match on `card_id` / name+set. |
|
||||
| **Fallback links** | TCGPlayer + eBay search URLs | Free | No | Already on card detail — keep for buy-outbound. |
|
||||
|
||||
**Optional signup (still $0):**
|
||||
- [TCG Price Lookup](https://tcgpricelookup.com/tcg-api) — **10,000 req/month free** with API key (multi-game). Good for on-demand card detail if TCGdex/Foilio miss a card.
|
||||
|
||||
**Do NOT use for this convoy:**
|
||||
- tcgapi.dev beyond 100/day (too tight for bulk sync)
|
||||
- Google Places (paid after credits)
|
||||
- Scraping TCGPlayer / Cardmarket pages
|
||||
|
||||
### Local “near me” (free)
|
||||
|
||||
| Step | Source | Cost |
|
||||
|------|--------|------|
|
||||
| Zip → lat/lng | [Nominatim](https://nominatim.org/release-docs/develop/api/Search/) (OSM) | Free — **1 req/s max**, cache forever per zip |
|
||||
| Stores in radius | [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) `shop=games` | Free — cache 7 days per `(lat,lng,radius)` |
|
||||
|
||||
Show: name, address, distance, `website` / `phone` tags, OpenStreetMap link. **No scraped in-store prices.**
|
||||
|
||||
---
|
||||
|
||||
## Decisions (planning round)
|
||||
|
||||
| # | Question | Decision |
|
||||
| --- | --- | --- |
|
||||
| A | Nearby stores auth | **Auth required**; zip from saved `home_zip` only — no anonymous geocode relay (Security sev-3) |
|
||||
| B | Local stores placement | Subsection in **Purchase tab**, not a fourth tab |
|
||||
| C | Zip input | Manual zip in Preferences; geolocation **nice-to-have** later |
|
||||
| D | Online pricing visibility | Public (matches existing `/api/cards/[id]`) |
|
||||
| E | Price History empty state | Show tab with honest empty copy until snapshots accumulate |
|
||||
| F | Collection sort | **Server-side** `sort=price` on paginated APIs |
|
||||
| G | Settings placement | Home zip under **Preferences** panel |
|
||||
| H | Currency v1 | USD primary; respect `preferred_currency` where data exists |
|
||||
|
||||
---
|
||||
|
||||
## Design direction
|
||||
|
||||
Incremental extension of Liquid Glass — no new token files. Prices use `.gradient-text-gold` / `var(--accent-flame)`; staleness uses neutral tertiary pills (not error red); glass on section containers only (never per-card grid). See full typography/spacing/badge spec in planning transcript; repo tokens win on conflict.
|
||||
|
||||
**Surfaces:** Purchase tab price summary, Price History chart panel, staleness badge, collection sort control, settings zip field, nearby stores list.
|
||||
|
||||
---
|
||||
|
||||
## IA
|
||||
|
||||
### Affected routes
|
||||
|
||||
**Pages:** `/card/[id]` [modified], `/settings` [modified], `/my-cards` [modified], `/collection/[identifier]` [modified], `/collections` [impacted], `/profile` [impacted], `/cards` [impacted]
|
||||
|
||||
**API:** `GET /api/cards/[id]/pricing` [new], `GET /api/cards/[id]/price-history` [new], `GET /api/stores/nearby` [new], `GET|PUT /api/user/settings` [modified], `GET /api/cards/owned` [modified], bulk import jobs [impacted]
|
||||
|
||||
### User flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Settings: save zip] --> B[Card Detail]
|
||||
B --> C[Purchase tab]
|
||||
C --> D[Online NM price]
|
||||
C --> E[Buy links]
|
||||
C --> F[Stores near you]
|
||||
G[My Collection] --> H[Sort by Price]
|
||||
```
|
||||
|
||||
### Screen inventory
|
||||
|
||||
| Screen | Path | New/modified | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Card Detail — Purchase | `/card/[id]` | Modified | DB prices, staleness badge, outbound links, local stores subsection |
|
||||
| Card Detail — Price History | `/card/[id]` | Modified | Snapshot chart from sync-stored history |
|
||||
| Settings — zip | `/settings` | Modified | Home ZIP in Preferences |
|
||||
| My Collection | `/my-cards` | Modified | Sort by price + corrected estimated value |
|
||||
| List Detail | `/collection/[identifier]` | Modified | Wire existing sort dropdown |
|
||||
|
||||
---
|
||||
|
||||
## UX
|
||||
|
||||
**Reuse:** `CardDetailView`, `useCardDetail`, `CollectionPageView`, `useCollectionView`, `StatCard`, `GlassSurface`, `Input`, `Button`, settings page patterns.
|
||||
|
||||
**Key patterns:** DB-first pricing (no optimistic price UI); staleness badge with text date (not color-only); server-side sort; zip saves with explicit Settings Save button; local stores below purchase links.
|
||||
|
||||
**A11y handoff:** Tab roles on card detail tabs; `aria-live` for pricing load; zip validation via `Input error`; 4.5:1 contrast on badge text; link purpose text on all outbound URLs.
|
||||
|
||||
**Anti-patterns:** No “live” pricing copy; no per-card glass; no client-only sort across pages; no auto GPS on load.
|
||||
|
||||
---
|
||||
|
||||
## Security (planning)
|
||||
|
||||
| Check | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| Auth boundary | ⚠️ | Pricing public OK; **nearby stores auth required** |
|
||||
| Authorization (IDOR) | ⚠️ | Zip settings owner-scoped; no arbitrary zip query |
|
||||
| Input / injection | ⚠️ | Overpass fixed-template only; validate zip + radius |
|
||||
| Secrets exposure | ✅ | Core path keyless; server-only optional key |
|
||||
| Dependencies | ⚠️ | Validate upstream JSON; native fetch preferred |
|
||||
|
||||
**Top pre-merge fixes:** Overpass QL injection (sev 4), geocode relay without auth (sev 3), bulk sync admin gate (sev 3).
|
||||
|
||||
Re-audit with file:line citations after implementer PR.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Decomposition
|
||||
|
||||
| Brief # | Title | Phase | Depends on | Est. PR |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Pricing schema migration | 0 | — | ~120 LOC |
|
||||
| 2 | Pricing foundation lib | 0 | 1 | ~280 LOC |
|
||||
| 3 | MTG bulk sync + snapshots | 0 | 2 | ~180 LOC |
|
||||
| 4 | TCGdex Pokémon pricing | 0 | 2 | ~320 LOC |
|
||||
| 5 | Foilio Lorcana pricing | 0 | 2 | ~280 LOC |
|
||||
| 6 | Foil-aware valuation APIs | 0 | 2 | ~150 LOC |
|
||||
| 7 | Sort by price wiring | 0 | 2, 6 | ~220 LOC |
|
||||
| 8 | Card pricing + history APIs | 1 | 2 | ~350 LOC |
|
||||
| 9 | Card detail pricing UI | 1 | 8 | ~380 LOC |
|
||||
| 10 | Geo clients + DB cache | 2 | 1 | ~300 LOC |
|
||||
| 11 | Stores nearby API | 2 | 10 | ~200 LOC |
|
||||
| 12 | Settings home zip | 2 | 1 | ~120 LOC |
|
||||
| 13 | Local stores card detail UI | 2 | 9, 11, 12 | ~200 LOC |
|
||||
|
||||
**Brief files:** `.convoys/pricing-online-local/brief-*.md`
|
||||
|
||||
### Schema diff (migration `1783000000001_add-pricing-schema.js`)
|
||||
|
||||
- `cards.prices_updated_at TIMESTAMPTZ`
|
||||
- `card_price_history` (daily snapshots, UNIQUE per card/day/source/type/currency)
|
||||
- `users.home_zip VARCHAR(10)`
|
||||
- `geo_zip_cache`, `geo_stores_cache` (DB-backed, 7d TTL on stores)
|
||||
|
||||
### slice_dependencies (multitask-ready)
|
||||
|
||||
```yaml
|
||||
slice_dependencies:
|
||||
- brief: 1
|
||||
depends_on: []
|
||||
files: [migrations/1783000000001_add-pricing-schema.js, docs/SCHEMA_MAP.md]
|
||||
- brief: 2
|
||||
depends_on: [1]
|
||||
files: [lib/pricing/card-price.js, lib/pricing/price-history.js, lib/pricing/collection-valuation.js, lib/pricing/outbound-links.js]
|
||||
- brief: 3
|
||||
depends_on: [2]
|
||||
files: [lib/card-import/bulk-sync.js]
|
||||
- brief: 4
|
||||
depends_on: [2]
|
||||
files: [lib/pricing/tcgdex-client.js, lib/card-import/pokemon-bulk.js]
|
||||
- brief: 5
|
||||
depends_on: [2]
|
||||
files: [lib/pricing/foilio-client.js, lib/card-import/lorcana-bulk.js]
|
||||
- brief: 6
|
||||
depends_on: [2]
|
||||
files: [pages/api/user/stats.js, pages/api/collections/[identifier].js]
|
||||
- brief: 7
|
||||
depends_on: [2, 6]
|
||||
files: [pages/api/cards/owned.js, pages/api/collections/[identifier]/cards.js, lib/use-collection-view.js, components/CollectionPageView.js, pages/my-cards.js]
|
||||
- brief: 8
|
||||
depends_on: [2]
|
||||
files: [lib/pricing/pricing-service.js, pages/api/cards/[id]/pricing.js, pages/api/cards/[id]/price-history.js]
|
||||
- brief: 9
|
||||
depends_on: [8]
|
||||
files: [components/card-detail/CardDetailPurchaseTab.js, components/card-detail/CardDetailPriceHistoryTab.js, components/CardDetailView.js, lib/use-card-detail.js]
|
||||
- brief: 10
|
||||
depends_on: [1]
|
||||
files: [lib/pricing/nominatim.js, lib/pricing/overpass.js, lib/pricing/geo-cache.js]
|
||||
- brief: 11
|
||||
depends_on: [10]
|
||||
files: [pages/api/stores/nearby.js, lib/rate-limit.js]
|
||||
- brief: 12
|
||||
depends_on: [1]
|
||||
files: [pages/settings.js, pages/api/user/settings.js]
|
||||
- brief: 13
|
||||
depends_on: [9, 11, 12]
|
||||
files: [components/card-detail/CardDetailLocalStores.js, components/CardDetailView.js, lib/use-card-detail.js]
|
||||
```
|
||||
|
||||
**Parallelization:** After Brief 1 → Brief 2 → **parallel** Briefs 3, 4, 5, 6 → Brief 7 → Brief 8 → Brief 9 → **parallel** Briefs 10, 12 → Brief 11 → Brief 13.
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
| --- | --- |
|
||||
| TCGdex ID mismatch | Match set_code + card_number; log unmatched |
|
||||
| Foilio card_id drift | Fallback name+set match |
|
||||
| Nominatim ToS | DB cache forever; 1 req/s; server-only |
|
||||
| Overpass load | 7d cache; auth + 1/hour/user |
|
||||
| Valuation on lists (no is_foil) | `COALESCE(current_price, market_price)` + tooltip |
|
||||
|
||||
---
|
||||
|
||||
## Phase checklist
|
||||
|
||||
### Phase 0
|
||||
- [ ] Pokemon bulk → TCGdex prices
|
||||
- [ ] Lorcana bulk → Foilio feed
|
||||
- [ ] Fix MTG foil valuation bug
|
||||
- [ ] Sort by Price wired
|
||||
- [ ] Price snapshots on sync
|
||||
|
||||
### Phase 1
|
||||
- [ ] `GET /api/cards/[id]/pricing`
|
||||
- [ ] Purchase tab + staleness badge
|
||||
- [ ] Price History tab
|
||||
|
||||
### Phase 2
|
||||
- [ ] Settings home zip
|
||||
- [ ] `GET /api/stores/nearby` (auth-gated)
|
||||
- [ ] Local stores on card detail
|
||||
|
||||
---
|
||||
|
||||
## Env vars
|
||||
|
||||
```bash
|
||||
TCGDEX_BASE_URL=https://api.tcgdex.net/v2/en
|
||||
FOILIO_PRICES_FEED_URL=https://foilio.net/api/prices/feed?game=lorcana
|
||||
NOMINATIM_USER_AGENT=DeckHearth/1.0 (contact@stillwell.cloud)
|
||||
OVERPASS_URL=https://overpass-api.de/api/interpreter
|
||||
TCG_PRICE_LOOKUP_API_KEY= # optional
|
||||
PRICING_LOCAL_ENABLED=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gentleness rules
|
||||
|
||||
| API | Rule |
|
||||
|-----|------|
|
||||
| Scryfall bulk | One download per sync; User-Agent set |
|
||||
| TCGdex | Batch by set; sleep 100ms between pages |
|
||||
| Foilio | One feed fetch/hour |
|
||||
| Nominatim | Max 1 req/s; cache zip→coords forever |
|
||||
| Overpass | Max 1 query/user/hour; 7d cache |
|
||||
|
||||
---
|
||||
|
||||
**Architecture complete. 13 briefs created. Estimated PRs: 13. Awaiting human gate 1 (plan approval) before implementers run.**
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
convoy: pricing-online-local
|
||||
brief_number: 1
|
||||
depends_on: []
|
||||
recommended_model: composer-2.5
|
||||
model_tier: standard
|
||||
files:
|
||||
- migrations/1783000000001_add-pricing-schema.js
|
||||
- docs/SCHEMA_MAP.md
|
||||
---
|
||||
|
||||
# Brief 1: Pricing schema migration
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Ship the node-pg-migrate migration for pricing timestamps, price history, user home zip, and geo caches.
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
- `migrations/1783000000001_add-pricing-schema.js`
|
||||
- `docs/SCHEMA_MAP.md`
|
||||
|
||||
## Conventions to follow
|
||||
- Generate via `npm run migrate create add-pricing-schema -- -j js`; apply with `npm run migrate up`.
|
||||
- `down()` must hard-stub (throw) — see `migrations/1779853647564_initial-schema.js`.
|
||||
- Update `docs/SCHEMA_MAP.md` for every new column/table (AGENTS.md § Schema changes).
|
||||
- Use `ADD COLUMN IF NOT EXISTS` / `CREATE TABLE IF NOT EXISTS` for idempotency.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Migration adds `cards.prices_updated_at TIMESTAMPTZ`
|
||||
- [ ] Migration creates `card_price_history` with UNIQUE `(card_id, source, price_type, currency, recorded_at)` and index on `(card_id, recorded_at DESC)`
|
||||
- [ ] Migration adds `users.home_zip VARCHAR(10)`
|
||||
- [ ] Migration creates `geo_zip_cache` and `geo_stores_cache` tables per Architecture §3
|
||||
- [ ] `docs/SCHEMA_MAP.md` updated
|
||||
- [ ] `npm run migrate up` succeeds on fresh + existing DB
|
||||
- [ ] No scope expansion (do not edit files outside `files:` above)
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
All pricing and geo features depend on these columns/tables. Landing schema first unblocks parallel implementers for bulk ingest, APIs, and settings without merge conflicts on migration files.
|
||||
47
.convoys/pricing-online-local/brief-10-geo-clients-cache.md
Normal file
47
.convoys/pricing-online-local/brief-10-geo-clients-cache.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
convoy: pricing-online-local
|
||||
brief_number: 10
|
||||
depends_on: [1]
|
||||
recommended_model: composer-2.5
|
||||
model_tier: standard
|
||||
files:
|
||||
- lib/pricing/nominatim.js
|
||||
- lib/pricing/overpass.js
|
||||
- lib/pricing/geo-cache.js
|
||||
- test/lib/pricing/nominatim.test.js
|
||||
- test/lib/pricing/overpass.test.js
|
||||
- test/lib/pricing/geo-cache.test.js
|
||||
---
|
||||
|
||||
# Brief 10: Geo clients + DB cache
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Implement Nominatim geocoding, Overpass store queries, and DB-backed caches per convoy gentleness rules.
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
- `lib/pricing/nominatim.js`
|
||||
- `lib/pricing/overpass.js`
|
||||
- `lib/pricing/geo-cache.js`
|
||||
- `test/lib/pricing/nominatim.test.js`
|
||||
- `test/lib/pricing/overpass.test.js`
|
||||
- `test/lib/pricing/geo-cache.test.js`
|
||||
|
||||
## Conventions to follow
|
||||
- **Security:** Overpass QL uses fixed template only; inject parsed floats for lat/lng/radiusMeters — never user strings.
|
||||
- `nominatim.js`: User-Agent from `NOMINATIM_USER_AGENT`; min 1s between requests.
|
||||
- `overpass.js`: query `shop=games` and `shop=hobby`; radius capped 1–50 miles server-side.
|
||||
- `geo-cache.js`: zip coords cached forever; stores cache 7-day TTL.
|
||||
- Allowlist outbound hosts at module init.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Zip cache hit skips Nominatim
|
||||
- [ ] Stores cache respects 7-day TTL
|
||||
- [ ] Overpass parser returns distance-ready lat/lng per store
|
||||
- [ ] Unit tests cover parser + TTL logic
|
||||
- [ ] Tests pass
|
||||
- [ ] No scope expansion
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
Isolates external geo APIs from the route handler for testability. DB cache avoids Redis dependency for long-lived zip coords. Satisfies convoy rate-limit requirements before Brief 11 wires the API.
|
||||
40
.convoys/pricing-online-local/brief-11-stores-nearby-api.md
Normal file
40
.convoys/pricing-online-local/brief-11-stores-nearby-api.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
convoy: pricing-online-local
|
||||
brief_number: 11
|
||||
depends_on: [10]
|
||||
recommended_model: composer-2.5
|
||||
model_tier: standard
|
||||
files:
|
||||
- pages/api/stores/nearby.js
|
||||
- lib/rate-limit.js
|
||||
- test/api/stores-nearby.test.js
|
||||
---
|
||||
|
||||
# Brief 11: Stores nearby API
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Ship `GET /api/stores/nearby` with geocode + Overpass lookup, caching, and per-user refresh rate limit.
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
- `pages/api/stores/nearby.js`
|
||||
- `lib/rate-limit.js`
|
||||
- `test/api/stores-nearby.test.js`
|
||||
|
||||
## Conventions to follow
|
||||
- **Auth required** (`getUserFromRequest` → 401). Zip from user's saved `home_zip` only — ignore arbitrary `zip` query param (security planning decision A).
|
||||
- Add `stores` limiter: 1/hour/user; IP backstop for burst abuse.
|
||||
- Respect `PRICING_LOCAL_ENABLED` — return 503 when `false`.
|
||||
- Overpass fixed-template query only (see Brief 10).
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Endpoint returns store list for authenticated user with saved zip
|
||||
- [ ] 401 without Bearer token
|
||||
- [ ] Second call within 7 days serves from `geo_stores_cache`
|
||||
- [ ] Rate limit returns 429 with `Retry-After`
|
||||
- [ ] Tests pass
|
||||
- [ ] No scope expansion
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
Phase 2 API surface from convoy spec. Auth-gated zip prevents geocode relay abuse. Rate limiting protects Overpass.
|
||||
39
.convoys/pricing-online-local/brief-12-settings-home-zip.md
Normal file
39
.convoys/pricing-online-local/brief-12-settings-home-zip.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
convoy: pricing-online-local
|
||||
brief_number: 12
|
||||
depends_on: [1]
|
||||
recommended_model: composer-2.5
|
||||
model_tier: standard
|
||||
files:
|
||||
- pages/settings.js
|
||||
- pages/api/user/settings.js
|
||||
- test/api/user-settings-home-zip.test.js
|
||||
---
|
||||
|
||||
# Brief 12: Settings home zip
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Let users save a home zip code in Settings for local store discovery.
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
- `pages/settings.js`
|
||||
- `pages/api/user/settings.js`
|
||||
- `test/api/user-settings-home-zip.test.js`
|
||||
|
||||
## Conventions to follow
|
||||
- Auth: `getUserFromRequest` → 401 if null.
|
||||
- Validate zip: 5-digit US; store 5-digit string; allow `null` to clear.
|
||||
- Settings UI: field under **Preferences** — “Home ZIP code (for nearby game stores)”.
|
||||
- Owner-scoped read/write only (`user_id = session.userId`).
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] GET settings includes `home_zip`
|
||||
- [ ] PUT persists valid zip; 400 on invalid
|
||||
- [ ] Settings page loads and saves zip
|
||||
- [ ] Tests pass
|
||||
- [ ] No scope expansion
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
Phase 2 requires a default location without GPS. Reuses existing settings flow. Independent of geo API briefs once migration lands.
|
||||
47
.convoys/pricing-online-local/brief-13-local-stores-ui.md
Normal file
47
.convoys/pricing-online-local/brief-13-local-stores-ui.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
convoy: pricing-online-local
|
||||
brief_number: 13
|
||||
depends_on: [9, 11, 12]
|
||||
recommended_model: composer-2.5
|
||||
model_tier: standard
|
||||
files:
|
||||
- components/card-detail/CardDetailLocalStores.js
|
||||
- components/CardDetailView.js
|
||||
- lib/use-card-detail.js
|
||||
- test/components/CardDetailLocalStores.test.js
|
||||
cross_brief_commitments:
|
||||
- brief: 9
|
||||
description: |
|
||||
Inserts local stores section into the purchase tab area using the
|
||||
slot Brief 9 reserved. Does not rework purchase pricing UI.
|
||||
---
|
||||
|
||||
# Brief 13: Local stores card detail UI
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Show “Game stores near you” on card detail using saved home zip and the nearby stores API.
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
- `components/card-detail/CardDetailLocalStores.js`
|
||||
- `components/CardDetailView.js`
|
||||
- `lib/use-card-detail.js`
|
||||
- `test/components/CardDetailLocalStores.test.js`
|
||||
|
||||
## Conventions to follow
|
||||
- Fetch `/api/stores/nearby` when purchase tab active (auth required).
|
||||
- List: name, address, distance, phone/website links, OSM link — no in-store prices.
|
||||
- Empty states: no zip → link to Settings; no stores → gentle copy.
|
||||
- Liquid Glass styling consistent with Brief 9 components.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Stores render when user has home zip configured
|
||||
- [ ] Prompt to set zip when missing
|
||||
- [ ] No scraped in-store pricing shown
|
||||
- [ ] Component test covers empty + populated states
|
||||
- [ ] Tests pass
|
||||
- [ ] No scope expansion
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
Completes Phase 2 user-facing goal on card detail. Depends on pricing UI shell from Brief 9 and backend from Briefs 11–12.
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
convoy: pricing-online-local
|
||||
brief_number: 2
|
||||
depends_on: [1]
|
||||
recommended_model: composer-2.5
|
||||
model_tier: standard
|
||||
files:
|
||||
- lib/pricing/card-price.js
|
||||
- lib/pricing/price-history.js
|
||||
- lib/pricing/collection-valuation.js
|
||||
- lib/pricing/outbound-links.js
|
||||
- test/lib/pricing/card-price.test.js
|
||||
- test/lib/pricing/price-history.test.js
|
||||
- test/lib/pricing/collection-valuation.test.js
|
||||
cross_brief_commitments:
|
||||
- brief: 3
|
||||
description: |
|
||||
Briefs 3–5 import `recordPriceSnapshots` from `lib/pricing/price-history.js`
|
||||
and set `prices_updated_at` after successful price writes. They must call
|
||||
the shared helpers rather than duplicating snapshot SQL.
|
||||
---
|
||||
|
||||
# Brief 2: Pricing foundation lib
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Create shared pricing helpers for foil-aware unit prices, daily history snapshots, collection valuation SQL, and outbound buy links.
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
- `lib/pricing/card-price.js`
|
||||
- `lib/pricing/price-history.js`
|
||||
- `lib/pricing/collection-valuation.js`
|
||||
- `lib/pricing/outbound-links.js`
|
||||
- `test/lib/pricing/card-price.test.js`
|
||||
- `test/lib/pricing/price-history.test.js`
|
||||
- `test/lib/pricing/collection-valuation.test.js`
|
||||
|
||||
## Conventions to follow
|
||||
- ESM (`import`/`export`); DB via `import { sql } from '../sql.js'` (relative from `lib/pricing/`).
|
||||
- `card-price.js`: export `resolveUnitPrice({ currentPrice, marketPrice, isFoil })` — nonfoil prefers `current_price`, foil prefers `market_price`; export `PRICE_STALE_HOURS = 24`.
|
||||
- `collection-valuation.js`: export `effectiveUnitPriceExpr({ cardAlias = 'c', userCardAlias = 'uc' })` returning SQL `CASE WHEN uc.is_foil THEN c.market_price ELSE COALESCE(c.current_price, c.market_price) END`; export `listUnitPriceExpr({ cardAlias = 'c' })` → `COALESCE(c.current_price, c.market_price)`.
|
||||
- `price-history.js`: export `recordPriceSnapshots(rows)` where each row is `{ cardId, source, priceType, currency, amount }`; upsert on daily UNIQUE key.
|
||||
- `outbound-links.js`: extract TCGPlayer + eBay URL logic currently inline in `CardDetailView.js` (game-aware).
|
||||
- Mirror test style from `test/lib/collection-vocabulary.test.js`.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] `resolveUnitPrice` returns foil vs nonfoil correctly for MTG-shaped data
|
||||
- [ ] `recordPriceSnapshots` is idempotent for same card/day/source/type
|
||||
- [ ] `effectiveUnitPriceExpr` produces valid SQL fragment (unit-tested via known inputs or integration mock)
|
||||
- [ ] `buildTcgplayerUrl(card)` and `buildEbayUrl(card)` exported
|
||||
- [ ] All three test files pass (`npm run test:run`)
|
||||
- [ ] No scope expansion
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
Centralizes pricing semantics so bulk sync, valuation fix, and APIs cannot drift. The foil bug (`market_price` = Scryfall `usd_foil`) is fixed once at the helper layer. Snapshot writer gives Briefs 3–5 a single hook.
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
convoy: pricing-online-local
|
||||
brief_number: 3
|
||||
depends_on: [2]
|
||||
recommended_model: composer-2.5
|
||||
model_tier: standard
|
||||
files:
|
||||
- lib/card-import/bulk-sync.js
|
||||
- test/lib/card-import/bulk-sync.test.js
|
||||
cross_brief_commitments:
|
||||
- brief: 2
|
||||
description: |
|
||||
Uses `recordPriceSnapshots` and sets `prices_updated_at = CURRENT_TIMESTAMP`
|
||||
on each upsert batch where price columns change.
|
||||
---
|
||||
|
||||
# Brief 3: MTG bulk sync + snapshots
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Update Scryfall bulk sync to stamp `prices_updated_at` and record daily price history snapshots.
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
- `lib/card-import/bulk-sync.js`
|
||||
- `test/lib/card-import/bulk-sync.test.js`
|
||||
|
||||
## Conventions to follow
|
||||
- Keep existing `mapCard()` mapping: `usd` → `current_price`, `usd_foil` → `market_price`.
|
||||
- Add `prices_updated_at` to INSERT/UPDATE columns (set when either price is non-null).
|
||||
- After each successful `upsertBatch`, call `recordPriceSnapshots` for rows with prices (`source: 'scryfall'`, `priceType: 'market'` for nonfoil amount, optionally second snapshot for foil if present).
|
||||
- Export `mapCard` for unit tests (or test via named export already present).
|
||||
- Preserve Scryfall User-Agent header.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] `upsertBatch` writes `prices_updated_at`
|
||||
- [ ] Price snapshots recorded per card per sync day
|
||||
- [ ] `mapCard` unit tests cover price parsing
|
||||
- [ ] Existing `runBulkMtgSync` return shape unchanged (add optional `snapshotsWritten` count)
|
||||
- [ ] Tests pass
|
||||
- [ ] No scope expansion
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
MTG already has price data from Scryfall; this brief wires timestamps and history without changing upstream source. Fixes the data pipeline for Phase 1 pricing API reads.
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
convoy: pricing-online-local
|
||||
brief_number: 4
|
||||
depends_on: [2]
|
||||
recommended_model: composer-2.5
|
||||
model_tier: standard
|
||||
files:
|
||||
- lib/pricing/tcgdex-client.js
|
||||
- lib/card-import/pokemon-bulk.js
|
||||
- test/lib/pricing/tcgdex-client.test.js
|
||||
- test/lib/card-import/pokemon-bulk.test.js
|
||||
---
|
||||
|
||||
# Brief 4: TCGdex Pokémon pricing ingest
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Ingest USD TCGPlayer prices from TCGdex during Pokémon bulk sync instead of writing null prices.
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
- `lib/pricing/tcgdex-client.js`
|
||||
- `lib/card-import/pokemon-bulk.js`
|
||||
- `test/lib/pricing/tcgdex-client.test.js`
|
||||
- `test/lib/card-import/pokemon-bulk.test.js`
|
||||
|
||||
## Conventions to follow
|
||||
- Base URL from `process.env.TCGDEX_BASE_URL ?? 'https://api.tcgdex.net/v2/en'`.
|
||||
- Client: `fetchSetCards(setId)` with 100ms delay between set requests; parse `pricing.tcgplayer` (USD market/low) and `pricing.cardmarket` (EUR — store in `market_price` only when USD missing, document in code comment).
|
||||
- Match TCGdex cards to DB rows by `set_code` + `card_number` (normalize leading zeros); fallback name match within set.
|
||||
- `mapPokemonBulkRow` / `buildUpsertQuery`: populate `current_price` (low or market NM), `market_price` (market), `prices_updated_at`.
|
||||
- Call `recordPriceSnapshots` with `source: 'tcgdex'`.
|
||||
- Log `unmatchedCount` in sync summary JSON.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Pokémon bulk no longer writes `null` prices when TCGdex has data
|
||||
- [ ] Gentleness: 100ms sleep between set fetches
|
||||
- [ ] Unit tests with mocked fetch fixtures
|
||||
- [ ] `runBulkPokemonSync` summary includes `pricesMatched` / `unmatchedCount`
|
||||
- [ ] Tests pass
|
||||
- [ ] No scope expansion
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
Pokémon catalog sync currently nulls prices. TCGdex is the convoy's free USD source. Set-batched fetch keeps within polite limits.
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
convoy: pricing-online-local
|
||||
brief_number: 5
|
||||
depends_on: [2]
|
||||
recommended_model: composer-2.5
|
||||
model_tier: standard
|
||||
files:
|
||||
- lib/pricing/foilio-client.js
|
||||
- lib/card-import/lorcana-bulk.js
|
||||
- test/lib/pricing/foilio-client.test.js
|
||||
- test/lib/card-import/lorcana-bulk.test.js
|
||||
---
|
||||
|
||||
# Brief 5: Foilio Lorcana pricing ingest
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Enrich Lorcana bulk sync with hourly Foilio feed prices keyed by card id and name+set.
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
- `lib/pricing/foilio-client.js`
|
||||
- `lib/card-import/lorcana-bulk.js`
|
||||
- `test/lib/pricing/foilio-client.test.js`
|
||||
- `test/lib/card-import/lorcana-bulk.test.js`
|
||||
|
||||
## Conventions to follow
|
||||
- Feed URL: `process.env.FOILIO_PRICES_FEED_URL ?? 'https://foilio.net/api/prices/feed?game=lorcana'`.
|
||||
- Client fetches once per sync run; build `Map` by `card_id` and composite `normalize(name)|set`.
|
||||
- Extend `buildUpsertQuery` / `mapLorcanaCard` to include `current_price`, `market_price`, `prices_updated_at`.
|
||||
- Prefer USD fields when present; otherwise EUR with `currency` noted in snapshot.
|
||||
- `recordPriceSnapshots` with `source: 'foilio'`.
|
||||
- Preserve existing `scryfall_id = Unique_ID` keying.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Lorcana cards receive prices when feed contains a match
|
||||
- [ ] Single feed fetch per `runBulkLorcanaSync` invocation
|
||||
- [ ] Summary includes `pricesMatched` / `unmatchedCount`
|
||||
- [ ] Tests pass with fixture JSON
|
||||
- [ ] No scope expansion
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
Lorcana bulk currently omits price columns entirely. Foilio provides a free hourly JSON feed. One fetch per sync aligns with their 1h cache.
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
convoy: pricing-online-local
|
||||
brief_number: 6
|
||||
depends_on: [2]
|
||||
recommended_model: composer-2.5
|
||||
model_tier: standard
|
||||
files:
|
||||
- pages/api/user/stats.js
|
||||
- pages/api/collections/[identifier].js
|
||||
- test/api/user-stats-valuation.test.js
|
||||
---
|
||||
|
||||
# Brief 6: Foil-aware valuation APIs
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Fix collection total value to use nonfoil `current_price` for `user_cards.is_foil = false` rows.
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
- `pages/api/user/stats.js`
|
||||
- `pages/api/collections/[identifier].js`
|
||||
- `test/api/user-stats-valuation.test.js`
|
||||
|
||||
## Conventions to follow
|
||||
- Import `effectiveUnitPriceExpr` and `listUnitPriceExpr` from `lib/pricing/collection-valuation.js`.
|
||||
- `stats.js`: replace `cards.market_price * quantity` with effective unit price expression in value, game breakdown, rarity breakdown, and recent activity.
|
||||
- `collections/[identifier].js`: replace `SUM(cards.market_price * cc.quantity)` with `listUnitPriceExpr`.
|
||||
- Auth pattern unchanged (`getUserFromRequest`).
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Nonfoil `user_cards` row valued at `current_price`, not foil `market_price`
|
||||
- [ ] Foil row still uses `market_price`
|
||||
- [ ] Collection list totals use `COALESCE(current_price, market_price)`
|
||||
- [ ] Regression test locks MTG foil/nonfoil scenario
|
||||
- [ ] Tests pass
|
||||
- [ ] No scope expansion
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
This is the user-visible valuation bug from Phase 0. `market_price` stores Scryfall `usd_foil` for MTG. Fixing at the API layer immediately improves dashboard and profile totals.
|
||||
46
.convoys/pricing-online-local/brief-7-sort-by-price.md
Normal file
46
.convoys/pricing-online-local/brief-7-sort-by-price.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
convoy: pricing-online-local
|
||||
brief_number: 7
|
||||
depends_on: [2, 6]
|
||||
recommended_model: composer-2.5
|
||||
model_tier: standard
|
||||
files:
|
||||
- pages/api/cards/owned.js
|
||||
- pages/api/collections/[identifier]/cards.js
|
||||
- lib/use-collection-view.js
|
||||
- components/CollectionPageView.js
|
||||
- pages/my-cards.js
|
||||
- test/api/cards-owned-sort.test.js
|
||||
---
|
||||
|
||||
# Brief 7: Sort by price wiring
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Wire “Sort by Price” through owned-cards and list APIs into collection and My Collection UIs.
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
- `pages/api/cards/owned.js`
|
||||
- `pages/api/collections/[identifier]/cards.js`
|
||||
- `lib/use-collection-view.js`
|
||||
- `components/CollectionPageView.js`
|
||||
- `pages/my-cards.js`
|
||||
- `test/api/cards-owned-sort.test.js`
|
||||
|
||||
## Conventions to follow
|
||||
- Query params: `sort=name|price|rarity|date`, `order=asc|desc` (default `name`/`asc`).
|
||||
- Price sort uses `listUnitPriceExpr` or `COALESCE(c.current_price, c.market_price)`; NULLs last.
|
||||
- `CollectionPageView` select options must use stable values (`name`, `price`, etc.) not display strings.
|
||||
- Server-side sort required for paginated My Collection.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] `/api/cards/owned?sort=price&order=desc` orders by effective price
|
||||
- [ ] Collection cards API supports same params
|
||||
- [ ] UI sort dropdown changes card order
|
||||
- [ ] API test asserts ORDER BY clause or result ordering
|
||||
- [ ] Tests pass
|
||||
- [ ] No scope expansion
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
Sort UI exists but is inert. Server-side sort avoids loading entire collections client-side. Depends on valuation helpers for consistent price column.
|
||||
52
.convoys/pricing-online-local/brief-8-card-pricing-apis.md
Normal file
52
.convoys/pricing-online-local/brief-8-card-pricing-apis.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
convoy: pricing-online-local
|
||||
brief_number: 8
|
||||
depends_on: [2]
|
||||
recommended_model: composer-2.5
|
||||
model_tier: standard
|
||||
files:
|
||||
- lib/pricing/pricing-service.js
|
||||
- pages/api/cards/[id]/pricing.js
|
||||
- pages/api/cards/[id]/price-history.js
|
||||
- pages/api/cards/[id].js
|
||||
- test/api/cards-pricing.test.js
|
||||
- test/api/cards-price-history.test.js
|
||||
cross_brief_commitments:
|
||||
- brief: 9
|
||||
description: |
|
||||
Response shapes from `/api/cards/[id]/pricing` and
|
||||
`/api/cards/[id]/price-history` are the contract Brief 9's UI consumes.
|
||||
Do not rename fields without updating Brief 9.
|
||||
---
|
||||
|
||||
# Brief 8: Card pricing + history APIs
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Expose `GET /api/cards/[id]/pricing` and `GET /api/cards/[id]/price-history` reading DB with optional stale refresh.
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
- `lib/pricing/pricing-service.js`
|
||||
- `pages/api/cards/[id]/pricing.js`
|
||||
- `pages/api/cards/[id]/price-history.js`
|
||||
- `pages/api/cards/[id].js`
|
||||
- `test/api/cards-pricing.test.js`
|
||||
- `test/api/cards-price-history.test.js`
|
||||
|
||||
## Conventions to follow
|
||||
- API route pattern from `.cursor/rules/api-routes.mdc`: method gate, try/catch, manual validation.
|
||||
- `pricing-service.js`: DB-first; live refresh only when stale >24h with Redis single-flight lock.
|
||||
- Include `outbound` links via `outbound-links.js`.
|
||||
- `price-history.js` API: query `card_price_history` last N days (default 90, max 365).
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] `GET /api/cards/123/pricing` returns prices + `pricesUpdatedAt` + `stale` boolean
|
||||
- [ ] `GET /api/cards/123/price-history?days=30` returns `{ points: [...] }`
|
||||
- [ ] 404 when card missing; 400 on invalid id/days
|
||||
- [ ] Live refresh does not throw on upstream failure
|
||||
- [ ] Tests pass
|
||||
- [ ] No scope expansion
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
Phase 1 backend contract for Purchase and Price History tabs. DB-first path respects free API limits; 24h staleness matches convoy spec.
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
convoy: pricing-online-local
|
||||
brief_number: 9
|
||||
depends_on: [8]
|
||||
recommended_model: composer-2.5
|
||||
model_tier: standard
|
||||
files:
|
||||
- components/card-detail/CardDetailPurchaseTab.js
|
||||
- components/card-detail/CardDetailPriceHistoryTab.js
|
||||
- components/CardDetailView.js
|
||||
- lib/use-card-detail.js
|
||||
- test/components/CardDetailPurchaseTab.test.js
|
||||
cross_brief_commitments:
|
||||
- brief: 8
|
||||
description: |
|
||||
Consumes `/api/cards/[id]/pricing` and `/api/cards/[id]/price-history`
|
||||
response shapes defined in Brief 8.
|
||||
- brief: 13
|
||||
description: |
|
||||
Brief 13 will add `CardDetailLocalStores` below the purchase panel.
|
||||
Leave a slot in purchase tab wrapper for Brief 13 insertion.
|
||||
---
|
||||
|
||||
# Brief 9: Card detail pricing UI
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Replace placeholder Purchase and Price History tabs with live pricing data, staleness badge, and a simple history chart.
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
- `components/card-detail/CardDetailPurchaseTab.js`
|
||||
- `components/card-detail/CardDetailPriceHistoryTab.js`
|
||||
- `components/CardDetailView.js`
|
||||
- `lib/use-card-detail.js`
|
||||
- `test/components/CardDetailPurchaseTab.test.js`
|
||||
|
||||
## Conventions to follow
|
||||
- Liquid Glass surfaces: `glass-panel`, CSS variables — no hardcoded hex.
|
||||
- Purchase tab: staleness badge, outbound links from API `outbound`.
|
||||
- Price History tab: line chart from `points`; empty state when none.
|
||||
- Reserve insertion point for Brief 13 local stores section.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Purchase tab shows DB prices and staleness badge
|
||||
- [ ] Price History tab renders data when points exist; empty state when none
|
||||
- [ ] No placeholder “available soon” copy remains
|
||||
- [ ] Component test verifies staleness badge text
|
||||
- [ ] Tests pass
|
||||
- [ ] No scope expansion
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
Delivers Phase 1 user-facing value on existing tab chrome. Keeps `CardDetailView` thin by extracting tab bodies. Documents insertion point for Phase 2 local stores.
|
||||
|
|
@ -5,6 +5,9 @@ import CardDetailDeckModal from './CardDetailDeckModal';
|
|||
import { ManaCost, ColorIdentity, AdvancedManaCost } from './ManaSymbols';
|
||||
import ManaSymbolSettings from './ManaSymbolSettings';
|
||||
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
|
||||
import CardDetailPurchaseTab from './card-detail/CardDetailPurchaseTab';
|
||||
import CardDetailPriceHistoryTab from './card-detail/CardDetailPriceHistoryTab';
|
||||
|
||||
export default function CardDetailView(props) {
|
||||
const {
|
||||
activeTab,
|
||||
|
|
@ -30,8 +33,16 @@ export default function CardDetailView(props) {
|
|||
isFavorited,
|
||||
loading,
|
||||
manaSymbolSettings,
|
||||
nearbyStores,
|
||||
nearbyStoresError,
|
||||
nearbyStoresLoading,
|
||||
nearbyStoresNoHomeZip,
|
||||
ownedQuantity,
|
||||
particleStyles,
|
||||
priceHistory,
|
||||
priceHistoryLoading,
|
||||
pricing,
|
||||
pricingLoading,
|
||||
quantity,
|
||||
router,
|
||||
selectedCollection,
|
||||
|
|
@ -344,85 +355,24 @@ export default function CardDetailView(props) {
|
|||
)}
|
||||
|
||||
{activeTab === 'price-history' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Price History
|
||||
</h3>
|
||||
|
||||
{/* Price Graph Placeholder */}
|
||||
<div className="glass-panel p-6 rounded-xl">
|
||||
<h4 className="text-lg font-semibold mb-6" style={{ color: 'var(--text-primary)' }}>
|
||||
Price Trend
|
||||
</h4>
|
||||
<div className="h-64 flex items-center justify-center">
|
||||
<p style={{ color: 'var(--text-secondary)' }}>
|
||||
Price history data will be available soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Statistics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Current Price */}
|
||||
<div className="glass-panel p-6 rounded-xl transition-all duration-200 hover:shadow-lg hover:scale-105">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">💰</div>
|
||||
<h5 className="font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Current Price
|
||||
</h5>
|
||||
<div className="text-xl font-bold gradient-text-gold">
|
||||
{formatCurrency(card.current_price || 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CardDetailPriceHistoryTab
|
||||
points={priceHistory}
|
||||
priceHistoryLoading={priceHistoryLoading}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'purchase' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Where to Buy
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<a
|
||||
href={`https://www.tcgplayer.com/search/${card.game.toLowerCase()}/product?productName=${encodeURIComponent(card.name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="glass-panel p-6 rounded-xl transition-all duration-200 hover:shadow-lg hover:scale-105"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🃏</span>
|
||||
<div>
|
||||
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
TCGPlayer
|
||||
</h4>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
View on TCGPlayer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href={`https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card.name + ' ' + card.game)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="glass-panel p-6 rounded-xl transition-all duration-200 hover:shadow-lg hover:scale-105"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🛒</span>
|
||||
<div>
|
||||
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
eBay
|
||||
</h4>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
View on eBay
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<CardDetailPurchaseTab
|
||||
card={card}
|
||||
pricing={pricing}
|
||||
pricingLoading={pricingLoading}
|
||||
formatCurrency={formatCurrency}
|
||||
nearbyStores={nearbyStores}
|
||||
nearbyStoresLoading={nearbyStoresLoading}
|
||||
nearbyStoresError={nearbyStoresError}
|
||||
nearbyStoresNoHomeZip={nearbyStoresNoHomeZip}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export default function CollectionPageView(props) {
|
|||
setShowShareModal,
|
||||
setShowUploadModal,
|
||||
setSortBy,
|
||||
setSortOrder,
|
||||
setViewMode,
|
||||
showDeleteModal,
|
||||
showEditModal,
|
||||
|
|
@ -71,6 +72,7 @@ export default function CollectionPageView(props) {
|
|||
showShareModal,
|
||||
showUploadModal,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
toggleFavorite,
|
||||
togglePublic,
|
||||
user,
|
||||
|
|
@ -344,11 +346,22 @@ export default function CollectionPageView(props) {
|
|||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="input-field"
|
||||
aria-label="Sort cards"
|
||||
>
|
||||
<option>Sort by Name</option>
|
||||
<option>Sort by Price</option>
|
||||
<option>Sort by Rarity</option>
|
||||
<option>Sort by Date Added</option>
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="price">Sort by Price</option>
|
||||
<option value="rarity">Sort by Rarity</option>
|
||||
<option value="date">Sort by Date Added</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value)}
|
||||
className="input-field"
|
||||
aria-label="Sort order"
|
||||
>
|
||||
<option value="asc">Ascending</option>
|
||||
<option value="desc">Descending</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
119
components/card-detail/CardDetailLocalStores.js
Normal file
119
components/card-detail/CardDetailLocalStores.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import Link from 'next/link';
|
||||
|
||||
/**
|
||||
* Nearby game stores subsection for the Purchase tab.
|
||||
*/
|
||||
export default function CardDetailLocalStores({
|
||||
stores = [],
|
||||
loading = false,
|
||||
error = null,
|
||||
noHomeZip = false,
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="glass-panel p-6 rounded-xl" aria-live="polite">
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Loading nearby stores…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (noHomeZip) {
|
||||
return (
|
||||
<div className="glass-panel p-6 rounded-xl">
|
||||
<h4 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Game stores near you
|
||||
</h4>
|
||||
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
Add your home ZIP in Settings to see nearby game and hobby stores.
|
||||
</p>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="text-sm font-medium underline"
|
||||
style={{ color: 'var(--accent-flame)' }}
|
||||
>
|
||||
Set home ZIP in Settings
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="glass-panel p-6 rounded-xl">
|
||||
<h4 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Game stores near you
|
||||
</h4>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-panel p-6 rounded-xl">
|
||||
<h4 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Game stores near you
|
||||
</h4>
|
||||
|
||||
{stores.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
No game or hobby stores found in your area yet. Try again later — store data comes from OpenStreetMap.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-4" aria-label="Nearby game stores">
|
||||
{stores.map((store) => (
|
||||
<li
|
||||
key={store.id ?? `${store.name}-${store.lat}-${store.lng}`}
|
||||
className="p-4 rounded-xl border"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{store.name}
|
||||
</p>
|
||||
{store.address ? (
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{store.address}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{store.distanceMiles} mi away
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-sm mt-2 sm:mt-0">
|
||||
{store.phone ? (
|
||||
<a href={`tel:${store.phone}`} style={{ color: 'var(--accent-flame)' }}>
|
||||
Call {store.phone}
|
||||
</a>
|
||||
) : null}
|
||||
{store.website ? (
|
||||
<a
|
||||
href={store.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent-flame)' }}
|
||||
>
|
||||
Visit website
|
||||
</a>
|
||||
) : null}
|
||||
{store.osmUrl ? (
|
||||
<a
|
||||
href={store.osmUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent-flame)' }}
|
||||
>
|
||||
View on OpenStreetMap
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
components/card-detail/CardDetailPriceHistoryTab.js
Normal file
110
components/card-detail/CardDetailPriceHistoryTab.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
function buildChartGeometry(points, width, height, padding) {
|
||||
if (!points.length) return null;
|
||||
|
||||
const amounts = points.map((point) => point.amount);
|
||||
const min = Math.min(...amounts);
|
||||
const max = Math.max(...amounts);
|
||||
const range = max - min || 1;
|
||||
const innerWidth = width - padding * 2;
|
||||
const innerHeight = height - padding * 2;
|
||||
|
||||
const coordinates = points.map((point, index) => {
|
||||
const x = padding + (index / Math.max(points.length - 1, 1)) * innerWidth;
|
||||
const y = padding + innerHeight - ((point.amount - min) / range) * innerHeight;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const polyline = coordinates.map(({ x, y }) => `${x},${y}`).join(' ');
|
||||
const area = [
|
||||
`${coordinates[0].x},${height - padding}`,
|
||||
...coordinates.map(({ x, y }) => `${x},${y}`),
|
||||
`${coordinates[coordinates.length - 1].x},${height - padding}`,
|
||||
].join(' ');
|
||||
|
||||
return { polyline, area, min, max };
|
||||
}
|
||||
|
||||
export default function CardDetailPriceHistoryTab({
|
||||
points = [],
|
||||
priceHistoryLoading,
|
||||
formatCurrency,
|
||||
}) {
|
||||
const chart = buildChartGeometry(points, 640, 220, 24);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Price History
|
||||
</h3>
|
||||
|
||||
<div className="glass-panel p-6 rounded-xl">
|
||||
<h4 className="text-lg font-semibold mb-6" style={{ color: 'var(--text-primary)' }}>
|
||||
Price Trend
|
||||
</h4>
|
||||
|
||||
{priceHistoryLoading ? (
|
||||
<div className="h-64 flex items-center justify-center">
|
||||
<p style={{ color: 'var(--text-secondary)' }} aria-live="polite">Loading price history…</p>
|
||||
</div>
|
||||
) : points.length === 0 ? (
|
||||
<div className="h-64 flex items-center justify-center text-center px-6">
|
||||
<p style={{ color: 'var(--text-secondary)' }}>
|
||||
No price history yet. Snapshots are recorded during catalog sync — check back after the next update.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<svg
|
||||
viewBox="0 0 640 220"
|
||||
className="w-full h-56"
|
||||
role="img"
|
||||
aria-label="Card price history chart"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="price-history-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--accent-flame)" stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor="var(--accent-flame)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon
|
||||
points={chart.area}
|
||||
fill="url(#price-history-fill)"
|
||||
/>
|
||||
<polyline
|
||||
points={chart.polyline}
|
||||
fill="none"
|
||||
stroke="var(--accent-flame)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex justify-between text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span>{points[0]?.date}</span>
|
||||
<span>
|
||||
Low {formatCurrency(chart.min)} · High {formatCurrency(chart.max)}
|
||||
</span>
|
||||
<span>{points[points.length - 1]?.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{points.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="glass-panel p-6 rounded-xl">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2" aria-hidden="true">💰</div>
|
||||
<h5 className="font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Latest Price
|
||||
</h5>
|
||||
<div className="text-xl font-bold gradient-text-gold">
|
||||
{formatCurrency(points[points.length - 1].amount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
components/card-detail/CardDetailPurchaseTab.js
Normal file
130
components/card-detail/CardDetailPurchaseTab.js
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
function formatUpdatedLabel(pricesUpdatedAt) {
|
||||
if (!pricesUpdatedAt) return 'Price date unknown';
|
||||
const date = new Date(pricesUpdatedAt);
|
||||
if (Number.isNaN(date.getTime())) return 'Price date unknown';
|
||||
return `Updated ${date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function stalenessBadgeText({ stale, pricesUpdatedAt }) {
|
||||
if (stale) return 'Prices may be outdated';
|
||||
return formatUpdatedLabel(pricesUpdatedAt);
|
||||
}
|
||||
|
||||
import CardDetailLocalStores from './CardDetailLocalStores';
|
||||
|
||||
export default function CardDetailPurchaseTab({
|
||||
card,
|
||||
pricing,
|
||||
pricingLoading,
|
||||
formatCurrency,
|
||||
nearbyStores = [],
|
||||
nearbyStoresLoading = false,
|
||||
nearbyStoresError = null,
|
||||
nearbyStoresNoHomeZip = false,
|
||||
}) {
|
||||
const displayPrice = pricing?.prices?.current ?? pricing?.prices?.market ?? card?.current_price ?? null;
|
||||
const foilPrice = pricing?.prices?.market ?? card?.market_price ?? null;
|
||||
const badgeText = pricing ? stalenessBadgeText(pricing) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Where to Buy
|
||||
</h3>
|
||||
{badgeText && (
|
||||
<span
|
||||
className="px-3 py-1 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
data-testid="pricing-staleness-badge"
|
||||
>
|
||||
{badgeText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-6 rounded-xl">
|
||||
<h4 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Online Price Estimate
|
||||
</h4>
|
||||
{pricingLoading ? (
|
||||
<p style={{ color: 'var(--text-secondary)' }} aria-live="polite">Loading prices…</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>NM / Non-foil</p>
|
||||
<p className="text-2xl font-bold gradient-text-gold">
|
||||
{formatCurrency(displayPrice || 0)}
|
||||
</p>
|
||||
</div>
|
||||
{foilPrice != null && foilPrice !== displayPrice && (
|
||||
<div>
|
||||
<p className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Foil / Market</p>
|
||||
<p className="text-2xl font-bold gradient-text-gold">
|
||||
{formatCurrency(foilPrice)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{pricing?.outbound?.tcgplayer && (
|
||||
<a
|
||||
href={pricing.outbound.tcgplayer}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="glass-panel p-6 rounded-xl transition-all duration-200 hover:shadow-lg hover:scale-105"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl" aria-hidden="true">🃏</span>
|
||||
<div>
|
||||
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
TCGPlayer
|
||||
</h4>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
View on TCGPlayer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
{pricing?.outbound?.ebay && (
|
||||
<a
|
||||
href={pricing.outbound.ebay}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="glass-panel p-6 rounded-xl transition-all duration-200 hover:shadow-lg hover:scale-105"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl" aria-hidden="true">🛒</span>
|
||||
<div>
|
||||
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
eBay
|
||||
</h4>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
View on eBay
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardDetailLocalStores
|
||||
stores={nearbyStores}
|
||||
loading={nearbyStoresLoading}
|
||||
error={nearbyStoresError}
|
||||
noHomeZip={nearbyStoresNoHomeZip}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,8 +15,8 @@
|
|||
> historical effects into the migration history; until then this file
|
||||
> remains the curated reference for the full prod shape.
|
||||
>
|
||||
> **Last reviewed:** 2026-06-14 against `migrations/` HEAD (through
|
||||
> `1781442340000_cleanup-lorcana-orphans.js`).
|
||||
> **Last reviewed:** 2026-09-08 against `migrations/` HEAD (through
|
||||
> `1783000000001_add-pricing-schema.js`).
|
||||
|
||||
## Quick model groups
|
||||
|
||||
|
|
@ -29,6 +29,7 @@
|
|||
| **Collections** | `collections`, `collection_cards`, `collection_permissions`, `collection_activity` | Curated card lists with sharing (UI: **Lists**) |
|
||||
| **Decks** | `decks`, `deck_cards` | Playable deck definitions |
|
||||
| **Scanning** | `card_submissions`, `scan_attempts` | Unknown-card review queue + scan telemetry |
|
||||
| **Pricing** | `card_price_history`, `geo_zip_cache`, `geo_stores_cache` | Daily price snapshots + local store geocode caches |
|
||||
| **Invitations** | `invitations` (referenced; verify) | Pending share requests |
|
||||
|
||||
## Tables
|
||||
|
|
@ -57,6 +58,7 @@
|
|||
| `theme` | `VARCHAR(10)` default `'system'` | `'light' \| 'dark' \| 'system'` |
|
||||
| `language` | `VARCHAR(5)` default `'en'` | |
|
||||
| `is_pending` | `BOOLEAN` default `false` | Set by invitation flow before signup completes |
|
||||
| `home_zip` | `VARCHAR(10)` | US zip for nearby game store discovery (`1783000000001`) |
|
||||
|
||||
### cards
|
||||
|
||||
|
|
@ -74,7 +76,8 @@
|
|||
| `oracle_text` | `TEXT` | LARGE — never `SELECT *` |
|
||||
| `power`, `toughness` | `VARCHAR(10)` | MTG creatures |
|
||||
| `image_url`, `stock_image_url` | `TEXT` | |
|
||||
| `current_price`, `market_price` | `DECIMAL(10,2)` | |
|
||||
| `current_price`, `market_price` | `DECIMAL(10,2)` | MTG: `usd` / `usd_foil` from Scryfall |
|
||||
| `prices_updated_at` | `TIMESTAMPTZ` | Last price ingest timestamp (`1783000000001`) |
|
||||
| `scryfall_id` | `VARCHAR(255) UNIQUE` | External dedupe key (Scryfall UUID, Pokémon TCG id, Lorcana `Unique_ID`) |
|
||||
| `verified` | `BOOLEAN` default `false` | Admin-edited cards |
|
||||
| `quantity` | `INTEGER` default `0` | **Unused; consider dropping — quantity lives in `user_cards`** (`1781442330001_reconcile-cards-columns`) |
|
||||
|
|
@ -284,6 +287,49 @@ Per-scan telemetry for the identify pipeline (layer 0 = visual kNN, layer 1 = Te
|
|||
|
||||
Index: `idx_scan_attempts_user_created (user_id, created_at DESC)`.
|
||||
|
||||
### card_price_history
|
||||
|
||||
Daily price snapshots written during catalog sync (Scryfall, TCGdex, Foilio).
|
||||
|
||||
| Column | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | `SERIAL PK` | |
|
||||
| `card_id` | `INTEGER FK cards(id) ON DELETE CASCADE` | |
|
||||
| `source` | `VARCHAR(32) NOT NULL` | `'scryfall' \| 'tcgdex' \| 'foilio'` |
|
||||
| `price_type` | `VARCHAR(32)` default `'market'` | `'market' \| 'low' \| 'mid' \| 'high'` |
|
||||
| `currency` | `VARCHAR(3)` default `'USD'` | |
|
||||
| `amount` | `DECIMAL(10,2) NOT NULL` | |
|
||||
| `recorded_at` | `DATE` default today | One row per card/source/type/currency/day |
|
||||
| `created_at` | `TIMESTAMPTZ` default now | |
|
||||
|
||||
**Unique:** `(card_id, source, price_type, currency, recorded_at)`
|
||||
**Index:** `idx_card_price_history_card_date (card_id, recorded_at DESC)`
|
||||
|
||||
### geo_zip_cache
|
||||
|
||||
Permanent Nominatim geocode cache (zip → coordinates).
|
||||
|
||||
| Column | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `zip` | `VARCHAR(10) PK` | Normalized 5-digit US zip |
|
||||
| `lat`, `lng` | `DOUBLE PRECISION NOT NULL` | |
|
||||
| `display_name` | `TEXT` | OSM display name |
|
||||
| `geocoded_at` | `TIMESTAMPTZ` default now | |
|
||||
|
||||
### geo_stores_cache
|
||||
|
||||
Overpass query results cache (7-day TTL enforced in application code).
|
||||
|
||||
| Column | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `cache_key` | `VARCHAR(64) PK` | sha256 of `{lat}:{lng}:{radiusMeters}` |
|
||||
| `lat`, `lng` | `DOUBLE PRECISION NOT NULL` | Query center |
|
||||
| `radius_meters` | `INTEGER NOT NULL` | |
|
||||
| `stores` | `JSONB NOT NULL` | Parsed store list |
|
||||
| `fetched_at` | `TIMESTAMPTZ` default now | |
|
||||
|
||||
**Index:** `idx_geo_stores_cache_fetched (fetched_at)`
|
||||
|
||||
### user_settings (split from users.*; verify which is canonical)
|
||||
|
||||
Defined in `add-user-profile-fields.js`. Mirrors several `users.*` columns — there's redundancy that needs to be reconciled.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
*/
|
||||
|
||||
import { sql } from '../sql.js';
|
||||
import { recordPriceSnapshots } from '../pricing/price-history.js';
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
|
@ -23,7 +24,7 @@ async function fetchBulkDownloadUrl() {
|
|||
return response.json();
|
||||
}
|
||||
|
||||
function mapCard(card) {
|
||||
export function mapCard(card) {
|
||||
let currentPrice = null;
|
||||
let marketPrice = null;
|
||||
if (card.prices) {
|
||||
|
|
@ -65,14 +66,64 @@ function mapCard(card) {
|
|||
};
|
||||
}
|
||||
|
||||
function hasPrice(row) {
|
||||
return row.currentPrice != null || row.marketPrice != null;
|
||||
}
|
||||
|
||||
async function resolveCardIds(scryfallIds) {
|
||||
if (scryfallIds.length === 0) return new Map();
|
||||
|
||||
const result = await sql.query(
|
||||
'SELECT id, scryfall_id FROM cards WHERE scryfall_id = ANY($1)',
|
||||
[scryfallIds]
|
||||
);
|
||||
|
||||
return new Map(result.rows.map((row) => [row.scryfall_id, row.id]));
|
||||
}
|
||||
|
||||
async function recordBatchSnapshots(cards) {
|
||||
const pricedCards = cards.filter(hasPrice);
|
||||
if (pricedCards.length === 0) return 0;
|
||||
|
||||
const idByScryfall = await resolveCardIds(pricedCards.map((row) => row.scryfallId));
|
||||
const snapshots = [];
|
||||
|
||||
for (const row of pricedCards) {
|
||||
const cardId = idByScryfall.get(row.scryfallId);
|
||||
if (cardId == null) continue;
|
||||
|
||||
if (row.currentPrice != null) {
|
||||
snapshots.push({
|
||||
cardId,
|
||||
source: 'scryfall',
|
||||
priceType: 'market',
|
||||
currency: 'USD',
|
||||
amount: row.currentPrice,
|
||||
});
|
||||
}
|
||||
|
||||
if (row.marketPrice != null) {
|
||||
snapshots.push({
|
||||
cardId,
|
||||
source: 'scryfall',
|
||||
priceType: 'foil',
|
||||
currency: 'USD',
|
||||
amount: row.marketPrice,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return recordPriceSnapshots(snapshots);
|
||||
}
|
||||
|
||||
async function upsertBatch(cards) {
|
||||
if (cards.length === 0) return 0;
|
||||
if (cards.length === 0) return { upserted: 0, snapshotsWritten: 0 };
|
||||
|
||||
const columns = [
|
||||
'scryfall_id', 'oracle_id', 'illustration_id', 'name', 'set_name', 'set_code',
|
||||
'card_number', 'rarity', 'game', 'mana_cost', 'cmc', 'card_type', 'colors',
|
||||
'color_identity', 'oracle_text', 'power', 'toughness', 'image_url', 'stock_image_url',
|
||||
'current_price', 'market_price', 'keywords', 'legalities', 'flavor_text', 'artist',
|
||||
'current_price', 'market_price', 'prices_updated_at', 'keywords', 'legalities', 'flavor_text', 'artist',
|
||||
'released_at', 'layout', 'edhrec_rank', 'reserved', 'reprint', 'finishes', 'verified',
|
||||
];
|
||||
|
||||
|
|
@ -81,11 +132,12 @@ async function upsertBatch(cards) {
|
|||
let paramIdx = 1;
|
||||
|
||||
for (const row of cards) {
|
||||
const pricesUpdatedAt = hasPrice(row) ? new Date() : null;
|
||||
const rowValues = [
|
||||
row.scryfallId, row.oracleId, row.illustrationId, row.name, row.setName, row.setCode,
|
||||
row.cardNumber, row.rarity, 'MTG', row.manaCost, row.cmc, row.cardType, row.colors,
|
||||
row.colorIdentity, row.oracleText, row.power, row.toughness, row.imageUrl, row.stockImageUrl,
|
||||
row.currentPrice, row.marketPrice, row.keywords, row.legalities, row.flavorText, row.artist,
|
||||
row.currentPrice, row.marketPrice, pricesUpdatedAt, row.keywords, row.legalities, row.flavorText, row.artist,
|
||||
row.releasedAt, row.layout, row.edhrecRank, row.reserved, row.reprint, row.finishes, true,
|
||||
];
|
||||
const rowP = rowValues.map(() => `$${paramIdx++}`);
|
||||
|
|
@ -105,7 +157,9 @@ async function upsertBatch(cards) {
|
|||
values
|
||||
);
|
||||
|
||||
return cards.length;
|
||||
const snapshotsWritten = await recordBatchSnapshots(cards);
|
||||
|
||||
return { upserted: cards.length, snapshotsWritten };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -126,6 +180,7 @@ export async function runBulkMtgSync() {
|
|||
const mtgCards = allCards.filter((c) => c.lang === 'en' && !c.digital);
|
||||
|
||||
let upserted = 0;
|
||||
let snapshotsWritten = 0;
|
||||
let errors = 0;
|
||||
let batch = [];
|
||||
|
||||
|
|
@ -133,7 +188,9 @@ export async function runBulkMtgSync() {
|
|||
batch.push(mapCard(card));
|
||||
if (batch.length >= BATCH_SIZE) {
|
||||
try {
|
||||
upserted += await upsertBatch(batch);
|
||||
const result = await upsertBatch(batch);
|
||||
upserted += result.upserted;
|
||||
snapshotsWritten += result.snapshotsWritten;
|
||||
} catch (err) {
|
||||
console.error('[bulk-sync] Batch error:', err.message);
|
||||
errors += batch.length;
|
||||
|
|
@ -144,7 +201,9 @@ export async function runBulkMtgSync() {
|
|||
|
||||
if (batch.length > 0) {
|
||||
try {
|
||||
upserted += await upsertBatch(batch);
|
||||
const result = await upsertBatch(batch);
|
||||
upserted += result.upserted;
|
||||
snapshotsWritten += result.snapshotsWritten;
|
||||
} catch (err) {
|
||||
console.error('[bulk-sync] Final batch error:', err.message);
|
||||
errors += batch.length;
|
||||
|
|
@ -171,6 +230,7 @@ export async function runBulkMtgSync() {
|
|||
mode: 'bulk',
|
||||
totalInFile: mtgCards.length,
|
||||
upserted,
|
||||
snapshotsWritten,
|
||||
errors,
|
||||
backfilledOracleId: backfill.rowCount || 0,
|
||||
updatedAt: meta.updated_at,
|
||||
|
|
|
|||
|
|
@ -2,17 +2,24 @@
|
|||
* Bulk catalog sync for Disney Lorcana via lorcana-api.com /bulk/cards.
|
||||
*
|
||||
* Single fetch returns all cards (~2.3k). Upserts keyed on scryfall_id (stores Unique_ID).
|
||||
* Enriches prices from the Foilio hourly feed when available.
|
||||
*/
|
||||
|
||||
import { sql as vercelSql } from '../sql.js';
|
||||
import { recordPriceSnapshots } from '../pricing/price-history.js';
|
||||
import { fetchFolioPriceIndex, matchFolioPrice } from '../pricing/foolio-client.js';
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
const BULK_URL = 'https://api.lorcana-api.com/bulk/cards';
|
||||
|
||||
function hasPrice(row) {
|
||||
return row.currentPrice != null || row.marketPrice != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Lorcana bulk API card object to cards-table insert shape.
|
||||
*/
|
||||
export function mapLorcanaCard(card) {
|
||||
export function mapLorcanaCard(card, pricing = null) {
|
||||
const colors = card.Color
|
||||
? card.Color.split(',').map((entry) => entry.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
|
@ -32,7 +39,7 @@ export function mapLorcanaCard(card) {
|
|||
|
||||
const cost = card.Cost != null ? Number(card.Cost) : null;
|
||||
|
||||
return {
|
||||
const row = {
|
||||
scryfallId: card.Unique_ID,
|
||||
name: card.Name,
|
||||
setName: card.Set_Name,
|
||||
|
|
@ -54,14 +61,42 @@ export function mapLorcanaCard(card) {
|
|||
edhrecRank: card.Lore != null ? Number(card.Lore) : null,
|
||||
keywords: JSON.stringify(keywords),
|
||||
};
|
||||
|
||||
if (pricing) {
|
||||
row.currentPrice = pricing.currentPrice;
|
||||
row.marketPrice = pricing.marketPrice;
|
||||
row.priceCurrency = pricing.currency ?? 'USD';
|
||||
if (hasPrice(row)) {
|
||||
row.pricesUpdatedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
export function enrichLorcanaRowWithFolio(row, folioIndex, stats) {
|
||||
const pricing = matchFolioPrice(folioIndex, row);
|
||||
if (!pricing || !hasPrice(pricing)) {
|
||||
if (stats) stats.unmatchedCount += 1;
|
||||
return row;
|
||||
}
|
||||
|
||||
if (stats) stats.pricesMatched += 1;
|
||||
return {
|
||||
...row,
|
||||
currentPrice: pricing.currentPrice,
|
||||
marketPrice: pricing.marketPrice,
|
||||
priceCurrency: pricing.currency,
|
||||
pricesUpdatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUpsertQuery(batch) {
|
||||
const columns = [
|
||||
'scryfall_id', 'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game',
|
||||
'mana_cost', 'cmc', 'card_type', 'colors', 'color_identity', 'oracle_text',
|
||||
'power', 'toughness', 'image_url', 'stock_image_url', 'keywords', 'flavor_text',
|
||||
'artist', 'edhrec_rank', 'verified',
|
||||
'power', 'toughness', 'image_url', 'stock_image_url', 'current_price', 'market_price',
|
||||
'prices_updated_at', 'keywords', 'flavor_text', 'artist', 'edhrec_rank', 'verified',
|
||||
];
|
||||
|
||||
const placeholders = [];
|
||||
|
|
@ -74,6 +109,7 @@ export function buildUpsertQuery(batch) {
|
|||
row.scryfallId, row.name, row.setName, row.setCode, row.cardNumber, row.rarity,
|
||||
'Lorcana', row.manaCost, row.cmc, row.cardType, row.colors, row.colorIdentity,
|
||||
row.oracleText, row.power, row.toughness, row.imageUrl, row.stockImageUrl,
|
||||
row.currentPrice ?? null, row.marketPrice ?? null, row.pricesUpdatedAt ?? null,
|
||||
row.keywords, row.flavorText, row.artist, row.edhrecRank, true,
|
||||
];
|
||||
|
||||
|
|
@ -101,11 +137,70 @@ export function buildUpsertQuery(batch) {
|
|||
return { query, values };
|
||||
}
|
||||
|
||||
async function resolveCardIds(sql, scryfallIds) {
|
||||
if (scryfallIds.length === 0) return new Map();
|
||||
|
||||
const result = await sql.query(
|
||||
'SELECT id, scryfall_id FROM cards WHERE scryfall_id = ANY($1)',
|
||||
[scryfallIds]
|
||||
);
|
||||
|
||||
return new Map(result.rows.map((row) => [row.scryfall_id, row.id]));
|
||||
}
|
||||
|
||||
async function recordBatchSnapshots(sql, batch) {
|
||||
const pricedRows = batch.filter(hasPrice);
|
||||
if (pricedRows.length === 0) return 0;
|
||||
|
||||
const idByScryfall = await resolveCardIds(sql, pricedRows.map((row) => row.scryfallId));
|
||||
const snapshots = [];
|
||||
|
||||
for (const row of pricedRows) {
|
||||
const cardId = idByScryfall.get(row.scryfallId);
|
||||
if (cardId == null) continue;
|
||||
|
||||
const currency = row.priceCurrency ?? 'USD';
|
||||
|
||||
if (row.currentPrice != null) {
|
||||
snapshots.push({
|
||||
cardId,
|
||||
source: 'foilio',
|
||||
priceType: 'market',
|
||||
currency,
|
||||
amount: row.currentPrice,
|
||||
});
|
||||
}
|
||||
|
||||
if (row.marketPrice != null && row.marketPrice !== row.currentPrice) {
|
||||
snapshots.push({
|
||||
cardId,
|
||||
source: 'foilio',
|
||||
priceType: 'market',
|
||||
currency,
|
||||
amount: row.marketPrice,
|
||||
});
|
||||
} else if (row.marketPrice != null && row.currentPrice == null) {
|
||||
snapshots.push({
|
||||
cardId,
|
||||
source: 'foilio',
|
||||
priceType: 'market',
|
||||
currency,
|
||||
amount: row.marketPrice,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return recordPriceSnapshots(snapshots);
|
||||
}
|
||||
|
||||
async function upsertBatch(sql, batch) {
|
||||
if (batch.length === 0) return 0;
|
||||
if (batch.length === 0) return { upserted: 0, snapshotsWritten: 0 };
|
||||
|
||||
const { query, values } = buildUpsertQuery(batch);
|
||||
await sql.query(query, values);
|
||||
return batch.length;
|
||||
const snapshotsWritten = await recordBatchSnapshots(sql, batch);
|
||||
|
||||
return { upserted: batch.length, snapshotsWritten };
|
||||
}
|
||||
|
||||
export async function fetchLorcanaBulkCards() {
|
||||
|
|
@ -121,11 +216,21 @@ export async function fetchLorcanaBulkCards() {
|
|||
/**
|
||||
* Run a full Lorcana bulk sync. Returns summary compatible with catalog sync reporting.
|
||||
*
|
||||
* @param {{ sql?: { query: (q: string, v: unknown[]) => Promise<unknown> }, dryRun?: boolean }} options
|
||||
* @param {{ sql?: { query: (q: string, v: unknown[]) => Promise<unknown> }, dryRun?: boolean, fetchFolioIndex?: () => Promise<{ byCardId: Map<string, unknown>, byNameSet: Map<string, unknown> }> }} options
|
||||
*/
|
||||
export async function runBulkLorcanaSync(options = {}) {
|
||||
const dryRun = options.dryRun ?? false;
|
||||
const sql = options.sql ?? vercelSql;
|
||||
const fetchFolioIndex = options.fetchFolioIndex ?? fetchFolioPriceIndex;
|
||||
|
||||
let folioIndex = { byCardId: new Map(), byNameSet: new Map() };
|
||||
if (!dryRun) {
|
||||
try {
|
||||
folioIndex = await fetchFolioIndex();
|
||||
} catch (error) {
|
||||
console.warn('[lorcana-bulk] Foilio feed fetch failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const allCards = await fetchLorcanaBulkCards();
|
||||
const lorcanaCards = allCards.filter(
|
||||
|
|
@ -133,21 +238,33 @@ export async function runBulkLorcanaSync(options = {}) {
|
|||
);
|
||||
|
||||
let upserted = 0;
|
||||
let snapshotsWritten = 0;
|
||||
let errors = 0;
|
||||
let pricesMatched = 0;
|
||||
let unmatchedCount = 0;
|
||||
let batch = [];
|
||||
|
||||
const matchStats = {
|
||||
pricesMatched: 0,
|
||||
unmatchedCount: 0,
|
||||
};
|
||||
|
||||
for (const card of lorcanaCards) {
|
||||
if (!card.Unique_ID) {
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
batch.push(mapLorcanaCard(card));
|
||||
let mapped = mapLorcanaCard(card);
|
||||
mapped = enrichLorcanaRowWithFolio(mapped, folioIndex, matchStats);
|
||||
batch.push(mapped);
|
||||
|
||||
if (batch.length >= BATCH_SIZE) {
|
||||
try {
|
||||
if (!dryRun) {
|
||||
upserted += await upsertBatch(sql, batch);
|
||||
const result = await upsertBatch(sql, batch);
|
||||
upserted += result.upserted;
|
||||
snapshotsWritten += result.snapshotsWritten;
|
||||
} else {
|
||||
upserted += batch.length;
|
||||
}
|
||||
|
|
@ -162,7 +279,9 @@ export async function runBulkLorcanaSync(options = {}) {
|
|||
if (batch.length > 0) {
|
||||
try {
|
||||
if (!dryRun) {
|
||||
upserted += await upsertBatch(sql, batch);
|
||||
const result = await upsertBatch(sql, batch);
|
||||
upserted += result.upserted;
|
||||
snapshotsWritten += result.snapshotsWritten;
|
||||
} else {
|
||||
upserted += batch.length;
|
||||
}
|
||||
|
|
@ -172,12 +291,18 @@ export async function runBulkLorcanaSync(options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
pricesMatched = matchStats.pricesMatched;
|
||||
unmatchedCount = matchStats.unmatchedCount;
|
||||
|
||||
const summary = {
|
||||
mode: 'bulk',
|
||||
game: 'lorcana',
|
||||
totalInFile: lorcanaCards.length,
|
||||
upserted,
|
||||
snapshotsWritten,
|
||||
errors,
|
||||
pricesMatched,
|
||||
unmatchedCount,
|
||||
};
|
||||
|
||||
console.log('[lorcana-bulk]', JSON.stringify(summary));
|
||||
|
|
|
|||
|
|
@ -3,9 +3,17 @@
|
|||
*
|
||||
* Fetches all sets from sets/en.json, then upserts every card in each set
|
||||
* (keyed on scryfall_id = external card id). Populates legalities from set metadata.
|
||||
* Enriches prices from TCGdex when available.
|
||||
*/
|
||||
|
||||
import { sql } from '../sql.js';
|
||||
import { recordPriceSnapshots } from '../pricing/price-history.js';
|
||||
import {
|
||||
buildTcgdexPriceIndex,
|
||||
fetchSetCards,
|
||||
matchTcgdexCard,
|
||||
parseTcgdexPricing,
|
||||
} from '../pricing/tcgdex-client.js';
|
||||
|
||||
import {
|
||||
fetchPokemonSetCards,
|
||||
|
|
@ -25,8 +33,12 @@ function getSetLegalities(setMeta) {
|
|||
return {};
|
||||
}
|
||||
|
||||
export function mapPokemonBulkRow(mapped, setLegalities) {
|
||||
return {
|
||||
function hasPrice(row) {
|
||||
return row.currentPrice != null || row.marketPrice != null;
|
||||
}
|
||||
|
||||
export function mapPokemonBulkRow(mapped, setLegalities, pricing = null) {
|
||||
const row = {
|
||||
scryfallId: mapped.externalId,
|
||||
name: mapped.name,
|
||||
setName: mapped.setName,
|
||||
|
|
@ -40,14 +52,29 @@ export function mapPokemonBulkRow(mapped, setLegalities) {
|
|||
imageUrl: mapped.imageSmall,
|
||||
stockImageUrl: mapped.imageLarge,
|
||||
legalities: JSON.stringify(setLegalities),
|
||||
currentPrice: null,
|
||||
marketPrice: null,
|
||||
pricesUpdatedAt: null,
|
||||
priceCurrency: 'USD',
|
||||
};
|
||||
|
||||
if (pricing) {
|
||||
row.currentPrice = pricing.currentPrice;
|
||||
row.marketPrice = pricing.marketPrice;
|
||||
row.priceCurrency = pricing.currency ?? 'USD';
|
||||
if (hasPrice(row)) {
|
||||
row.pricesUpdatedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function buildUpsertQuery(batch) {
|
||||
const columns = [
|
||||
'scryfall_id', 'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game',
|
||||
'mana_cost', 'cmc', 'card_type', 'colors', 'oracle_text', 'power', 'toughness',
|
||||
'image_url', 'stock_image_url', 'current_price', 'market_price', 'legalities', 'verified',
|
||||
'image_url', 'stock_image_url', 'current_price', 'market_price', 'prices_updated_at', 'legalities', 'verified',
|
||||
];
|
||||
|
||||
const placeholders = [];
|
||||
|
|
@ -73,8 +100,9 @@ function buildUpsertQuery(batch) {
|
|||
null,
|
||||
row.imageUrl,
|
||||
row.stockImageUrl,
|
||||
null,
|
||||
null,
|
||||
row.currentPrice,
|
||||
row.marketPrice,
|
||||
row.pricesUpdatedAt,
|
||||
row.legalities,
|
||||
true,
|
||||
];
|
||||
|
|
@ -103,6 +131,62 @@ function buildUpsertQuery(batch) {
|
|||
return { query, values };
|
||||
}
|
||||
|
||||
async function resolveCardIds(scryfallIds, queryFn) {
|
||||
if (scryfallIds.length === 0) return new Map();
|
||||
|
||||
const result = await queryFn(
|
||||
'SELECT id, scryfall_id FROM cards WHERE scryfall_id = ANY($1)',
|
||||
[scryfallIds]
|
||||
);
|
||||
|
||||
return new Map(result.rows.map((row) => [row.scryfall_id, row.id]));
|
||||
}
|
||||
|
||||
async function recordBatchSnapshots(batch, queryFn) {
|
||||
const pricedRows = batch.filter(hasPrice);
|
||||
if (pricedRows.length === 0) return 0;
|
||||
|
||||
const idByScryfall = await resolveCardIds(pricedRows.map((row) => row.scryfallId), queryFn);
|
||||
const snapshots = [];
|
||||
|
||||
for (const row of pricedRows) {
|
||||
const cardId = idByScryfall.get(row.scryfallId);
|
||||
if (cardId == null) continue;
|
||||
|
||||
const currency = row.priceCurrency ?? 'USD';
|
||||
|
||||
if (row.currentPrice != null) {
|
||||
snapshots.push({
|
||||
cardId,
|
||||
source: 'tcgdex',
|
||||
priceType: 'market',
|
||||
currency,
|
||||
amount: row.currentPrice,
|
||||
});
|
||||
}
|
||||
|
||||
if (row.marketPrice != null && row.marketPrice !== row.currentPrice) {
|
||||
snapshots.push({
|
||||
cardId,
|
||||
source: 'tcgdex',
|
||||
priceType: 'market',
|
||||
currency,
|
||||
amount: row.marketPrice,
|
||||
});
|
||||
} else if (row.marketPrice != null && row.currentPrice == null) {
|
||||
snapshots.push({
|
||||
cardId,
|
||||
source: 'tcgdex',
|
||||
priceType: 'market',
|
||||
currency,
|
||||
amount: row.marketPrice,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return recordPriceSnapshots(snapshots);
|
||||
}
|
||||
|
||||
async function upsertBatch(batch, queryFn, stats) {
|
||||
if (batch.length === 0) return;
|
||||
|
||||
|
|
@ -115,6 +199,7 @@ async function upsertBatch(batch, queryFn, stats) {
|
|||
try {
|
||||
await queryFn(query, values);
|
||||
stats.upserted += batch.length;
|
||||
stats.snapshotsWritten += await recordBatchSnapshots(batch, queryFn);
|
||||
} catch (error) {
|
||||
console.error(`[pokemon-bulk] Batch failed (${batch.length} rows):`, error.message);
|
||||
for (const row of batch) {
|
||||
|
|
@ -122,6 +207,7 @@ async function upsertBatch(batch, queryFn, stats) {
|
|||
const { query: singleQ, values: singleV } = buildUpsertQuery([row]);
|
||||
await queryFn(singleQ, singleV);
|
||||
stats.upserted += 1;
|
||||
stats.snapshotsWritten += await recordBatchSnapshots([row], queryFn);
|
||||
} catch (singleErr) {
|
||||
console.error(`[pokemon-bulk] Failed: ${row.name} (${row.scryfallId}):`, singleErr.message);
|
||||
stats.errors += 1;
|
||||
|
|
@ -130,13 +216,49 @@ async function upsertBatch(batch, queryFn, stats) {
|
|||
}
|
||||
}
|
||||
|
||||
function enrichRowWithTcgdex(row, tcgIndex, stats) {
|
||||
const tcgCard = matchTcgdexCard(tcgIndex, row);
|
||||
if (!tcgCard) {
|
||||
stats.unmatchedCount += 1;
|
||||
return row;
|
||||
}
|
||||
|
||||
const pricing = parseTcgdexPricing(tcgCard);
|
||||
if (!hasPrice({ currentPrice: pricing.currentPrice, marketPrice: pricing.marketPrice })) {
|
||||
stats.unmatchedCount += 1;
|
||||
return row;
|
||||
}
|
||||
|
||||
stats.pricesMatched += 1;
|
||||
return {
|
||||
...row,
|
||||
currentPrice: pricing.currentPrice,
|
||||
marketPrice: pricing.marketPrice,
|
||||
priceCurrency: pricing.currency,
|
||||
pricesUpdatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertSetCards(setMeta, cards, options, stats) {
|
||||
const setLegalities = getSetLegalities(setMeta);
|
||||
let tcgIndex = { byNumber: new Map(), byName: new Map() };
|
||||
|
||||
if (!stats.dryRun && options.fetchTcgdexSetCards) {
|
||||
try {
|
||||
const tcgCards = await options.fetchTcgdexSetCards(setMeta.id);
|
||||
tcgIndex = buildTcgdexPriceIndex(tcgCards);
|
||||
} catch (error) {
|
||||
console.warn(`[pokemon-bulk] TCGdex fetch failed for ${setMeta.id}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
let batch = [];
|
||||
|
||||
for (const card of cards) {
|
||||
const mapped = mapGithubCardForInsert(card, setMeta);
|
||||
batch.push(mapPokemonBulkRow(mapped, setLegalities));
|
||||
let row = mapPokemonBulkRow(mapped, setLegalities);
|
||||
row = enrichRowWithTcgdex(row, tcgIndex, stats);
|
||||
batch.push(row);
|
||||
|
||||
if (batch.length >= options.batchSize) {
|
||||
await upsertBatch(batch, options.queryFn, stats);
|
||||
|
|
@ -158,6 +280,7 @@ export async function runBulkPokemonSync(options = {}) {
|
|||
const dryRun = options.dryRun ?? process.env.DRY_RUN === 'true';
|
||||
const setDelayMs = options.setDelayMs ?? DEFAULT_SET_DELAY_MS;
|
||||
const queryFn = options.query ?? ((query, values) => sql.query(query, values));
|
||||
const fetchTcgdexSetCards = options.fetchTcgdexSetCards ?? fetchSetCards;
|
||||
|
||||
const sets = await fetchPokemonSets();
|
||||
console.log(`[pokemon-bulk] Found ${sets.length} sets in pokemon-tcg-data`);
|
||||
|
|
@ -168,6 +291,9 @@ export async function runBulkPokemonSync(options = {}) {
|
|||
skipped: 0,
|
||||
errors: 0,
|
||||
setsProcessed: 0,
|
||||
pricesMatched: 0,
|
||||
unmatchedCount: 0,
|
||||
snapshotsWritten: 0,
|
||||
};
|
||||
|
||||
for (let index = 0; index < sets.length; index += 1) {
|
||||
|
|
@ -186,7 +312,7 @@ export async function runBulkPokemonSync(options = {}) {
|
|||
if (cards.length === 0) {
|
||||
console.log(`[pokemon-bulk] Set ${setCode}: no cards (empty or missing file)`);
|
||||
} else {
|
||||
await upsertSetCards(setMeta, cards, { batchSize, queryFn }, stats);
|
||||
await upsertSetCards(setMeta, cards, { batchSize, queryFn, fetchTcgdexSetCards }, stats);
|
||||
console.log(`[pokemon-bulk] Set ${setCode}: processed ${cards.length} cards`);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -207,6 +333,9 @@ export async function runBulkPokemonSync(options = {}) {
|
|||
upserted: stats.upserted,
|
||||
skipped: stats.skipped,
|
||||
errors: stats.errors,
|
||||
pricesMatched: stats.pricesMatched,
|
||||
unmatchedCount: stats.unmatchedCount,
|
||||
snapshotsWritten: stats.snapshotsWritten,
|
||||
dryRun,
|
||||
};
|
||||
|
||||
|
|
|
|||
31
lib/pricing/card-price.js
Normal file
31
lib/pricing/card-price.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/** Hours after which a stored price is considered stale for live refresh. */
|
||||
export const PRICE_STALE_HOURS = 24;
|
||||
|
||||
/**
|
||||
* Resolve the unit price for a owned card row (foil-aware).
|
||||
* MTG: current_price = nonfoil USD, market_price = foil USD (Scryfall mapping).
|
||||
*
|
||||
* @param {{ currentPrice?: number | null, marketPrice?: number | null, isFoil?: boolean }} params
|
||||
* @returns {number | null}
|
||||
*/
|
||||
export function resolveUnitPrice({ currentPrice, marketPrice, isFoil = false }) {
|
||||
const current = currentPrice != null ? Number(currentPrice) : null;
|
||||
const market = marketPrice != null ? Number(marketPrice) : null;
|
||||
|
||||
if (isFoil) {
|
||||
return market ?? current ?? null;
|
||||
}
|
||||
return current ?? market ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date | string | null | undefined} pricesUpdatedAt
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPriceStale(pricesUpdatedAt) {
|
||||
if (!pricesUpdatedAt) return true;
|
||||
const updated = pricesUpdatedAt instanceof Date ? pricesUpdatedAt : new Date(pricesUpdatedAt);
|
||||
if (Number.isNaN(updated.getTime())) return true;
|
||||
const ageMs = Date.now() - updated.getTime();
|
||||
return ageMs > PRICE_STALE_HOURS * 60 * 60 * 1000;
|
||||
}
|
||||
19
lib/pricing/collection-valuation.js
Normal file
19
lib/pricing/collection-valuation.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* SQL expression for foil-aware unit price on user-owned rows.
|
||||
*
|
||||
* @param {{ cardAlias?: string, userCardAlias?: string }} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function effectiveUnitPriceExpr({ cardAlias = 'c', userCardAlias = 'uc' } = {}) {
|
||||
return `CASE WHEN ${userCardAlias}.is_foil THEN ${cardAlias}.market_price ELSE COALESCE(${cardAlias}.current_price, ${cardAlias}.market_price) END`;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL expression for list/catalog unit price (no per-row foil flag on collection_cards).
|
||||
*
|
||||
* @param {{ cardAlias?: string }} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function listUnitPriceExpr({ cardAlias = 'c' } = {}) {
|
||||
return `COALESCE(${cardAlias}.current_price, ${cardAlias}.market_price)`;
|
||||
}
|
||||
120
lib/pricing/foolio-client.js
Normal file
120
lib/pricing/foolio-client.js
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
const DEFAULT_FOILIO_FEED_URL = 'https://foilio.net/api/prices/feed?game=lorcana';
|
||||
|
||||
export function getFolioFeedUrl() {
|
||||
return process.env.FOILIO_PRICES_FEED_URL ?? DEFAULT_FOILIO_FEED_URL;
|
||||
}
|
||||
|
||||
export function normalizeFolioName(name) {
|
||||
return String(name ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function parseAmount(value) {
|
||||
if (value == null || value === '') return null;
|
||||
const amount = Number(value);
|
||||
return Number.isFinite(amount) ? amount : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Foilio feed row into current/market prices.
|
||||
* Prefer USD fields; fall back to EUR with currency noted for snapshots.
|
||||
*/
|
||||
export function parseFolioPriceEntry(entry) {
|
||||
const usdLow =
|
||||
parseAmount(entry.usd_low)
|
||||
?? parseAmount(entry.low_usd)
|
||||
?? parseAmount(entry.price_usd)
|
||||
?? parseAmount(entry.usd);
|
||||
const usdMarket =
|
||||
parseAmount(entry.usd_market)
|
||||
?? parseAmount(entry.market_usd)
|
||||
?? parseAmount(entry.market)
|
||||
?? parseAmount(entry.price);
|
||||
|
||||
if (usdLow != null || usdMarket != null) {
|
||||
return {
|
||||
currentPrice: usdLow ?? usdMarket,
|
||||
marketPrice: usdMarket ?? usdLow,
|
||||
currency: 'USD',
|
||||
};
|
||||
}
|
||||
|
||||
const eurLow = parseAmount(entry.eur_low) ?? parseAmount(entry.low_eur);
|
||||
const eurMarket = parseAmount(entry.eur_market) ?? parseAmount(entry.eur) ?? parseAmount(entry.price_eur);
|
||||
if (eurLow != null || eurMarket != null) {
|
||||
return {
|
||||
currentPrice: eurLow ?? eurMarket,
|
||||
marketPrice: eurMarket ?? eurLow,
|
||||
currency: 'EUR',
|
||||
};
|
||||
}
|
||||
|
||||
return { currentPrice: null, marketPrice: null, currency: 'USD' };
|
||||
}
|
||||
|
||||
function feedEntries(payload) {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(payload?.prices)) return payload.prices;
|
||||
if (Array.isArray(payload?.cards)) return payload.cards;
|
||||
if (Array.isArray(payload?.data)) return payload.data;
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build lookup indexes from a Foilio feed payload.
|
||||
*/
|
||||
export function buildFolioPriceIndex(payload) {
|
||||
const byCardId = new Map();
|
||||
const byNameSet = new Map();
|
||||
|
||||
for (const entry of feedEntries(payload)) {
|
||||
const parsed = parseFolioPriceEntry(entry);
|
||||
if (parsed.currentPrice == null && parsed.marketPrice == null) continue;
|
||||
|
||||
const cardId = entry.card_id ?? entry.cardId ?? entry.id ?? entry.unique_id ?? entry.Unique_ID;
|
||||
if (cardId) {
|
||||
byCardId.set(String(cardId), parsed);
|
||||
}
|
||||
|
||||
const setCode = entry.set ?? entry.set_id ?? entry.setId ?? entry.Set_ID;
|
||||
const name = entry.name ?? entry.Name;
|
||||
if (setCode && name) {
|
||||
const key = `${normalizeFolioName(name)}|${String(setCode).trim()}`;
|
||||
if (!byNameSet.has(key)) {
|
||||
byNameSet.set(key, parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { byCardId, byNameSet };
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a Lorcana catalog row against the Foilio price index.
|
||||
*/
|
||||
export function matchFolioPrice(index, row) {
|
||||
const byId = index.byCardId.get(String(row.scryfallId));
|
||||
if (byId) return byId;
|
||||
|
||||
const key = `${normalizeFolioName(row.name)}|${String(row.setCode ?? '').trim()}`;
|
||||
return index.byNameSet.get(key) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the Foilio Lorcana price feed once per sync run.
|
||||
*/
|
||||
export async function fetchFolioPriceFeed() {
|
||||
const response = await fetch(getFolioFeedUrl(), {
|
||||
headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Foilio feed error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchFolioPriceIndex() {
|
||||
const payload = await fetchFolioPriceFeed();
|
||||
return buildFolioPriceIndex(payload);
|
||||
}
|
||||
110
lib/pricing/geo-cache.js
Normal file
110
lib/pricing/geo-cache.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { sql } from '../sql.js';
|
||||
import { geocodeUsZip } from './nominatim.js';
|
||||
import { fetchGameStores } from './overpass.js';
|
||||
|
||||
export const STORES_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* @param {number} lat
|
||||
* @param {number} lng
|
||||
* @param {number} radiusMeters
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildStoresCacheKey(lat, lng, radiusMeters) {
|
||||
const payload = `${lat.toFixed(5)}:${lng.toFixed(5)}:${radiusMeters}`;
|
||||
return createHash('sha256').update(payload).digest('hex').slice(0, 64);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date | string | null | undefined} fetchedAt
|
||||
* @param {number} [ttlMs]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isStoresCacheFresh(fetchedAt, ttlMs = STORES_CACHE_TTL_MS) {
|
||||
if (!fetchedAt) return false;
|
||||
const ts = fetchedAt instanceof Date ? fetchedAt.getTime() : new Date(fetchedAt).getTime();
|
||||
if (!Number.isFinite(ts)) return false;
|
||||
return Date.now() - ts < ttlMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} zip
|
||||
* @param {{ fetchImpl?: typeof fetch }} [options]
|
||||
* @returns {Promise<{ lat: number, lng: number, displayName: string, cached: boolean } | null>}
|
||||
*/
|
||||
export async function getZipCoords(zip, options = {}) {
|
||||
const cached = await sql`
|
||||
SELECT lat, lng, display_name
|
||||
FROM geo_zip_cache
|
||||
WHERE zip = ${zip}
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (cached.rows.length > 0) {
|
||||
const row = cached.rows[0];
|
||||
return {
|
||||
lat: Number(row.lat),
|
||||
lng: Number(row.lng),
|
||||
displayName: row.display_name ?? zip,
|
||||
cached: true,
|
||||
};
|
||||
}
|
||||
|
||||
const geocoded = await geocodeUsZip(zip, options);
|
||||
if (!geocoded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await sql`
|
||||
INSERT INTO geo_zip_cache (zip, lat, lng, display_name)
|
||||
VALUES (${zip}, ${geocoded.lat}, ${geocoded.lng}, ${geocoded.displayName})
|
||||
ON CONFLICT (zip) DO UPDATE SET
|
||||
lat = EXCLUDED.lat,
|
||||
lng = EXCLUDED.lng,
|
||||
display_name = EXCLUDED.display_name,
|
||||
geocoded_at = CURRENT_TIMESTAMP
|
||||
`;
|
||||
|
||||
return { ...geocoded, cached: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} lat
|
||||
* @param {number} lng
|
||||
* @param {number} radiusMeters
|
||||
* @param {{ fetchImpl?: typeof fetch, forceRefresh?: boolean }} [options]
|
||||
* @returns {Promise<{ stores: unknown[], cached: boolean }>}
|
||||
*/
|
||||
export async function getStoresNear(lat, lng, radiusMeters, options = {}) {
|
||||
const cacheKey = buildStoresCacheKey(lat, lng, radiusMeters);
|
||||
|
||||
if (!options.forceRefresh) {
|
||||
const cached = await sql`
|
||||
SELECT stores, fetched_at
|
||||
FROM geo_stores_cache
|
||||
WHERE cache_key = ${cacheKey}
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (cached.rows.length > 0 && isStoresCacheFresh(cached.rows[0].fetched_at)) {
|
||||
return { stores: cached.rows[0].stores, cached: true };
|
||||
}
|
||||
}
|
||||
|
||||
const stores = await fetchGameStores(lat, lng, radiusMeters, options);
|
||||
|
||||
await sql`
|
||||
INSERT INTO geo_stores_cache (cache_key, lat, lng, radius_meters, stores, fetched_at)
|
||||
VALUES (${cacheKey}, ${lat}, ${lng}, ${radiusMeters}, ${JSON.stringify(stores)}::jsonb, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (cache_key) DO UPDATE SET
|
||||
lat = EXCLUDED.lat,
|
||||
lng = EXCLUDED.lng,
|
||||
radius_meters = EXCLUDED.radius_meters,
|
||||
stores = EXCLUDED.stores,
|
||||
fetched_at = CURRENT_TIMESTAMP
|
||||
`;
|
||||
|
||||
return { stores, cached: false };
|
||||
}
|
||||
69
lib/pricing/nominatim.js
Normal file
69
lib/pricing/nominatim.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
const DEFAULT_NOMINATIM_BASE = 'https://nominatim.openstreetmap.org';
|
||||
const MIN_REQUEST_INTERVAL_MS = 1000;
|
||||
|
||||
const ALLOWED_HOSTS = new Set(['nominatim.openstreetmap.org']);
|
||||
|
||||
let lastRequestAt = 0;
|
||||
|
||||
function assertAllowedHost(url) {
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
if (!ALLOWED_HOSTS.has(host)) {
|
||||
throw new Error(`[nominatim] Host not allowlisted: ${host}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getUserAgent() {
|
||||
return process.env.NOMINATIM_USER_AGENT ?? 'DeckHearth/1.0';
|
||||
}
|
||||
|
||||
async function throttle() {
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastRequestAt;
|
||||
if (elapsed < MIN_REQUEST_INTERVAL_MS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, MIN_REQUEST_INTERVAL_MS - elapsed));
|
||||
}
|
||||
lastRequestAt = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} zip Five-digit US zip code.
|
||||
* @param {{ fetchImpl?: typeof fetch }} [options]
|
||||
* @returns {Promise<{ lat: number, lng: number, displayName: string } | null>}
|
||||
*/
|
||||
export async function geocodeUsZip(zip, { fetchImpl = fetch } = {}) {
|
||||
const base = process.env.NOMINATIM_BASE_URL ?? DEFAULT_NOMINATIM_BASE;
|
||||
const url = new URL('/search', base);
|
||||
url.searchParams.set('postalcode', zip);
|
||||
url.searchParams.set('country', 'US');
|
||||
url.searchParams.set('format', 'json');
|
||||
url.searchParams.set('limit', '1');
|
||||
|
||||
assertAllowedHost(url.toString());
|
||||
await throttle();
|
||||
|
||||
const response = await fetchImpl(url.toString(), {
|
||||
headers: { 'User-Agent': getUserAgent() },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`[nominatim] HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const results = await response.json();
|
||||
if (!Array.isArray(results) || results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hit = results[0];
|
||||
const lat = Number(hit.lat);
|
||||
const lng = Number(hit.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
lat,
|
||||
lng,
|
||||
displayName: hit.display_name ?? `${zip}, US`,
|
||||
};
|
||||
}
|
||||
32
lib/pricing/outbound-links.js
Normal file
32
lib/pricing/outbound-links.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* @param {{ name?: string, game?: string, set_name?: string, setName?: string }} card
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildTcgplayerUrl(card) {
|
||||
const name = card.name || '';
|
||||
const game = (card.game || 'mtg').toLowerCase();
|
||||
return `https://www.tcgplayer.com/search/${game}/product?productName=${encodeURIComponent(name)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ name?: string, game?: string, set_name?: string, setName?: string }} card
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildEbayUrl(card) {
|
||||
const name = card.name || '';
|
||||
const game = card.game || '';
|
||||
const setName = card.set_name || card.setName || '';
|
||||
const query = [name, setName, game].filter(Boolean).join(' ');
|
||||
return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ name?: string, game?: string, set_name?: string, setName?: string }} card
|
||||
* @returns {{ tcgplayer: string, ebay: string }}
|
||||
*/
|
||||
export function buildOutboundLinks(card) {
|
||||
return {
|
||||
tcgplayer: buildTcgplayerUrl(card),
|
||||
ebay: buildEbayUrl(card),
|
||||
};
|
||||
}
|
||||
178
lib/pricing/overpass.js
Normal file
178
lib/pricing/overpass.js
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
const DEFAULT_OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
|
||||
const METERS_PER_MILE = 1609.344;
|
||||
const MIN_RADIUS_MILES = 1;
|
||||
const MAX_RADIUS_MILES = 50;
|
||||
const DEFAULT_RADIUS_MILES = 25;
|
||||
|
||||
const ALLOWED_HOSTS = new Set(['overpass-api.de', 'overpass.kumi.systems']);
|
||||
|
||||
function assertAllowedHost(url) {
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
if (!ALLOWED_HOSTS.has(host)) {
|
||||
throw new Error(`[overpass] Host not allowlisted: ${host}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} miles
|
||||
* @returns {number}
|
||||
*/
|
||||
export function clampRadiusMiles(miles) {
|
||||
const value = Number(miles);
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_RADIUS_MILES;
|
||||
}
|
||||
return Math.min(MAX_RADIUS_MILES, Math.max(MIN_RADIUS_MILES, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} miles
|
||||
* @returns {number}
|
||||
*/
|
||||
export function milesToMeters(miles) {
|
||||
return Math.round(clampRadiusMiles(miles) * METERS_PER_MILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Overpass QL from validated numeric inputs only — never user strings.
|
||||
* @param {{ lat: number, lng: number, radiusMeters: number }} params
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildOverpassQuery({ lat, lng, radiusMeters }) {
|
||||
const safeLat = Number(lat);
|
||||
const safeLng = Number(lng);
|
||||
const safeRadius = Math.round(Number(radiusMeters));
|
||||
|
||||
if (!Number.isFinite(safeLat) || !Number.isFinite(safeLng) || !Number.isFinite(safeRadius)) {
|
||||
throw new Error('[overpass] Invalid numeric query parameters');
|
||||
}
|
||||
if (safeRadius < 1 || safeRadius > Math.round(MAX_RADIUS_MILES * METERS_PER_MILE)) {
|
||||
throw new Error('[overpass] Radius out of bounds');
|
||||
}
|
||||
|
||||
return `[out:json][timeout:25];
|
||||
(
|
||||
node["shop"="games"](around:${safeRadius},${safeLat},${safeLng});
|
||||
node["shop"="hobby"](around:${safeRadius},${safeLat},${safeLng});
|
||||
way["shop"="games"](around:${safeRadius},${safeLat},${safeLng});
|
||||
way["shop"="hobby"](around:${safeRadius},${safeLat},${safeLng});
|
||||
);
|
||||
out center tags;`;
|
||||
}
|
||||
|
||||
function formatAddress(tags) {
|
||||
const parts = [
|
||||
tags['addr:housenumber'],
|
||||
tags['addr:street'],
|
||||
tags['addr:city'],
|
||||
tags['addr:state'],
|
||||
tags['addr:postcode'],
|
||||
].filter(Boolean);
|
||||
if (parts.length > 0) {
|
||||
return parts.join(', ');
|
||||
}
|
||||
return tags['addr:full'] ?? '';
|
||||
}
|
||||
|
||||
function elementCoords(element) {
|
||||
if (element.type === 'node' && element.lat != null && element.lon != null) {
|
||||
return { lat: Number(element.lat), lng: Number(element.lon) };
|
||||
}
|
||||
if (element.center?.lat != null && element.center?.lon != null) {
|
||||
return { lat: Number(element.center.lat), lng: Number(element.center.lon) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function osmUrl(element) {
|
||||
const type = element.type === 'node' ? 'node' : 'way';
|
||||
return `https://www.openstreetmap.org/${type}/${element.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} data
|
||||
* @param {{ originLat: number, originLng: number }} origin
|
||||
* @returns {Array<{
|
||||
* id: string,
|
||||
* name: string,
|
||||
* address: string,
|
||||
* lat: number,
|
||||
* lng: number,
|
||||
* distanceMiles: number,
|
||||
* phone: string | null,
|
||||
* website: string | null,
|
||||
* osmUrl: string,
|
||||
* }>}
|
||||
*/
|
||||
export function parseOverpassResponse(data, { originLat, originLng }) {
|
||||
const elements = data?.elements;
|
||||
if (!Array.isArray(elements)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stores = [];
|
||||
|
||||
for (const element of elements) {
|
||||
const tags = element.tags ?? {};
|
||||
const name = tags.name;
|
||||
if (!name) continue;
|
||||
|
||||
const coords = elementCoords(element);
|
||||
if (!coords || !Number.isFinite(coords.lat) || !Number.isFinite(coords.lng)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
stores.push({
|
||||
id: `${element.type}/${element.id}`,
|
||||
name,
|
||||
address: formatAddress(tags),
|
||||
lat: coords.lat,
|
||||
lng: coords.lng,
|
||||
distanceMiles: haversineMiles(originLat, originLng, coords.lat, coords.lng),
|
||||
phone: tags.phone ?? tags['contact:phone'] ?? null,
|
||||
website: tags.website ?? tags['contact:website'] ?? null,
|
||||
osmUrl: osmUrl(element),
|
||||
});
|
||||
}
|
||||
|
||||
stores.sort((a, b) => a.distanceMiles - b.distanceMiles);
|
||||
return stores;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} lat
|
||||
* @param {number} lng
|
||||
* @param {number} radiusMeters
|
||||
* @param {{ fetchImpl?: typeof fetch }} [options]
|
||||
* @returns {Promise<ReturnType<typeof parseOverpassResponse>>}
|
||||
*/
|
||||
export async function fetchGameStores(lat, lng, radiusMeters, { fetchImpl = fetch } = {}) {
|
||||
const endpoint = process.env.OVERPASS_URL ?? DEFAULT_OVERPASS_URL;
|
||||
assertAllowedHost(endpoint);
|
||||
|
||||
const query = buildOverpassQuery({ lat, lng, radiusMeters });
|
||||
const response = await fetchImpl(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`[overpass] HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return parseOverpassResponse(data, { originLat: lat, originLng: lng });
|
||||
}
|
||||
|
||||
function haversineMiles(lat1, lng1, lat2, lng2) {
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
const earthRadiusMiles = 3958.8;
|
||||
return Math.round(c * earthRadiusMiles * 10) / 10;
|
||||
}
|
||||
78
lib/pricing/price-history.js
Normal file
78
lib/pricing/price-history.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { sql } from '../sql.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} PriceSnapshotRow
|
||||
* @property {number} cardId
|
||||
* @property {string} source
|
||||
* @property {string} [priceType]
|
||||
* @property {string} [currency]
|
||||
* @property {number} amount
|
||||
* @property {string | Date} [recordedAt]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Upsert daily price snapshots (idempotent per card/source/type/currency/day).
|
||||
*
|
||||
* @param {PriceSnapshotRow[]} rows
|
||||
* @returns {Promise<number>} rows written
|
||||
*/
|
||||
export async function recordPriceSnapshots(rows) {
|
||||
if (!rows?.length) return 0;
|
||||
|
||||
let written = 0;
|
||||
for (const row of rows) {
|
||||
if (row.cardId == null || row.amount == null || Number.isNaN(Number(row.amount))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const priceType = row.priceType ?? 'market';
|
||||
const currency = row.currency ?? 'USD';
|
||||
const recordedAt =
|
||||
row.recordedAt instanceof Date
|
||||
? row.recordedAt.toISOString().slice(0, 10)
|
||||
: row.recordedAt ?? new Date().toISOString().slice(0, 10);
|
||||
|
||||
await sql`
|
||||
INSERT INTO card_price_history (card_id, source, price_type, currency, amount, recorded_at)
|
||||
VALUES (
|
||||
${row.cardId},
|
||||
${row.source},
|
||||
${priceType},
|
||||
${currency},
|
||||
${Number(row.amount)},
|
||||
${recordedAt}
|
||||
)
|
||||
ON CONFLICT (card_id, source, price_type, currency, recorded_at)
|
||||
DO UPDATE SET amount = EXCLUDED.amount
|
||||
`;
|
||||
written += 1;
|
||||
}
|
||||
|
||||
return written;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} cardId
|
||||
* @param {{ days?: number }} [options]
|
||||
* @returns {Promise<Array<{ date: string, amount: number, source: string, priceType: string, currency: string }>>}
|
||||
*/
|
||||
export async function getPriceHistoryPoints(cardId, { days = 90 } = {}) {
|
||||
const cappedDays = Math.min(Math.max(Number(days) || 90, 1), 365);
|
||||
const result = await sql`
|
||||
SELECT recorded_at, amount, source, price_type, currency
|
||||
FROM card_price_history
|
||||
WHERE card_id = ${cardId}
|
||||
AND recorded_at >= CURRENT_DATE - ${cappedDays}::integer
|
||||
ORDER BY recorded_at ASC
|
||||
`;
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
date: row.recorded_at instanceof Date
|
||||
? row.recorded_at.toISOString().slice(0, 10)
|
||||
: String(row.recorded_at).slice(0, 10),
|
||||
amount: parseFloat(row.amount),
|
||||
source: row.source,
|
||||
priceType: row.price_type,
|
||||
currency: row.currency,
|
||||
}));
|
||||
}
|
||||
256
lib/pricing/pricing-service.js
Normal file
256
lib/pricing/pricing-service.js
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import Redis from 'ioredis';
|
||||
import { sql } from '../sql.js';
|
||||
import { isPriceStale } from './card-price.js';
|
||||
import { buildOutboundLinks } from './outbound-links.js';
|
||||
import { recordPriceSnapshots } from './price-history.js';
|
||||
import { getTcgdexBaseUrl, parseTcgdexPricing } from './tcgdex-client.js';
|
||||
import { fetchFolioPriceIndex, matchFolioPrice } from './foolio-client.js';
|
||||
|
||||
const REFRESH_LOCK_TTL_SEC = 60;
|
||||
const REFRESH_LOCK_PREFIX = 'deckhearth:pricing-refresh:';
|
||||
|
||||
let redisClient = null;
|
||||
|
||||
function getRedis() {
|
||||
const url = process.env.REDIS_URL;
|
||||
if (!url) return null;
|
||||
if (!redisClient) {
|
||||
redisClient = new Redis(url, {
|
||||
maxRetriesPerRequest: 1,
|
||||
enableOfflineQueue: false,
|
||||
});
|
||||
}
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} cardId
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function acquireRefreshLock(cardId) {
|
||||
const redis = getRedis();
|
||||
if (!redis) return true;
|
||||
try {
|
||||
const result = await redis.set(
|
||||
`${REFRESH_LOCK_PREFIX}${cardId}`,
|
||||
'1',
|
||||
'EX',
|
||||
REFRESH_LOCK_TTL_SEC,
|
||||
'NX'
|
||||
);
|
||||
return result === 'OK';
|
||||
} catch (err) {
|
||||
console.error('[pricing-service] lock acquire failed', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} cardId
|
||||
*/
|
||||
async function releaseRefreshLock(cardId) {
|
||||
const redis = getRedis();
|
||||
if (!redis) return;
|
||||
try {
|
||||
await redis.del(`${REFRESH_LOCK_PREFIX}${cardId}`);
|
||||
} catch (err) {
|
||||
console.error('[pricing-service] lock release failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} scryfallId
|
||||
*/
|
||||
async function fetchScryfallPrices(scryfallId) {
|
||||
const response = await fetch(`https://api.scryfall.com/cards/${scryfallId}`, {
|
||||
headers: { 'User-Agent': 'DeckHearth/1.0', Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Scryfall API error: ${response.status}`);
|
||||
}
|
||||
const card = await response.json();
|
||||
let currentPrice = null;
|
||||
let marketPrice = null;
|
||||
if (card.prices) {
|
||||
currentPrice = card.prices.usd ? parseFloat(card.prices.usd) : null;
|
||||
marketPrice = card.prices.usd_foil ? parseFloat(card.prices.usd_foil) : null;
|
||||
}
|
||||
return { currentPrice, marketPrice, currency: 'USD' };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ set_code?: string, card_number?: string, name?: string }} card
|
||||
*/
|
||||
async function fetchPokemonPrices(card) {
|
||||
const setCode = card.set_code;
|
||||
if (!setCode) return null;
|
||||
|
||||
const base = getTcgdexBaseUrl();
|
||||
if (!card.card_number) return null;
|
||||
|
||||
const paths = [
|
||||
`/cards/${encodeURIComponent(setCode)}-${encodeURIComponent(card.card_number)}`,
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' },
|
||||
});
|
||||
if (!response.ok) continue;
|
||||
const tcgCard = await response.json();
|
||||
const parsed = parseTcgdexPricing(tcgCard);
|
||||
if (parsed.currentPrice != null || parsed.marketPrice != null) {
|
||||
return {
|
||||
currentPrice: parsed.currentPrice,
|
||||
marketPrice: parsed.marketPrice,
|
||||
currency: parsed.currency ?? 'USD',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// try next path
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ name?: string, set_code?: string, scryfall_id?: string }} card
|
||||
*/
|
||||
async function fetchLorcanaPrices(card) {
|
||||
const index = await fetchFolioPriceIndex();
|
||||
const matched = matchFolioPrice(index, {
|
||||
name: card.name,
|
||||
setCode: card.set_code,
|
||||
scryfallId: card.scryfall_id,
|
||||
});
|
||||
if (!matched) return null;
|
||||
return {
|
||||
currentPrice: matched.currentPrice,
|
||||
marketPrice: matched.marketPrice,
|
||||
currency: matched.currency ?? 'USD',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} card
|
||||
*/
|
||||
async function refreshLivePrices(card) {
|
||||
const game = String(card.game || '').toLowerCase();
|
||||
let prices = null;
|
||||
|
||||
if (game === 'mtg' && card.scryfall_id) {
|
||||
prices = await fetchScryfallPrices(card.scryfall_id);
|
||||
} else if (game === 'pokemon') {
|
||||
prices = await fetchPokemonPrices(card);
|
||||
} else if (game === 'lorcana') {
|
||||
prices = await fetchLorcanaPrices(card);
|
||||
}
|
||||
|
||||
if (!prices || (prices.currentPrice == null && prices.marketPrice == null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await sql`
|
||||
UPDATE cards
|
||||
SET
|
||||
current_price = ${prices.currentPrice},
|
||||
market_price = ${prices.marketPrice},
|
||||
prices_updated_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ${card.id}
|
||||
`;
|
||||
|
||||
const snapshots = [];
|
||||
if (prices.currentPrice != null) {
|
||||
snapshots.push({
|
||||
cardId: card.id,
|
||||
source: game === 'mtg' ? 'scryfall' : game === 'pokemon' ? 'tcgdex' : 'foilio',
|
||||
priceType: 'market',
|
||||
currency: prices.currency ?? 'USD',
|
||||
amount: prices.currentPrice,
|
||||
});
|
||||
}
|
||||
if (prices.marketPrice != null && prices.marketPrice !== prices.currentPrice) {
|
||||
snapshots.push({
|
||||
cardId: card.id,
|
||||
source: game === 'mtg' ? 'scryfall' : game === 'pokemon' ? 'tcgdex' : 'foilio',
|
||||
priceType: 'foil',
|
||||
currency: prices.currency ?? 'USD',
|
||||
amount: prices.marketPrice,
|
||||
});
|
||||
}
|
||||
if (snapshots.length) {
|
||||
await recordPriceSnapshots(snapshots);
|
||||
}
|
||||
|
||||
return prices;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} cardId
|
||||
* @returns {Promise<null | {
|
||||
* prices: { current: number | null, market: number | null, currency: string },
|
||||
* pricesUpdatedAt: string | null,
|
||||
* stale: boolean,
|
||||
* outbound: { tcgplayer: string, ebay: string },
|
||||
* }>}
|
||||
*/
|
||||
export async function getCardPricing(cardId) {
|
||||
const result = await sql`
|
||||
SELECT
|
||||
id, name, game, set_name, set_code, card_number,
|
||||
current_price, market_price, prices_updated_at, scryfall_id
|
||||
FROM cards
|
||||
WHERE id = ${cardId}
|
||||
`;
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let card = result.rows[0];
|
||||
let stale = isPriceStale(card.prices_updated_at);
|
||||
|
||||
if (stale) {
|
||||
const gotLock = await acquireRefreshLock(cardId);
|
||||
if (gotLock) {
|
||||
try {
|
||||
await refreshLivePrices(card).catch((err) => {
|
||||
console.error('[pricing-service] live refresh failed', err);
|
||||
});
|
||||
|
||||
const refreshed = await sql`
|
||||
SELECT
|
||||
id, name, game, set_name, set_code, card_number,
|
||||
current_price, market_price, prices_updated_at, scryfall_id
|
||||
FROM cards
|
||||
WHERE id = ${cardId}
|
||||
`;
|
||||
if (refreshed.rows.length) {
|
||||
card = refreshed.rows[0];
|
||||
stale = isPriceStale(card.prices_updated_at);
|
||||
}
|
||||
} finally {
|
||||
await releaseRefreshLock(cardId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const current = card.current_price != null ? parseFloat(card.current_price) : null;
|
||||
const market = card.market_price != null ? parseFloat(card.market_price) : null;
|
||||
|
||||
return {
|
||||
prices: {
|
||||
current: Number.isFinite(current) ? current : null,
|
||||
market: Number.isFinite(market) ? market : null,
|
||||
currency: 'USD',
|
||||
},
|
||||
pricesUpdatedAt: card.prices_updated_at
|
||||
? new Date(card.prices_updated_at).toISOString()
|
||||
: null,
|
||||
stale,
|
||||
outbound: buildOutboundLinks(card),
|
||||
};
|
||||
}
|
||||
162
lib/pricing/tcgdex-client.js
Normal file
162
lib/pricing/tcgdex-client.js
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
const DEFAULT_TCGDEX_BASE_URL = 'https://api.tcgdex.net/v2/en';
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export function getTcgdexBaseUrl() {
|
||||
return process.env.TCGDEX_BASE_URL ?? DEFAULT_TCGDEX_BASE_URL;
|
||||
}
|
||||
|
||||
function tcgplayerVariant(tcgplayer) {
|
||||
if (!tcgplayer || typeof tcgplayer !== 'object') return null;
|
||||
|
||||
const preferredKeys = ['normal', 'holofoil', 'reverse', 'reverse-holofoil', 'unlimited', '1st-edition'];
|
||||
for (const key of preferredKeys) {
|
||||
const variant = tcgplayer[key];
|
||||
if (variant && typeof variant === 'object') {
|
||||
return variant;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(tcgplayer).find(
|
||||
(value) => value && typeof value === 'object' && ('marketPrice' in value || 'lowPrice' in value)
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse TCGdex pricing into Deck Hearth card price fields.
|
||||
* USD from TCGPlayer; Cardmarket EUR fills market_price only when USD is missing.
|
||||
*/
|
||||
export function parseTcgdexPricing(card) {
|
||||
let currentPrice = null;
|
||||
let marketPrice = null;
|
||||
let currency = 'USD';
|
||||
|
||||
const variant = tcgplayerVariant(card.pricing?.tcgplayer);
|
||||
if (variant) {
|
||||
const low = variant.lowPrice ?? variant.directLowPrice;
|
||||
const market = variant.marketPrice ?? variant.midPrice;
|
||||
currentPrice = low != null ? Number(low) : (market != null ? Number(market) : null);
|
||||
marketPrice = market != null ? Number(market) : currentPrice;
|
||||
}
|
||||
|
||||
// Cardmarket (EUR) — use for market_price only when TCGPlayer USD is unavailable.
|
||||
if (currentPrice == null && marketPrice == null) {
|
||||
const cardmarket = card.pricing?.cardmarket;
|
||||
const eur = cardmarket?.trend ?? cardmarket?.avg ?? cardmarket?.low;
|
||||
if (eur != null) {
|
||||
marketPrice = Number(eur);
|
||||
currency = 'EUR';
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPrice != null && Number.isNaN(currentPrice)) currentPrice = null;
|
||||
if (marketPrice != null && Number.isNaN(marketPrice)) marketPrice = null;
|
||||
|
||||
return { currentPrice, marketPrice, currency };
|
||||
}
|
||||
|
||||
export function normalizeTcgdexName(name) {
|
||||
return String(name ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function cardNumberKeys(card) {
|
||||
const keys = new Set();
|
||||
const localId = card.localId ?? card.id?.split('-').pop();
|
||||
if (localId != null && localId !== '') {
|
||||
keys.add(String(localId).trim().toLowerCase());
|
||||
const official = card.set?.cardCount?.official;
|
||||
if (official) {
|
||||
keys.add(`${String(parseInt(localId, 10))}/${official}`.toLowerCase());
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build lookup indexes for matching DB rows by set_code + card_number or name.
|
||||
*/
|
||||
export function buildTcgdexPriceIndex(cards) {
|
||||
const byNumber = new Map();
|
||||
const byName = new Map();
|
||||
|
||||
for (const card of cards) {
|
||||
for (const key of cardNumberKeys(card)) {
|
||||
if (!byNumber.has(key)) {
|
||||
byNumber.set(key, card);
|
||||
}
|
||||
}
|
||||
|
||||
const nameKey = normalizeTcgdexName(card.name);
|
||||
if (!byName.has(nameKey)) {
|
||||
byName.set(nameKey, []);
|
||||
}
|
||||
byName.get(nameKey).push(card);
|
||||
}
|
||||
|
||||
return { byNumber, byName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a catalog row to a TCGdex card within a set.
|
||||
*/
|
||||
export function matchTcgdexCard(index, row) {
|
||||
const cardNumber = String(row.cardNumber ?? '').trim().toLowerCase();
|
||||
if (cardNumber) {
|
||||
const direct = index.byNumber.get(cardNumber);
|
||||
if (direct) return direct;
|
||||
|
||||
const slashIndex = cardNumber.indexOf('/');
|
||||
if (slashIndex !== -1) {
|
||||
const numerator = String(parseInt(cardNumber.slice(0, slashIndex), 10));
|
||||
if (numerator !== 'NaN') {
|
||||
const byNumerator = index.byNumber.get(numerator);
|
||||
if (byNumerator) return byNumerator;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nameKey = normalizeTcgdexName(row.name);
|
||||
const candidates = index.byName.get(nameKey) ?? [];
|
||||
return candidates.length === 1 ? candidates[0] : null;
|
||||
}
|
||||
|
||||
async function fetchTcgdexJson(path) {
|
||||
const response = await fetch(`${getTcgdexBaseUrl()}${path}`, {
|
||||
headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`TCGdex API error: ${response.status} for ${path}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all cards for a Pokémon set, including pricing when available.
|
||||
*/
|
||||
export async function fetchSetCards(setId) {
|
||||
const setData = await fetchTcgdexJson(`/sets/${encodeURIComponent(setId)}`);
|
||||
const summaries = Array.isArray(setData?.cards) ? setData.cards : [];
|
||||
|
||||
const cards = [];
|
||||
for (const summary of summaries) {
|
||||
if (!summary?.id) continue;
|
||||
|
||||
if (summary.pricing) {
|
||||
cards.push(summary);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const fullCard = await fetchTcgdexJson(`/cards/${encodeURIComponent(summary.id)}`);
|
||||
cards.push(fullCard);
|
||||
} catch {
|
||||
cards.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
export { delay };
|
||||
|
|
@ -9,6 +9,8 @@ const LIMITER_CONFIG = {
|
|||
import: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:import' },
|
||||
// One camera verify may escalate L0→L2; vision path is the expensive step.
|
||||
scan: { limit: 15, durationSec: 60, prefix: 'deckhearth:scan' },
|
||||
stores: { limit: 1, durationSec: 60 * 60, prefix: 'deckhearth:stores' },
|
||||
storesIp: { limit: 10, durationSec: 60 * 60, prefix: 'deckhearth:stores-ip' },
|
||||
};
|
||||
|
||||
let cached = null;
|
||||
|
|
@ -122,3 +124,11 @@ export async function checkImportRateLimit(req, userId) {
|
|||
export async function checkScanRateLimit(req, userId) {
|
||||
return check('scan', extractUserIdentifier(userId));
|
||||
}
|
||||
|
||||
export async function checkStoresRateLimit(req, userId) {
|
||||
const userResult = await check('stores', extractUserIdentifier(userId));
|
||||
if (!userResult.allowed) {
|
||||
return userResult;
|
||||
}
|
||||
return check('storesIp', extractIpIdentifier(req));
|
||||
}
|
||||
|
|
|
|||
17
lib/sql.js
17
lib/sql.js
|
|
@ -65,6 +65,23 @@ export async function sql(strings, ...values) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameterized raw SQL for bulk import upserts (legacy `$1` placeholders).
|
||||
* @param {string} text
|
||||
* @param {unknown[]} [values]
|
||||
*/
|
||||
export async function sqlQuery(text, values = []) {
|
||||
const pg = getClient();
|
||||
const result = await pg.unsafe(text, values);
|
||||
const rows = Array.from(result);
|
||||
return {
|
||||
rows,
|
||||
rowCount: typeof result.count === 'number' ? result.count : rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
sql.query = sqlQuery;
|
||||
|
||||
/** Close the pooled client (tests / long-running scripts). */
|
||||
export async function closeSqlPool() {
|
||||
if (client) {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ export function useCardDetail({ user = null, authLoading = true } = {}) {
|
|||
const [decks, setDecks] = useState([]);
|
||||
const [cardCollections, setCardCollections] = useState([]);
|
||||
const [cardDecks, setCardDecks] = useState([]);
|
||||
const [pricing, setPricing] = useState(null);
|
||||
const [pricingLoading, setPricingLoading] = useState(false);
|
||||
const [priceHistory, setPriceHistory] = useState([]);
|
||||
const [priceHistoryLoading, setPriceHistoryLoading] = useState(false);
|
||||
const [nearbyStores, setNearbyStores] = useState([]);
|
||||
const [nearbyStoresLoading, setNearbyStoresLoading] = useState(false);
|
||||
const [nearbyStoresError, setNearbyStoresError] = useState(null);
|
||||
const [nearbyStoresNoHomeZip, setNearbyStoresNoHomeZip] = useState(false);
|
||||
|
||||
// Mana symbol settings
|
||||
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
||||
|
|
@ -82,6 +90,55 @@ export function useCardDetail({ user = null, authLoading = true } = {}) {
|
|||
|
||||
fetchCard();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || activeTab !== 'purchase') return;
|
||||
let cancelled = false;
|
||||
|
||||
const fetchPricing = async () => {
|
||||
setPricingLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/cards/${id}/pricing`);
|
||||
if (response.ok && !cancelled) {
|
||||
setPricing(await response.json());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching card pricing:', error);
|
||||
} finally {
|
||||
if (!cancelled) setPricingLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPricing();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || activeTab !== 'price-history') return;
|
||||
let cancelled = false;
|
||||
|
||||
const fetchHistory = async () => {
|
||||
setPriceHistoryLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/cards/${id}/price-history?days=90`);
|
||||
if (response.ok && !cancelled) {
|
||||
const data = await response.json();
|
||||
setPriceHistory(data.points || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching price history:', error);
|
||||
} finally {
|
||||
if (!cancelled) setPriceHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchHistory();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, activeTab]);
|
||||
|
||||
// Fetch user's collections and decks
|
||||
useEffect(() => {
|
||||
|
|
@ -129,6 +186,67 @@ export function useCardDetail({ user = null, authLoading = true } = {}) {
|
|||
fetchUserData();
|
||||
}
|
||||
}, [card, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'purchase' || !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const fetchNearbyStores = async () => {
|
||||
setNearbyStoresLoading(true);
|
||||
setNearbyStoresError(null);
|
||||
setNearbyStoresNoHomeZip(false);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) {
|
||||
setNearbyStores([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/stores/nearby', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (response.status === 400) {
|
||||
const data = await response.json();
|
||||
if (data.code === 'NO_HOME_ZIP') {
|
||||
setNearbyStores([]);
|
||||
setNearbyStoresNoHomeZip(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
setNearbyStores([]);
|
||||
setNearbyStoresError(data.error || 'Could not load nearby stores');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setNearbyStores(data.stores ?? []);
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error('Error fetching nearby stores:', error);
|
||||
setNearbyStoresError('Could not load nearby stores');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setNearbyStoresLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchNearbyStores();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeTab, user]);
|
||||
|
||||
const getRarityGradient = (rarity) => {
|
||||
const rarityKey = rarity?.toLowerCase();
|
||||
|
|
@ -369,9 +487,17 @@ export function useCardDetail({ user = null, authLoading = true } = {}) {
|
|||
isFavorited,
|
||||
loading,
|
||||
manaSymbolSettings,
|
||||
nearbyStores,
|
||||
nearbyStoresError,
|
||||
nearbyStoresLoading,
|
||||
nearbyStoresNoHomeZip,
|
||||
ownedQuantity,
|
||||
particleStyles,
|
||||
getParticleCount,
|
||||
priceHistory,
|
||||
priceHistoryLoading,
|
||||
pricing,
|
||||
pricingLoading,
|
||||
quantity,
|
||||
router,
|
||||
selectedCollection,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
|
|||
const [selectedRarity, setSelectedRarity] = useState('All Rarities');
|
||||
const [selectedType, setSelectedType] = useState('All Types');
|
||||
const [groupBy, setGroupBy] = useState('Group by Game');
|
||||
const [sortBy, setSortBy] = useState('Sort by Name');
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [viewMode, setViewMode] = useState('grid');
|
||||
const [searchCards, setSearchCards] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
|
|
@ -118,11 +119,14 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
|
|||
tags: Array.isArray(data.tags) ? data.tags : (data.tags ? data.tags.split(',') : [])
|
||||
});
|
||||
|
||||
const sortParams = new URLSearchParams({ sort: sortBy, order: sortOrder });
|
||||
const cardsUrl = `/api/collections/${identifier}/cards?${sortParams}`;
|
||||
|
||||
// Fetch collection cards (try public first, then authenticated)
|
||||
let cardsResponse = await fetch(`/api/collections/${identifier}/cards`);
|
||||
let cardsResponse = await fetch(cardsUrl);
|
||||
|
||||
if (!cardsResponse.ok && user) {
|
||||
cardsResponse = await fetch(`/api/collections/${identifier}/cards`, {
|
||||
cardsResponse = await fetch(cardsUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||
}
|
||||
|
|
@ -160,8 +164,8 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
|
|||
loadFavoritedCards();
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when list slug or auth changes
|
||||
}, [identifier, user]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when list slug, auth, or sort changes
|
||||
}, [identifier, user, sortBy, sortOrder]);
|
||||
|
||||
const handleEditCollection = async () => {
|
||||
try {
|
||||
|
|
@ -488,6 +492,7 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
|
|||
setShowShareModal,
|
||||
setShowUploadModal,
|
||||
setSortBy,
|
||||
setSortOrder,
|
||||
setViewMode,
|
||||
showDeleteModal,
|
||||
showEditModal,
|
||||
|
|
@ -496,6 +501,7 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
|
|||
showShareModal,
|
||||
showUploadModal,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
toggleFavorite,
|
||||
togglePublic,
|
||||
viewMode
|
||||
|
|
|
|||
64
migrations/1783000000001_add-pricing-schema.js
Normal file
64
migrations/1783000000001_add-pricing-schema.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Pricing convoy schema: price timestamps, history snapshots, home zip, geo caches.
|
||||
*
|
||||
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
||||
*/
|
||||
export const shorthands = undefined;
|
||||
|
||||
/**
|
||||
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||
*/
|
||||
export const up = (pgm) => {
|
||||
pgm.sql(`
|
||||
ALTER TABLE cards
|
||||
ADD COLUMN IF NOT EXISTS prices_updated_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS card_price_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
card_id INTEGER NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
price_type VARCHAR(32) NOT NULL DEFAULT 'market',
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
recorded_at DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (card_id, source, price_type, currency, recorded_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_card_price_history_card_date
|
||||
ON card_price_history (card_id, recorded_at DESC);
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS home_zip VARCHAR(10);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS geo_zip_cache (
|
||||
zip VARCHAR(10) PRIMARY KEY,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lng DOUBLE PRECISION NOT NULL,
|
||||
display_name TEXT,
|
||||
geocoded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS geo_stores_cache (
|
||||
cache_key VARCHAR(64) PRIMARY KEY,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lng DOUBLE PRECISION NOT NULL,
|
||||
radius_meters INTEGER NOT NULL,
|
||||
stores JSONB NOT NULL,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_geo_stores_cache_fetched
|
||||
ON geo_stores_cache (fetched_at);
|
||||
`);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||
*/
|
||||
export const down = (pgm) => {
|
||||
throw new Error(
|
||||
'[migration:1783000000001_add-pricing-schema] Refusing to drop pricing schema. ' +
|
||||
'Rolling back would erase card_price_history snapshots and geo caches.'
|
||||
);
|
||||
};
|
||||
|
|
@ -10,7 +10,7 @@ export default async function handler(req, res) {
|
|||
id, name, set_name, set_code, card_number, rarity, game,
|
||||
mana_cost, cmc, card_type, colors, oracle_text,
|
||||
power, toughness, image_url, stock_image_url,
|
||||
current_price, market_price, scryfall_id, verified,
|
||||
current_price, market_price, prices_updated_at, scryfall_id, verified,
|
||||
quantity
|
||||
FROM cards
|
||||
WHERE id = ${id}
|
||||
|
|
|
|||
32
pages/api/cards/[id]/price-history.js
Normal file
32
pages/api/cards/[id]/price-history.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { sql } from '../../../../lib/sql.js';
|
||||
import { getPriceHistoryPoints } from '../../../../lib/pricing/price-history.js';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const cardId = parseInt(req.query.id, 10);
|
||||
if (!req.query.id || Number.isNaN(cardId) || cardId <= 0) {
|
||||
return res.status(400).json({ error: 'Valid card ID is required' });
|
||||
}
|
||||
|
||||
const daysRaw = req.query.days ?? '90';
|
||||
const days = parseInt(daysRaw, 10);
|
||||
if (Number.isNaN(days) || days < 1 || days > 365) {
|
||||
return res.status(400).json({ error: 'days must be between 1 and 365' });
|
||||
}
|
||||
|
||||
const cardCheck = await sql`SELECT id FROM cards WHERE id = ${cardId}`;
|
||||
if (cardCheck.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Card not found' });
|
||||
}
|
||||
|
||||
const points = await getPriceHistoryPoints(cardId, { days });
|
||||
return res.status(200).json({ points });
|
||||
} catch (err) {
|
||||
console.error('[GET /api/cards/[id]/price-history]', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
24
pages/api/cards/[id]/pricing.js
Normal file
24
pages/api/cards/[id]/pricing.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { getCardPricing } from '../../../../lib/pricing/pricing-service.js';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const cardId = parseInt(req.query.id, 10);
|
||||
if (!req.query.id || Number.isNaN(cardId) || cardId <= 0) {
|
||||
return res.status(400).json({ error: 'Valid card ID is required' });
|
||||
}
|
||||
|
||||
const pricing = await getCardPricing(cardId);
|
||||
if (!pricing) {
|
||||
return res.status(404).json({ error: 'Card not found' });
|
||||
}
|
||||
|
||||
return res.status(200).json(pricing);
|
||||
} catch (err) {
|
||||
console.error('[GET /api/cards/[id]/pricing]', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,27 @@
|
|||
import { sql } from '../../../lib/sql.js';
|
||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||
import { listUnitPriceExpr } from '../../../lib/pricing/collection-valuation.js';
|
||||
|
||||
const VALID_SORTS = new Set(['name', 'price', 'rarity', 'date']);
|
||||
const VALID_ORDERS = new Set(['asc', 'desc']);
|
||||
|
||||
function buildSortClause(sort, order) {
|
||||
const dir = order === 'desc' ? 'DESC' : 'ASC';
|
||||
const nulls = 'NULLS LAST';
|
||||
const priceExpr = listUnitPriceExpr({ cardAlias: 'c' });
|
||||
|
||||
switch (sort) {
|
||||
case 'price':
|
||||
return `ORDER BY ${priceExpr} ${dir} ${nulls}, c.name ASC`;
|
||||
case 'rarity':
|
||||
return `ORDER BY c.rarity ${dir} ${nulls}, c.name ASC`;
|
||||
case 'date':
|
||||
return `ORDER BY uc.created_at ${dir} ${nulls}, c.name ASC`;
|
||||
case 'name':
|
||||
default:
|
||||
return `ORDER BY c.name ${dir} ${nulls}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'GET') {
|
||||
|
|
@ -20,9 +42,15 @@ export default async function handler(req, res) {
|
|||
set = 'all',
|
||||
valueRange = 'all',
|
||||
page = '1',
|
||||
limit = '50'
|
||||
limit = '50',
|
||||
sort: sortParam = 'name',
|
||||
order: orderParam = 'asc',
|
||||
} = req.query;
|
||||
|
||||
const sort = VALID_SORTS.has(sortParam) ? sortParam : 'name';
|
||||
const order = VALID_ORDERS.has(orderParam) ? orderParam : 'asc';
|
||||
const orderClause = buildSortClause(sort, order);
|
||||
|
||||
const pageNum = parseInt(page) || 1;
|
||||
const limitNum = parseInt(limit) || 50;
|
||||
const offset = (pageNum - 1) * limitNum;
|
||||
|
|
@ -57,6 +85,7 @@ export default async function handler(req, res) {
|
|||
}
|
||||
|
||||
if (valueRange !== 'all') {
|
||||
const priceExpr = listUnitPriceExpr({ cardAlias: 'c' });
|
||||
// Handle value range filtering
|
||||
const ranges = {
|
||||
'under-1': [0, 1],
|
||||
|
|
@ -69,11 +98,11 @@ export default async function handler(req, res) {
|
|||
if (ranges[valueRange]) {
|
||||
const [min, max] = ranges[valueRange];
|
||||
if (valueRange === 'over-100') {
|
||||
whereConditions.push(`c.market_price >= $${paramIndex}`);
|
||||
whereConditions.push(`${priceExpr} >= $${paramIndex}`);
|
||||
params.push(min);
|
||||
paramIndex++;
|
||||
} else {
|
||||
whereConditions.push(`c.market_price >= $${paramIndex} AND c.market_price <= $${paramIndex + 1}`);
|
||||
whereConditions.push(`${priceExpr} >= $${paramIndex} AND ${priceExpr} <= $${paramIndex + 1}`);
|
||||
params.push(min, max);
|
||||
paramIndex += 2;
|
||||
}
|
||||
|
|
@ -95,7 +124,7 @@ export default async function handler(req, res) {
|
|||
FROM cards c
|
||||
INNER JOIN user_cards uc ON c.id = uc.card_id
|
||||
WHERE ${whereClause}
|
||||
ORDER BY c.name ASC
|
||||
${orderClause}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { sql } from '../../../lib/sql.js';
|
||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||
import { isValidSlug } from '../../../lib/slug-utils';
|
||||
import { listUnitPriceExpr } from '../../../lib/pricing/collection-valuation.js';
|
||||
|
||||
const LIST_UNIT_PRICE_EXPR = listUnitPriceExpr({ cardAlias: 'cards' });
|
||||
const LIST_VALUED_QUANTITY = sql([`${LIST_UNIT_PRICE_EXPR} * cc.quantity`]);
|
||||
|
||||
export default async function handler(req, res) {
|
||||
try {
|
||||
|
|
@ -24,7 +28,7 @@ export default async function handler(req, res) {
|
|||
c.*,
|
||||
u.email as creator_email,
|
||||
COUNT(cc.card_id) as card_count,
|
||||
COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value,
|
||||
COALESCE(SUM(${LIST_VALUED_QUANTITY}), 0) as total_value,
|
||||
${user ? sql`cp.role as user_role,
|
||||
CASE
|
||||
WHEN c.user_id = ${user.userId} THEN 'owner'
|
||||
|
|
@ -51,7 +55,7 @@ export default async function handler(req, res) {
|
|||
c.*,
|
||||
u.email as creator_email,
|
||||
COUNT(cc.card_id) as card_count,
|
||||
COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value,
|
||||
COALESCE(SUM(${LIST_VALUED_QUANTITY}), 0) as total_value,
|
||||
${user ? sql`cp.role as user_role,
|
||||
CASE
|
||||
WHEN c.user_id = ${user.userId} THEN 'owner'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,28 @@
|
|||
import { sql } from '../../../../lib/sql.js';
|
||||
import { getUserFromRequest, logCollectionActivity } from '../../../../lib/permission-middleware';
|
||||
import { isValidSlug } from '../../../../lib/slug-utils';
|
||||
import { listUnitPriceExpr } from '../../../../lib/pricing/collection-valuation.js';
|
||||
|
||||
const VALID_SORTS = new Set(['name', 'price', 'rarity', 'date']);
|
||||
const VALID_ORDERS = new Set(['asc', 'desc']);
|
||||
|
||||
function buildSortClause(sort, order) {
|
||||
const dir = order === 'desc' ? 'DESC' : 'ASC';
|
||||
const nulls = 'NULLS LAST';
|
||||
const priceExpr = listUnitPriceExpr({ cardAlias: 'cards' });
|
||||
|
||||
switch (sort) {
|
||||
case 'price':
|
||||
return `ORDER BY ${priceExpr} ${dir} ${nulls}, cards.name ASC`;
|
||||
case 'rarity':
|
||||
return `ORDER BY cards.rarity ${dir} ${nulls}, cards.name ASC`;
|
||||
case 'date':
|
||||
return `ORDER BY cc.created_at ${dir} ${nulls}, cards.name ASC`;
|
||||
case 'name':
|
||||
default:
|
||||
return `ORDER BY cards.name ${dir} ${nulls}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
try {
|
||||
|
|
@ -50,17 +72,25 @@ export default async function handler(req, res) {
|
|||
const collection = collectionResult.rows[0];
|
||||
|
||||
if (req.method === 'GET') {
|
||||
// Get all cards in the collection
|
||||
const cardsResult = await sql`
|
||||
const sortParam = req.query.sort ?? 'name';
|
||||
const orderParam = req.query.order ?? 'asc';
|
||||
const sort = VALID_SORTS.has(sortParam) ? sortParam : 'name';
|
||||
const order = VALID_ORDERS.has(orderParam) ? orderParam : 'asc';
|
||||
const orderClause = buildSortClause(sort, order);
|
||||
|
||||
const cardsResult = await sql.query(
|
||||
`
|
||||
SELECT
|
||||
cards.*,
|
||||
cc.quantity,
|
||||
cc.created_at as added_at
|
||||
FROM collection_cards cc
|
||||
JOIN cards ON cc.card_id = cards.id
|
||||
WHERE cc.collection_id = ${collection.id}
|
||||
ORDER BY cc.created_at DESC
|
||||
`;
|
||||
WHERE cc.collection_id = $1
|
||||
${orderClause}
|
||||
`,
|
||||
[collection.id]
|
||||
);
|
||||
|
||||
const cards = cardsResult.rows.map(card => ({
|
||||
id: card.id,
|
||||
|
|
|
|||
73
pages/api/stores/nearby.js
Normal file
73
pages/api/stores/nearby.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { sql } from '../../../lib/sql.js';
|
||||
import { getUserFromRequest } from '../../../lib/permission-middleware.js';
|
||||
import { checkStoresRateLimit } from '../../../lib/rate-limit.js';
|
||||
import { getZipCoords, getStoresNear } from '../../../lib/pricing/geo-cache.js';
|
||||
import { clampRadiusMiles, milesToMeters } from '../../../lib/pricing/overpass.js';
|
||||
|
||||
function isLocalPricingEnabled() {
|
||||
const flag = process.env.PRICING_LOCAL_ENABLED;
|
||||
return flag === undefined || flag === '' || flag === 'true';
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
if (!isLocalPricingEnabled()) {
|
||||
return res.status(503).json({ error: 'Local store discovery is disabled' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const rateLimit = await checkStoresRateLimit(req, user.userId);
|
||||
if (!rateLimit.allowed) {
|
||||
const retryAfterSec = Math.max(1, Math.ceil((rateLimit.reset - Date.now()) / 1000));
|
||||
res.setHeader('Retry-After', String(retryAfterSec));
|
||||
return res.status(429).json({ error: 'Rate limit exceeded' });
|
||||
}
|
||||
|
||||
const userRow = await sql`
|
||||
SELECT home_zip
|
||||
FROM users
|
||||
WHERE id = ${user.userId}
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (userRow.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const homeZip = userRow.rows[0].home_zip;
|
||||
if (!homeZip) {
|
||||
return res.status(400).json({ error: 'Home ZIP not configured', code: 'NO_HOME_ZIP' });
|
||||
}
|
||||
|
||||
const radiusMiles = clampRadiusMiles(
|
||||
req.query.radiusMiles != null ? Number(req.query.radiusMiles) : undefined
|
||||
);
|
||||
const radiusMeters = milesToMeters(radiusMiles);
|
||||
|
||||
const coords = await getZipCoords(homeZip);
|
||||
if (!coords) {
|
||||
return res.status(404).json({ error: 'Could not geocode home ZIP' });
|
||||
}
|
||||
|
||||
const { stores, cached } = await getStoresNear(coords.lat, coords.lng, radiusMeters);
|
||||
|
||||
return res.status(200).json({
|
||||
homeZip,
|
||||
radiusMiles,
|
||||
stores,
|
||||
cached,
|
||||
coordsCached: coords.cached,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[stores/nearby]', error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ export default async function handler(req, res) {
|
|||
id, email, role, first_name, last_name, username,
|
||||
collection_visibility, preferred_currency, cards_per_page,
|
||||
default_view, notifications_email, notifications_marketing,
|
||||
two_factor_enabled, theme, language,
|
||||
two_factor_enabled, theme, language, home_zip,
|
||||
created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = ${user.userId}
|
||||
|
|
@ -54,7 +54,10 @@ export default async function handler(req, res) {
|
|||
|
||||
// Display Settings
|
||||
theme: userData.theme || 'system',
|
||||
language: userData.language || 'en'
|
||||
language: userData.language || 'en',
|
||||
|
||||
// Local stores
|
||||
home_zip: userData.home_zip ?? null,
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -71,7 +74,8 @@ export default async function handler(req, res) {
|
|||
notifications_email,
|
||||
notifications_marketing,
|
||||
theme,
|
||||
language
|
||||
language,
|
||||
home_zip
|
||||
} = req.body;
|
||||
|
||||
// Validate enum values
|
||||
|
|
@ -101,6 +105,9 @@ export default async function handler(req, res) {
|
|||
if (language && !validLanguage.includes(language)) {
|
||||
return res.status(400).json({ error: 'Invalid language' });
|
||||
}
|
||||
if (home_zip !== undefined && home_zip !== null && !/^\d{5}$/.test(String(home_zip))) {
|
||||
return res.status(400).json({ error: 'Invalid home ZIP — use a 5-digit US zip code' });
|
||||
}
|
||||
|
||||
// Build update query dynamically
|
||||
const updateFields = [];
|
||||
|
|
@ -152,6 +159,11 @@ export default async function handler(req, res) {
|
|||
updateValues.push(language);
|
||||
paramIndex++;
|
||||
}
|
||||
if (home_zip !== undefined) {
|
||||
updateFields.push(`home_zip = $${paramIndex}`);
|
||||
updateValues.push(home_zip === '' ? null : home_zip);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
return res.status(400).json({ error: 'No settings to update' });
|
||||
|
|
@ -170,7 +182,7 @@ export default async function handler(req, res) {
|
|||
id, email, role, first_name, last_name, username,
|
||||
collection_visibility, preferred_currency, cards_per_page,
|
||||
default_view, notifications_email, notifications_marketing,
|
||||
two_factor_enabled, theme, language,
|
||||
two_factor_enabled, theme, language, home_zip,
|
||||
created_at, updated_at
|
||||
`;
|
||||
|
||||
|
|
@ -201,7 +213,8 @@ export default async function handler(req, res) {
|
|||
notifications_email: userData.notifications_email,
|
||||
notifications_marketing: userData.notifications_marketing,
|
||||
theme: userData.theme,
|
||||
language: userData.language
|
||||
language: userData.language,
|
||||
home_zip: userData.home_zip ?? null,
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { sql } from '../../../lib/sql.js';
|
||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||
import { effectiveUnitPriceExpr } from '../../../lib/pricing/collection-valuation.js';
|
||||
|
||||
const UNIT_PRICE_EXPR = effectiveUnitPriceExpr({ cardAlias: 'cards', userCardAlias: 'user_cards' });
|
||||
const VALUED_QUANTITY = sql([`(${UNIT_PRICE_EXPR}) * user_cards.quantity`]);
|
||||
const HAS_UNIT_PRICE = sql([`(${UNIT_PRICE_EXPR}) IS NOT NULL`]);
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'GET') {
|
||||
|
|
@ -36,11 +41,11 @@ export default async function handler(req, res) {
|
|||
|
||||
// Get total value of user's cards
|
||||
const valueResult = await sql`
|
||||
SELECT COALESCE(SUM(cards.market_price * user_cards.quantity), 0) as total_value
|
||||
SELECT COALESCE(SUM(${VALUED_QUANTITY}), 0) as total_value
|
||||
FROM user_cards
|
||||
JOIN cards ON user_cards.card_id = cards.id
|
||||
WHERE user_cards.user_id = ${user.userId}
|
||||
AND cards.market_price IS NOT NULL
|
||||
AND ${HAS_UNIT_PRICE}
|
||||
`;
|
||||
|
||||
// Get game breakdown
|
||||
|
|
@ -49,7 +54,7 @@ export default async function handler(req, res) {
|
|||
cards.game,
|
||||
COUNT(DISTINCT cards.id) as unique_cards,
|
||||
COALESCE(SUM(user_cards.quantity), 0) as total_quantity,
|
||||
COALESCE(SUM(cards.market_price * user_cards.quantity), 0) as total_value
|
||||
COALESCE(SUM(${VALUED_QUANTITY}), 0) as total_value
|
||||
FROM user_cards
|
||||
JOIN cards ON user_cards.card_id = cards.id
|
||||
WHERE user_cards.user_id = ${user.userId}
|
||||
|
|
@ -63,7 +68,7 @@ export default async function handler(req, res) {
|
|||
cards.rarity,
|
||||
COUNT(DISTINCT cards.id) as unique_cards,
|
||||
COALESCE(SUM(user_cards.quantity), 0) as total_quantity,
|
||||
COALESCE(SUM(cards.market_price * user_cards.quantity), 0) as total_value
|
||||
COALESCE(SUM(${VALUED_QUANTITY}), 0) as total_value
|
||||
FROM user_cards
|
||||
JOIN cards ON user_cards.card_id = cards.id
|
||||
WHERE user_cards.user_id = ${user.userId}
|
||||
|
|
@ -78,7 +83,7 @@ export default async function handler(req, res) {
|
|||
cards.game,
|
||||
cards.rarity,
|
||||
cards.image_url,
|
||||
cards.market_price,
|
||||
${sql([`${UNIT_PRICE_EXPR} as market_price`])},
|
||||
user_cards.quantity,
|
||||
user_cards.created_at
|
||||
FROM user_cards
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export default function MyCards() {
|
|||
const [selectedRarity, setSelectedRarity] = useState('all');
|
||||
const [selectedSet, setSelectedSet] = useState('all');
|
||||
const [selectedValueRange, setSelectedValueRange] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [viewMode, setViewMode] = useState('grid'); // grid or list
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
|
|
@ -75,6 +77,8 @@ export default function MyCards() {
|
|||
const params = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
sort: sortBy,
|
||||
order: sortOrder,
|
||||
...(searchQuery && { search: searchQuery }),
|
||||
...(selectedTCG !== 'all' && { tcg: selectedTCG }),
|
||||
...(selectedRarity !== 'all' && { rarity: selectedRarity }),
|
||||
|
|
@ -185,7 +189,7 @@ export default function MyCards() {
|
|||
loadFavoritedCards();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- filter-driven reload; fetchCards closes over latest search state
|
||||
}, [user, searchQuery, selectedTCG, selectedRarity, selectedSet, selectedValueRange]);
|
||||
}, [user, searchQuery, selectedTCG, selectedRarity, selectedSet, selectedValueRange, sortBy, sortOrder]);
|
||||
|
||||
// Infinite scroll
|
||||
useEffect(() => {
|
||||
|
|
@ -382,6 +386,28 @@ export default function MyCards() {
|
|||
<option key={rarity} value={rarity}>{rarity}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="input-field"
|
||||
aria-label="Sort cards"
|
||||
>
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="price">Sort by Price</option>
|
||||
<option value="rarity">Sort by Rarity</option>
|
||||
<option value="date">Sort by Date Added</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value)}
|
||||
className="input-field"
|
||||
aria-label="Sort order"
|
||||
>
|
||||
<option value="asc">Ascending</option>
|
||||
<option value="desc">Descending</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ export default function Settings() {
|
|||
|
||||
// Display Settings
|
||||
theme: 'system', // light, dark, system
|
||||
language: 'en'
|
||||
language: 'en',
|
||||
|
||||
// Local stores
|
||||
home_zip: '',
|
||||
});
|
||||
|
||||
// Password change state
|
||||
|
|
@ -531,6 +534,26 @@ export default function Settings() {
|
|||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Home ZIP for local stores */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Home ZIP code (for nearby game stores)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]{5}"
|
||||
maxLength={5}
|
||||
value={settings.home_zip ?? ''}
|
||||
onChange={(e) => handleSettingChange('home_zip', e.target.value.replace(/\D/g, '').slice(0, 5))}
|
||||
placeholder="e.g. 10001"
|
||||
className="input-field w-full"
|
||||
/>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Used to find game and hobby stores near you on card detail. Leave blank to disable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
81
test/api/cards-owned-sort.test.js
Normal file
81
test/api/cards-owned-sort.test.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { listUnitPriceExpr } from '../../lib/pricing/collection-valuation.js';
|
||||
|
||||
vi.mock('../../lib/sql.js', () => ({ sql: { query: vi.fn() } }));
|
||||
vi.mock('../../lib/permission-middleware.js', () => ({
|
||||
getUserFromRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sql } from '../../lib/sql.js';
|
||||
import { getUserFromRequest } from '../../lib/permission-middleware.js';
|
||||
import handler from '../../pages/api/cards/owned.js';
|
||||
|
||||
const UNIT_PRICE_SQL = listUnitPriceExpr({ cardAlias: 'c' });
|
||||
|
||||
function createRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(data) {
|
||||
res.body = data;
|
||||
return res;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('GET /api/cards/owned sort', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
||||
sql.query.mockImplementation(async (queryText) => {
|
||||
if (queryText.includes('COUNT(*)')) {
|
||||
return { rows: [{ total: '0' }] };
|
||||
}
|
||||
if (queryText.includes('DISTINCT')) {
|
||||
return { rows: [] };
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
});
|
||||
|
||||
it('orders by effective price when sort=price&order=desc', async () => {
|
||||
const req = {
|
||||
method: 'GET',
|
||||
query: { sort: 'price', order: 'desc' },
|
||||
};
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const cardsQueryCall = sql.query.mock.calls.find(([queryText]) =>
|
||||
queryText.includes('INNER JOIN user_cards')
|
||||
);
|
||||
expect(cardsQueryCall).toBeDefined();
|
||||
const queryText = cardsQueryCall[0];
|
||||
expect(queryText).toContain(UNIT_PRICE_SQL);
|
||||
expect(queryText).toContain('ORDER BY');
|
||||
expect(queryText).toContain('DESC');
|
||||
expect(queryText).toContain('NULLS LAST');
|
||||
});
|
||||
|
||||
it('defaults to name sort when sort param is invalid', async () => {
|
||||
const req = {
|
||||
method: 'GET',
|
||||
query: { sort: 'invalid', order: 'asc' },
|
||||
};
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
const cardsQueryCall = sql.query.mock.calls.find(([queryText]) =>
|
||||
queryText.includes('INNER JOIN user_cards')
|
||||
);
|
||||
expect(cardsQueryCall[0]).toContain('ORDER BY c.name ASC');
|
||||
});
|
||||
});
|
||||
78
test/api/cards-price-history.test.js
Normal file
78
test/api/cards-price-history.test.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() }));
|
||||
vi.mock('../../lib/pricing/price-history.js', () => ({
|
||||
getPriceHistoryPoints: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sql } from '../../lib/sql.js';
|
||||
import { getPriceHistoryPoints } from '../../lib/pricing/price-history.js';
|
||||
import handler from '../../pages/api/cards/[id]/price-history.js';
|
||||
|
||||
function createRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(data) {
|
||||
res.body = data;
|
||||
return res;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('GET /api/cards/[id]/price-history', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sql.mockResolvedValue({ rows: [{ id: 42 }] });
|
||||
getPriceHistoryPoints.mockResolvedValue([
|
||||
{ date: '2026-01-01', amount: 1.25, source: 'scryfall', priceType: 'market', currency: 'USD' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid card id', async () => {
|
||||
const req = { method: 'GET', query: { id: 'nope' } };
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(sql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 when days is out of range', async () => {
|
||||
const req = { method: 'GET', query: { id: '42', days: '999' } };
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(getPriceHistoryPoints).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when card does not exist', async () => {
|
||||
sql.mockResolvedValue({ rows: [] });
|
||||
const req = { method: 'GET', query: { id: '42', days: '30' } };
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns points for a valid request', async () => {
|
||||
const req = { method: 'GET', query: { id: '42', days: '30' } };
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(getPriceHistoryPoints).toHaveBeenCalledWith(42, { days: 30 });
|
||||
expect(res.body.points).toHaveLength(1);
|
||||
expect(res.body.points[0].amount).toBe(1.25);
|
||||
});
|
||||
});
|
||||
72
test/api/cards-pricing.test.js
Normal file
72
test/api/cards-pricing.test.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../lib/pricing/pricing-service.js', () => ({
|
||||
getCardPricing: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getCardPricing } from '../../lib/pricing/pricing-service.js';
|
||||
import handler from '../../pages/api/cards/[id]/pricing.js';
|
||||
|
||||
function createRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(data) {
|
||||
res.body = data;
|
||||
return res;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('GET /api/cards/[id]/pricing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns 400 for invalid card id', async () => {
|
||||
const req = { method: 'GET', query: { id: 'abc' } };
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(getCardPricing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when card is missing', async () => {
|
||||
getCardPricing.mockResolvedValue(null);
|
||||
const req = { method: 'GET', query: { id: '99' } };
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns pricing payload with staleness metadata', async () => {
|
||||
getCardPricing.mockResolvedValue({
|
||||
prices: { current: 1.25, market: 2.5, currency: 'USD' },
|
||||
pricesUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
stale: true,
|
||||
outbound: {
|
||||
tcgplayer: 'https://tcgplayer.example',
|
||||
ebay: 'https://ebay.example',
|
||||
},
|
||||
});
|
||||
|
||||
const req = { method: 'GET', query: { id: '42' } };
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.stale).toBe(true);
|
||||
expect(res.body.prices.current).toBe(1.25);
|
||||
expect(res.body.outbound.tcgplayer).toContain('tcgplayer');
|
||||
});
|
||||
});
|
||||
101
test/api/stores-nearby.test.js
Normal file
101
test/api/stores-nearby.test.js
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../lib/sql.js', () => ({
|
||||
sql: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../lib/permission-middleware.js', () => ({
|
||||
getUserFromRequest: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../lib/rate-limit.js', () => ({
|
||||
checkStoresRateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../lib/pricing/geo-cache.js', () => ({
|
||||
getZipCoords: vi.fn(),
|
||||
getStoresNear: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sql } from '../../lib/sql.js';
|
||||
import { getUserFromRequest } from '../../lib/permission-middleware.js';
|
||||
import { checkStoresRateLimit } from '../../lib/rate-limit.js';
|
||||
import { getZipCoords, getStoresNear } from '../../lib/pricing/geo-cache.js';
|
||||
import handler from '../../pages/api/stores/nearby.js';
|
||||
|
||||
function createRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: null,
|
||||
status(code) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
setHeader(name, value) {
|
||||
res.headers[name] = value;
|
||||
return res;
|
||||
},
|
||||
json(data) {
|
||||
res.body = data;
|
||||
return res;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('GET /api/stores/nearby', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.PRICING_LOCAL_ENABLED = 'true';
|
||||
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
||||
checkStoresRateLimit.mockResolvedValue({ allowed: true, remaining: 0, reset: Date.now() + 3600000 });
|
||||
sql.mockResolvedValue({ rows: [{ home_zip: '10001' }] });
|
||||
getZipCoords.mockResolvedValue({ lat: 40.7, lng: -74.0, displayName: 'NYC', cached: true });
|
||||
getStoresNear.mockResolvedValue({
|
||||
stores: [{ name: 'Game Shop', distanceMiles: 1.2 }],
|
||||
cached: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 401 without authentication', async () => {
|
||||
getUserFromRequest.mockResolvedValue(null);
|
||||
const res = createRes();
|
||||
await handler({ method: 'GET', query: {} }, res);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 503 when local pricing is disabled', async () => {
|
||||
process.env.PRICING_LOCAL_ENABLED = 'false';
|
||||
const res = createRes();
|
||||
await handler({ method: 'GET', query: {} }, res);
|
||||
expect(res.statusCode).toBe(503);
|
||||
});
|
||||
|
||||
it('returns 429 with Retry-After when rate limited', async () => {
|
||||
checkStoresRateLimit.mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
reset: Date.now() + 1800000,
|
||||
});
|
||||
const res = createRes();
|
||||
await handler({ method: 'GET', query: {} }, res);
|
||||
expect(res.statusCode).toBe(429);
|
||||
expect(res.headers['Retry-After']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns stores for authenticated user with saved zip', async () => {
|
||||
const res = createRes();
|
||||
await handler({ method: 'GET', query: { zip: '90210' } }, res);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.homeZip).toBe('10001');
|
||||
expect(res.body.stores).toHaveLength(1);
|
||||
expect(getZipCoords).toHaveBeenCalledWith('10001');
|
||||
expect(getStoresNear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 when home zip is not configured', async () => {
|
||||
sql.mockResolvedValue({ rows: [{ home_zip: null }] });
|
||||
const res = createRes();
|
||||
await handler({ method: 'GET', query: {} }, res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.code).toBe('NO_HOME_ZIP');
|
||||
});
|
||||
});
|
||||
96
test/api/user-settings-home-zip.test.js
Normal file
96
test/api/user-settings-home-zip.test.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../lib/sql.js', () => ({
|
||||
sql: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../lib/permission-middleware.js', () => ({
|
||||
getUserFromRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sql } from '../../lib/sql.js';
|
||||
import { getUserFromRequest } from '../../lib/permission-middleware.js';
|
||||
import handler from '../../pages/api/user/settings.js';
|
||||
|
||||
function createRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(data) {
|
||||
res.body = data;
|
||||
return res;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
const baseUserRow = {
|
||||
id: 1,
|
||||
email: 'a@b.c',
|
||||
role: 'user',
|
||||
first_name: 'A',
|
||||
last_name: 'B',
|
||||
username: 'ab',
|
||||
collection_visibility: 'private',
|
||||
preferred_currency: 'USD',
|
||||
cards_per_page: 50,
|
||||
default_view: 'grid',
|
||||
notifications_email: true,
|
||||
notifications_marketing: false,
|
||||
two_factor_enabled: false,
|
||||
theme: 'system',
|
||||
language: 'en',
|
||||
home_zip: '10001',
|
||||
created_at: '2026-01-01',
|
||||
updated_at: '2026-01-01',
|
||||
};
|
||||
|
||||
describe('/api/user/settings home_zip', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
||||
});
|
||||
|
||||
it('GET includes home_zip in settings', async () => {
|
||||
sql.mockResolvedValue({ rows: [baseUserRow] });
|
||||
const res = createRes();
|
||||
await handler({ method: 'GET' }, res);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.settings.home_zip).toBe('10001');
|
||||
});
|
||||
|
||||
it('PUT persists valid 5-digit zip', async () => {
|
||||
sql.query = vi.fn().mockResolvedValue({
|
||||
rows: [{ ...baseUserRow, home_zip: '90210' }],
|
||||
});
|
||||
|
||||
const res = createRes();
|
||||
await handler({ method: 'PUT', body: { home_zip: '90210' } }, res);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.settings.home_zip).toBe('90210');
|
||||
expect(sql.query).toHaveBeenCalled();
|
||||
const queryText = sql.query.mock.calls[0][0];
|
||||
expect(queryText).toContain('home_zip');
|
||||
});
|
||||
|
||||
it('PUT rejects invalid zip', async () => {
|
||||
const res = createRes();
|
||||
await handler({ method: 'PUT', body: { home_zip: '1234' } }, res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toMatch(/zip/i);
|
||||
});
|
||||
|
||||
it('PUT allows null to clear zip', async () => {
|
||||
sql.query = vi.fn().mockResolvedValue({
|
||||
rows: [{ ...baseUserRow, home_zip: null }],
|
||||
});
|
||||
|
||||
const res = createRes();
|
||||
await handler({ method: 'PUT', body: { home_zip: null } }, res);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.settings.home_zip).toBeNull();
|
||||
});
|
||||
});
|
||||
152
test/api/user-stats-valuation.test.js
Normal file
152
test/api/user-stats-valuation.test.js
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../lib/sql.js', () => ({
|
||||
sql: vi.fn((strings, ...values) => {
|
||||
if (values.length === 0) {
|
||||
return { __sqlFragment: strings.join('') };
|
||||
}
|
||||
return Promise.resolve({ rows: [] });
|
||||
}),
|
||||
}));
|
||||
vi.mock('../../lib/permission-middleware.js', () => ({
|
||||
getUserFromRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sql } from '../../lib/sql.js';
|
||||
import { getUserFromRequest } from '../../lib/permission-middleware.js';
|
||||
import { effectiveUnitPriceExpr } from '../../lib/pricing/collection-valuation.js';
|
||||
import handler from '../../pages/api/user/stats.js';
|
||||
|
||||
const UNIT_PRICE_SQL = effectiveUnitPriceExpr({ cardAlias: 'cards', userCardAlias: 'user_cards' });
|
||||
|
||||
function createRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(data) {
|
||||
res.body = data;
|
||||
return res;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
function queryText(call) {
|
||||
const [strings, ...values] = call;
|
||||
const fragments = values.map((value) =>
|
||||
value && typeof value === 'object' && value.__sqlFragment ? value.__sqlFragment : '?'
|
||||
);
|
||||
return strings.reduce((text, part, index) => text + part + (fragments[index] ?? ''), '');
|
||||
}
|
||||
|
||||
describe('GET /api/user/stats foil-aware valuation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
||||
|
||||
sql.mockImplementation((strings, ...values) => {
|
||||
if (values.length === 0) {
|
||||
return { __sqlFragment: strings.join('') };
|
||||
}
|
||||
return Promise.resolve({ rows: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it('uses effective unit price SQL for totals and breakdowns', async () => {
|
||||
sql.mockImplementation((strings, ...values) => {
|
||||
if (values.length === 0) {
|
||||
return { __sqlFragment: strings.join('') };
|
||||
}
|
||||
|
||||
const text = queryText([strings, ...values]);
|
||||
|
||||
if (text.includes('SUM') && text.includes('total_value') && !text.includes('GROUP BY')) {
|
||||
return Promise.resolve({ rows: [{ total_value: '6.50' }] });
|
||||
}
|
||||
if (text.includes('GROUP BY cards.game')) {
|
||||
return Promise.resolve({
|
||||
rows: [{ game: 'MTG', unique_cards: '2', total_quantity: '3', total_value: '6.50' }],
|
||||
});
|
||||
}
|
||||
if (text.includes('GROUP BY cards.rarity')) {
|
||||
return Promise.resolve({
|
||||
rows: [{ rarity: 'rare', unique_cards: '2', total_quantity: '3', total_value: '6.50' }],
|
||||
});
|
||||
}
|
||||
if (text.includes('ORDER BY user_cards.created_at DESC')) {
|
||||
return Promise.resolve({
|
||||
rows: [
|
||||
{
|
||||
name: 'Lightning Bolt',
|
||||
game: 'MTG',
|
||||
rarity: 'common',
|
||||
image_url: null,
|
||||
market_price: '1.00',
|
||||
quantity: 2,
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
name: 'Lightning Bolt',
|
||||
game: 'MTG',
|
||||
rarity: 'common',
|
||||
image_url: null,
|
||||
market_price: '4.50',
|
||||
quantity: 1,
|
||||
created_at: '2026-01-02T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (text.includes('total_cards')) {
|
||||
return Promise.resolve({ rows: [{ total_cards: '3' }] });
|
||||
}
|
||||
if (text.includes('total_collections')) {
|
||||
return Promise.resolve({ rows: [{ total_collections: '1' }] });
|
||||
}
|
||||
if (text.includes('total_decks')) {
|
||||
return Promise.resolve({ rows: [{ total_decks: '0' }] });
|
||||
}
|
||||
|
||||
return Promise.resolve({ rows: [] });
|
||||
});
|
||||
|
||||
const req = { method: 'GET' };
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.totalValue).toBe(6.5);
|
||||
expect(res.body.recentActivity[0].marketPrice).toBe(1);
|
||||
expect(res.body.recentActivity[1].marketPrice).toBe(4.5);
|
||||
|
||||
const valuationCalls = sql.mock.calls.filter((call) => queryText(call).includes('total_value'));
|
||||
|
||||
for (const call of valuationCalls) {
|
||||
const text = queryText(call);
|
||||
expect(text).toContain(UNIT_PRICE_SQL);
|
||||
expect(text).not.toContain('cards.market_price * user_cards.quantity');
|
||||
}
|
||||
|
||||
const recentActivityCall = sql.mock.calls.find((call) =>
|
||||
queryText(call).includes('ORDER BY user_cards.created_at DESC')
|
||||
);
|
||||
expect(queryText(recentActivityCall)).toContain(`${UNIT_PRICE_SQL} as market_price`);
|
||||
});
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
getUserFromRequest.mockResolvedValue(null);
|
||||
|
||||
const req = { method: 'GET' };
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(sql).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
42
test/components/CardDetailLocalStores.test.js
Normal file
42
test/components/CardDetailLocalStores.test.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
|
||||
import CardDetailLocalStores from '../../components/card-detail/CardDetailLocalStores.js';
|
||||
|
||||
describe('CardDetailLocalStores', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('prompts to set zip when missing', () => {
|
||||
render(<CardDetailLocalStores noHomeZip />);
|
||||
expect(screen.getByText(/game stores near you/i)).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: /set home zip/i }).getAttribute('href')).toBe('/settings');
|
||||
});
|
||||
|
||||
it('renders populated store list', () => {
|
||||
render(
|
||||
<CardDetailLocalStores
|
||||
stores={[
|
||||
{
|
||||
id: 'node/1',
|
||||
name: 'Game Haven',
|
||||
address: '1 Main St',
|
||||
distanceMiles: 2.1,
|
||||
phone: '555-0100',
|
||||
website: 'https://example.com',
|
||||
osmUrl: 'https://www.openstreetmap.org/node/1',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Game Haven')).toBeTruthy();
|
||||
expect(screen.getByText(/2\.1 mi away/i)).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: /visit website/i }).getAttribute('href')).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('shows gentle empty copy when no stores found', () => {
|
||||
render(<CardDetailLocalStores stores={[]} />);
|
||||
expect(screen.getByText(/no game or hobby stores found/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
47
test/components/CardDetailPurchaseTab.test.js
Normal file
47
test/components/CardDetailPurchaseTab.test.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import CardDetailPurchaseTab, { stalenessBadgeText } from '../../components/card-detail/CardDetailPurchaseTab';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const formatCurrency = (amount) => `$${Number(amount).toFixed(2)}`;
|
||||
|
||||
describe('stalenessBadgeText', () => {
|
||||
test('shows outdated copy when stale', () => {
|
||||
expect(stalenessBadgeText({ stale: true, pricesUpdatedAt: '2026-01-01T00:00:00.000Z' }))
|
||||
.toBe('Prices may be outdated');
|
||||
});
|
||||
|
||||
test('shows updated date when fresh', () => {
|
||||
const text = stalenessBadgeText({ stale: false, pricesUpdatedAt: '2026-01-15T00:00:00.000Z' });
|
||||
expect(text).toContain('Updated');
|
||||
expect(text).toContain('2026');
|
||||
});
|
||||
});
|
||||
|
||||
describe('<CardDetailPurchaseTab>', () => {
|
||||
test('renders staleness badge text from pricing payload', () => {
|
||||
render(
|
||||
<CardDetailPurchaseTab
|
||||
card={{ name: 'Lightning Bolt', current_price: 1, market_price: 2, game: 'MTG' }}
|
||||
pricing={{
|
||||
prices: { current: 1.25, market: 2.5, currency: 'USD' },
|
||||
pricesUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
stale: true,
|
||||
outbound: {
|
||||
tcgplayer: 'https://www.tcgplayer.com/search',
|
||||
ebay: 'https://www.ebay.com/sch',
|
||||
},
|
||||
}}
|
||||
pricingLoading={false}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('pricing-staleness-badge').textContent)
|
||||
.toBe('Prices may be outdated');
|
||||
expect(screen.getByText('View on TCGPlayer')).toBeDefined();
|
||||
expect(screen.getByTestId('card-detail-local-stores-slot')).toBeDefined();
|
||||
});
|
||||
});
|
||||
55
test/lib/card-import/bulk-sync.test.js
Normal file
55
test/lib/card-import/bulk-sync.test.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { mapCard } from '../../../lib/card-import/bulk-sync.js';
|
||||
|
||||
describe('mapCard', () => {
|
||||
it('maps Scryfall usd to current_price and usd_foil to market_price', () => {
|
||||
const mapped = mapCard({
|
||||
id: 'abc-123',
|
||||
oracle_id: 'oracle-1',
|
||||
name: 'Lightning Bolt',
|
||||
set_name: 'Alpha',
|
||||
set: 'lea',
|
||||
collector_number: '161',
|
||||
rarity: 'common',
|
||||
prices: {
|
||||
usd: '1.50',
|
||||
usd_foil: '12.00',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapped.currentPrice).toBe(1.5);
|
||||
expect(mapped.marketPrice).toBe(12);
|
||||
expect(mapped.scryfallId).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('leaves prices null when Scryfall omits price fields', () => {
|
||||
const mapped = mapCard({
|
||||
id: 'no-price',
|
||||
name: 'Unknown Card',
|
||||
set_name: 'Test',
|
||||
set: 'tst',
|
||||
collector_number: '1',
|
||||
rarity: 'common',
|
||||
prices: {},
|
||||
});
|
||||
|
||||
expect(mapped.currentPrice).toBeNull();
|
||||
expect(mapped.marketPrice).toBeNull();
|
||||
});
|
||||
|
||||
it('parses only nonfoil price when foil is absent', () => {
|
||||
const mapped = mapCard({
|
||||
id: 'nonfoil-only',
|
||||
name: 'Island',
|
||||
set_name: 'Test',
|
||||
set: 'tst',
|
||||
collector_number: '2',
|
||||
rarity: 'common',
|
||||
prices: { usd: '0.25' },
|
||||
});
|
||||
|
||||
expect(mapped.currentPrice).toBe(0.25);
|
||||
expect(mapped.marketPrice).toBeNull();
|
||||
});
|
||||
});
|
||||
117
test/lib/card-import/lorcana-bulk.test.js
Normal file
117
test/lib/card-import/lorcana-bulk.test.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../lib/pricing/price-history.js', () => ({
|
||||
recordPriceSnapshots: vi.fn(async (rows) => rows.length),
|
||||
}));
|
||||
|
||||
import { recordPriceSnapshots } from '../../../lib/pricing/price-history.js';
|
||||
import {
|
||||
enrichLorcanaRowWithFolio,
|
||||
mapLorcanaCard,
|
||||
runBulkLorcanaSync,
|
||||
} from '../../../lib/card-import/lorcana-bulk.js';
|
||||
import { buildFolioPriceIndex } from '../../../lib/pricing/foolio-client.js';
|
||||
|
||||
describe('mapLorcanaCard pricing', () => {
|
||||
it('includes price fields when Foilio pricing is provided', () => {
|
||||
const mapped = mapLorcanaCard(
|
||||
{
|
||||
Unique_ID: 'TFC-001',
|
||||
Name: 'Ariel - On Human Legs',
|
||||
Set_Name: 'The First Chapter',
|
||||
Set_ID: 'TFC',
|
||||
Card_Num: 1,
|
||||
},
|
||||
{ currentPrice: 1.25, marketPrice: 1.5, currency: 'USD' }
|
||||
);
|
||||
|
||||
expect(mapped.currentPrice).toBe(1.25);
|
||||
expect(mapped.marketPrice).toBe(1.5);
|
||||
expect(mapped.pricesUpdatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichLorcanaRowWithFolio', () => {
|
||||
it('matches by card id and tracks matched/unmatched counts', () => {
|
||||
const index = buildFolioPriceIndex([
|
||||
{ card_id: 'TFC-001', usd_low: 1, usd_market: 2 },
|
||||
]);
|
||||
const stats = { pricesMatched: 0, unmatchedCount: 0 };
|
||||
|
||||
const enriched = enrichLorcanaRowWithFolio(
|
||||
mapLorcanaCard({
|
||||
Unique_ID: 'TFC-001',
|
||||
Name: 'Ariel - On Human Legs',
|
||||
Set_ID: 'TFC',
|
||||
}),
|
||||
index,
|
||||
stats
|
||||
);
|
||||
|
||||
expect(enriched.currentPrice).toBe(1);
|
||||
expect(enriched.marketPrice).toBe(2);
|
||||
expect(stats.pricesMatched).toBe(1);
|
||||
expect(stats.unmatchedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('increments unmatchedCount when no feed row matches', () => {
|
||||
const stats = { pricesMatched: 0, unmatchedCount: 0 };
|
||||
const enriched = enrichLorcanaRowWithFolio(
|
||||
mapLorcanaCard({
|
||||
Unique_ID: 'TFC-999',
|
||||
Name: 'Unknown',
|
||||
Set_ID: 'TFC',
|
||||
}),
|
||||
buildFolioPriceIndex([]),
|
||||
stats
|
||||
);
|
||||
|
||||
expect(enriched.currentPrice).toBeNull();
|
||||
expect(stats.unmatchedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runBulkLorcanaSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(recordPriceSnapshots).mockClear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('fetches Foilio feed once and reports price match summary', async () => {
|
||||
const fetchFolioIndex = vi.fn(async () => buildFolioPriceIndex([
|
||||
{ card_id: 'TFC-001', usd_low: 0.5, usd_market: 0.75 },
|
||||
]));
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ([
|
||||
{
|
||||
Unique_ID: 'TFC-001',
|
||||
Name: 'Ariel - On Human Legs',
|
||||
Set_Name: 'The First Chapter',
|
||||
Set_ID: 'TFC',
|
||||
Card_Num: 1,
|
||||
Gamemode: 'Lorcana',
|
||||
},
|
||||
]),
|
||||
})));
|
||||
|
||||
const query = vi.fn(async (text, values) => {
|
||||
if (String(text).includes('SELECT id, scryfall_id')) {
|
||||
return { rows: [{ id: 1, scryfall_id: 'TFC-001' }], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
});
|
||||
|
||||
const summary = await runBulkLorcanaSync({
|
||||
sql: { query },
|
||||
fetchFolioIndex,
|
||||
});
|
||||
|
||||
expect(fetchFolioIndex).toHaveBeenCalledTimes(1);
|
||||
expect(summary.pricesMatched).toBe(1);
|
||||
expect(summary.unmatchedCount).toBe(0);
|
||||
expect(summary.upserted).toBe(1);
|
||||
expect(recordPriceSnapshots).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
115
test/lib/card-import/pokemon-bulk.test.js
Normal file
115
test/lib/card-import/pokemon-bulk.test.js
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../lib/sql.js', () => ({
|
||||
sql: Object.assign(vi.fn(async () => ({ rows: [], rowCount: 0 })), {
|
||||
query: vi.fn(async (text, values) => {
|
||||
if (String(text).includes('SELECT id, scryfall_id')) {
|
||||
const ids = values?.[0] ?? [];
|
||||
return {
|
||||
rows: ids.map((scryfallId, index) => ({ id: index + 1, scryfall_id: scryfallId })),
|
||||
rowCount: ids.length,
|
||||
};
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/pricing/price-history.js', () => ({
|
||||
recordPriceSnapshots: vi.fn(async (rows) => rows.length),
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/card-import/pokemon-github.js', () => ({
|
||||
fetchPokemonSets: vi.fn(async () => [{ id: 'me3', name: 'Perfect Order', printedTotal: 88 }]),
|
||||
fetchPokemonSetCards: vi.fn(async () => [
|
||||
{
|
||||
id: 'me3-18',
|
||||
name: 'Seel',
|
||||
supertype: 'Pokémon',
|
||||
number: '18',
|
||||
rarity: 'Common',
|
||||
types: ['Water'],
|
||||
},
|
||||
]),
|
||||
mapGithubCardForInsert: vi.fn((card, setMeta) => ({
|
||||
externalId: card.id,
|
||||
name: card.name,
|
||||
setName: setMeta.name,
|
||||
setCode: setMeta.id,
|
||||
cardNumber: `${card.number}/${setMeta.printedTotal}`,
|
||||
rarity: card.rarity,
|
||||
cardType: card.supertype,
|
||||
types: card.types,
|
||||
flavorText: null,
|
||||
hp: null,
|
||||
imageSmall: null,
|
||||
imageLarge: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { sql } from '../../../lib/sql.js';
|
||||
import { recordPriceSnapshots } from '../../../lib/pricing/price-history.js';
|
||||
import {
|
||||
mapPokemonBulkRow,
|
||||
runBulkPokemonSync,
|
||||
} from '../../../lib/card-import/pokemon-bulk.js';
|
||||
|
||||
describe('mapPokemonBulkRow', () => {
|
||||
it('includes TCGdex prices and prices_updated_at when pricing is provided', () => {
|
||||
const row = mapPokemonBulkRow(
|
||||
{
|
||||
externalId: 'me3-18',
|
||||
name: 'Seel',
|
||||
setName: 'Perfect Order',
|
||||
setCode: 'me3',
|
||||
cardNumber: '18/88',
|
||||
rarity: 'Common',
|
||||
cardType: 'Pokémon',
|
||||
types: ['Water'],
|
||||
flavorText: null,
|
||||
hp: null,
|
||||
imageSmall: null,
|
||||
imageLarge: null,
|
||||
},
|
||||
{ unlimited: 'Legal' },
|
||||
{ currentPrice: 0.1, marketPrice: 0.2, currency: 'USD' }
|
||||
);
|
||||
|
||||
expect(row.currentPrice).toBe(0.1);
|
||||
expect(row.marketPrice).toBe(0.2);
|
||||
expect(row.pricesUpdatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runBulkPokemonSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(sql.query).mockClear();
|
||||
vi.mocked(recordPriceSnapshots).mockClear();
|
||||
});
|
||||
|
||||
it('enriches rows from TCGdex and reports match counts', async () => {
|
||||
const fetchTcgdexSetCards = vi.fn(async () => [
|
||||
{
|
||||
id: 'me3-18',
|
||||
localId: '18',
|
||||
name: 'Seel',
|
||||
set: { cardCount: { official: 88 } },
|
||||
pricing: {
|
||||
tcgplayer: { normal: { lowPrice: 0.1, marketPrice: 0.2 } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const summary = await runBulkPokemonSync({
|
||||
fetchTcgdexSetCards,
|
||||
query: sql.query,
|
||||
setDelayMs: 0,
|
||||
});
|
||||
|
||||
expect(summary.pricesMatched).toBe(1);
|
||||
expect(summary.unmatchedCount).toBe(0);
|
||||
expect(summary.snapshotsWritten).toBeGreaterThan(0);
|
||||
expect(recordPriceSnapshots).toHaveBeenCalled();
|
||||
expect(sql.query).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
26
test/lib/pricing/card-price.test.js
Normal file
26
test/lib/pricing/card-price.test.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isPriceStale, resolveUnitPrice, PRICE_STALE_HOURS } from '../../../lib/pricing/card-price.js';
|
||||
|
||||
describe('card-price', () => {
|
||||
it('uses current_price for nonfoil MTG rows', () => {
|
||||
expect(resolveUnitPrice({ currentPrice: 1.5, marketPrice: 10, isFoil: false })).toBe(1.5);
|
||||
});
|
||||
|
||||
it('uses market_price for foil MTG rows', () => {
|
||||
expect(resolveUnitPrice({ currentPrice: 1.5, marketPrice: 10, isFoil: true })).toBe(10);
|
||||
});
|
||||
|
||||
it('falls back when primary column is null', () => {
|
||||
expect(resolveUnitPrice({ currentPrice: null, marketPrice: 3, isFoil: false })).toBe(3);
|
||||
expect(resolveUnitPrice({ currentPrice: 2, marketPrice: null, isFoil: true })).toBe(2);
|
||||
});
|
||||
|
||||
it('detects stale prices', () => {
|
||||
const fresh = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const stale = new Date(Date.now() - (PRICE_STALE_HOURS + 1) * 60 * 60 * 1000);
|
||||
expect(isPriceStale(fresh)).toBe(false);
|
||||
expect(isPriceStale(stale)).toBe(true);
|
||||
expect(isPriceStale(null)).toBe(true);
|
||||
});
|
||||
});
|
||||
17
test/lib/pricing/collection-valuation.test.js
Normal file
17
test/lib/pricing/collection-valuation.test.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { effectiveUnitPriceExpr, listUnitPriceExpr } from '../../../lib/pricing/collection-valuation.js';
|
||||
|
||||
describe('collection-valuation', () => {
|
||||
it('builds foil-aware unit price SQL', () => {
|
||||
expect(effectiveUnitPriceExpr()).toContain('uc.is_foil');
|
||||
expect(effectiveUnitPriceExpr()).toContain('c.current_price');
|
||||
expect(effectiveUnitPriceExpr({ cardAlias: 'cards', userCardAlias: 'user_cards' })).toContain(
|
||||
'user_cards.is_foil'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds list unit price SQL', () => {
|
||||
expect(listUnitPriceExpr()).toBe('COALESCE(c.current_price, c.market_price)');
|
||||
});
|
||||
});
|
||||
70
test/lib/pricing/foolio-client.test.js
Normal file
70
test/lib/pricing/foolio-client.test.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildFolioPriceIndex,
|
||||
getFolioFeedUrl,
|
||||
matchFolioPrice,
|
||||
parseFolioPriceEntry,
|
||||
} from '../../../lib/pricing/foolio-client.js';
|
||||
|
||||
describe('foolio-client', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.FOILIO_PRICES_FEED_URL;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses default Foilio feed URL', () => {
|
||||
expect(getFolioFeedUrl()).toBe('https://foolio.net/api/prices/feed?game=lorcana');
|
||||
});
|
||||
|
||||
it('prefers USD fields when parsing feed rows', () => {
|
||||
expect(parseFolioPriceEntry({ usd_low: 1, usd_market: 2 })).toEqual({
|
||||
currentPrice: 1,
|
||||
marketPrice: 2,
|
||||
currency: 'USD',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to EUR with currency noted when USD is absent', () => {
|
||||
expect(parseFolioPriceEntry({ eur_low: 0.5, eur_market: 0.8 })).toEqual({
|
||||
currentPrice: 0.5,
|
||||
marketPrice: 0.8,
|
||||
currency: 'EUR',
|
||||
});
|
||||
});
|
||||
|
||||
it('indexes feed rows by card id and normalized name|set', () => {
|
||||
const index = buildFolioPriceIndex([
|
||||
{
|
||||
card_id: 'TFC-001',
|
||||
name: 'Ariel - On Human Legs',
|
||||
set: 'TFC',
|
||||
usd_low: 1,
|
||||
usd_market: 1.5,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(index.byCardId.get('TFC-001')?.marketPrice).toBe(1.5);
|
||||
expect(
|
||||
matchFolioPrice(index, {
|
||||
scryfallId: 'OTHER',
|
||||
name: 'Ariel - On Human Legs',
|
||||
setCode: 'TFC',
|
||||
})?.currentPrice
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('fetchFolioPriceFeed loads and indexes the feed once', async () => {
|
||||
const payload = [{ card_id: 'TFC-001', usd_market: 3 }];
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => payload,
|
||||
})));
|
||||
|
||||
const { fetchFolioPriceIndex } = await import('../../../lib/pricing/foolio-client.js');
|
||||
const index = await fetchFolioPriceIndex();
|
||||
|
||||
expect(index.byCardId.get('TFC-001')?.marketPrice).toBe(3);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
31
test/lib/pricing/geo-cache.test.js
Normal file
31
test/lib/pricing/geo-cache.test.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
STORES_CACHE_TTL_MS,
|
||||
buildStoresCacheKey,
|
||||
isStoresCacheFresh,
|
||||
} from '../../../lib/pricing/geo-cache.js';
|
||||
|
||||
describe('geo-cache', () => {
|
||||
it('builds stable cache keys for the same coordinates', () => {
|
||||
const a = buildStoresCacheKey(40.12345, -74.98765, 16093);
|
||||
const b = buildStoresCacheKey(40.12345, -74.98765, 16093);
|
||||
expect(a).toBe(b);
|
||||
expect(a.length).toBeLessThanOrEqual(64);
|
||||
});
|
||||
|
||||
it('treats fresh cache entries as valid within TTL', () => {
|
||||
const fetchedAt = new Date(Date.now() - STORES_CACHE_TTL_MS + 1000);
|
||||
expect(isStoresCacheFresh(fetchedAt)).toBe(true);
|
||||
});
|
||||
|
||||
it('expires cache entries after TTL', () => {
|
||||
const fetchedAt = new Date(Date.now() - STORES_CACHE_TTL_MS - 1);
|
||||
expect(isStoresCacheFresh(fetchedAt)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid fetched_at values', () => {
|
||||
expect(isStoresCacheFresh(null)).toBe(false);
|
||||
expect(isStoresCacheFresh('not-a-date')).toBe(false);
|
||||
});
|
||||
});
|
||||
59
test/lib/pricing/nominatim.test.js
Normal file
59
test/lib/pricing/nominatim.test.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { geocodeUsZip } from '../../../lib/pricing/nominatim.js';
|
||||
|
||||
describe('nominatim', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('geocodes a US zip via allowlisted host', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [{ lat: '40.7128', lon: '-74.0060', display_name: 'New York, NY' }],
|
||||
});
|
||||
|
||||
const result = await geocodeUsZip('10001', { fetchImpl });
|
||||
expect(result).toEqual({
|
||||
lat: 40.7128,
|
||||
lng: -74.006,
|
||||
displayName: 'New York, NY',
|
||||
});
|
||||
|
||||
const calledUrl = fetchImpl.mock.calls[0][0];
|
||||
expect(calledUrl).toContain('nominatim.openstreetmap.org');
|
||||
expect(calledUrl).toContain('postalcode=10001');
|
||||
});
|
||||
|
||||
it('returns null when no results', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
});
|
||||
|
||||
const result = await geocodeUsZip('00000', { fetchImpl });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('enforces minimum 1s between requests', async () => {
|
||||
vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') });
|
||||
vi.resetModules();
|
||||
const { geocodeUsZip: geocodeFresh } = await import('../../../lib/pricing/nominatim.js');
|
||||
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [{ lat: '40.0', lon: '-74.0', display_name: 'Test' }],
|
||||
});
|
||||
|
||||
await geocodeFresh('10001', { fetchImpl });
|
||||
const secondPromise = geocodeFresh('10002', { fetchImpl });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await secondPromise;
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
23
test/lib/pricing/outbound-links.test.js
Normal file
23
test/lib/pricing/outbound-links.test.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildEbayUrl, buildOutboundLinks, buildTcgplayerUrl } from '../../../lib/pricing/outbound-links.js';
|
||||
|
||||
describe('outbound-links', () => {
|
||||
const card = { name: 'Lightning Bolt', game: 'MTG', set_name: 'Alpha' };
|
||||
|
||||
it('builds tcgplayer search url', () => {
|
||||
expect(buildTcgplayerUrl(card)).toContain('tcgplayer.com');
|
||||
expect(buildTcgplayerUrl(card)).toContain(encodeURIComponent('Lightning Bolt'));
|
||||
});
|
||||
|
||||
it('builds ebay search url', () => {
|
||||
expect(buildEbayUrl(card)).toContain('ebay.com');
|
||||
expect(buildEbayUrl(card)).toContain(encodeURIComponent('Lightning Bolt Alpha MTG'));
|
||||
});
|
||||
|
||||
it('returns both outbound links', () => {
|
||||
const links = buildOutboundLinks(card);
|
||||
expect(links.tcgplayer).toBeTruthy();
|
||||
expect(links.ebay).toBeTruthy();
|
||||
});
|
||||
});
|
||||
64
test/lib/pricing/overpass.test.js
Normal file
64
test/lib/pricing/overpass.test.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildOverpassQuery,
|
||||
clampRadiusMiles,
|
||||
milesToMeters,
|
||||
parseOverpassResponse,
|
||||
} from '../../../lib/pricing/overpass.js';
|
||||
|
||||
describe('overpass', () => {
|
||||
it('clamps radius miles between 1 and 50', () => {
|
||||
expect(clampRadiusMiles(0)).toBe(1);
|
||||
expect(clampRadiusMiles(100)).toBe(50);
|
||||
expect(clampRadiusMiles(25)).toBe(25);
|
||||
});
|
||||
|
||||
it('builds fixed-template query with numeric injection only', () => {
|
||||
const query = buildOverpassQuery({ lat: 40.1, lng: -74.2, radiusMeters: 16093 });
|
||||
expect(query).toContain('around:16093,40.1,-74.2');
|
||||
expect(query).toContain('shop"="games"');
|
||||
expect(query).toContain('shop"="hobby"');
|
||||
expect(query).not.toContain('; DROP');
|
||||
});
|
||||
|
||||
it('parses elements with distance-ready coordinates', () => {
|
||||
const stores = parseOverpassResponse(
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
type: 'node',
|
||||
id: 42,
|
||||
lat: 40.11,
|
||||
lon: -74.21,
|
||||
tags: {
|
||||
name: 'Game Haven',
|
||||
'addr:street': 'Main St',
|
||||
'addr:city': 'Town',
|
||||
phone: '555-0100',
|
||||
website: 'https://example.com',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ originLat: 40.1, originLng: -74.2 }
|
||||
);
|
||||
|
||||
expect(stores).toHaveLength(1);
|
||||
expect(stores[0]).toMatchObject({
|
||||
name: 'Game Haven',
|
||||
lat: 40.11,
|
||||
lng: -74.21,
|
||||
phone: '555-0100',
|
||||
website: 'https://example.com',
|
||||
osmUrl: 'https://www.openstreetmap.org/node/42',
|
||||
});
|
||||
expect(stores[0].distanceMiles).toBeGreaterThan(0);
|
||||
expect(stores[0].address).toContain('Main St');
|
||||
});
|
||||
|
||||
it('converts miles to meters with clamping', () => {
|
||||
expect(milesToMeters(25)).toBe(Math.round(25 * 1609.344));
|
||||
expect(milesToMeters(0)).toBe(Math.round(1 * 1609.344));
|
||||
});
|
||||
});
|
||||
31
test/lib/pricing/price-history.test.js
Normal file
31
test/lib/pricing/price-history.test.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../../lib/sql.js', () => ({
|
||||
sql: vi.fn(async () => ({ rows: [], rowCount: 0 })),
|
||||
}));
|
||||
|
||||
import { sql } from '../../../lib/sql.js';
|
||||
import { recordPriceSnapshots } from '../../../lib/pricing/price-history.js';
|
||||
|
||||
describe('price-history', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(sql).mockClear();
|
||||
});
|
||||
|
||||
it('skips invalid rows', async () => {
|
||||
const written = await recordPriceSnapshots([
|
||||
{ cardId: 1, source: 'scryfall', amount: null },
|
||||
{ cardId: null, source: 'scryfall', amount: 1 },
|
||||
]);
|
||||
expect(written).toBe(0);
|
||||
expect(sql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upserts valid snapshot rows', async () => {
|
||||
const written = await recordPriceSnapshots([
|
||||
{ cardId: 42, source: 'scryfall', amount: 1.25, priceType: 'market', currency: 'USD' },
|
||||
]);
|
||||
expect(written).toBe(1);
|
||||
expect(sql).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
120
test/lib/pricing/tcgdex-client.test.js
Normal file
120
test/lib/pricing/tcgdex-client.test.js
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildTcgdexPriceIndex,
|
||||
getTcgdexBaseUrl,
|
||||
matchTcgdexCard,
|
||||
parseTcgdexPricing,
|
||||
} from '../../../lib/pricing/tcgdex-client.js';
|
||||
|
||||
describe('tcgdex-client', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.TCGDEX_BASE_URL;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses default TCGdex base URL', () => {
|
||||
expect(getTcgdexBaseUrl()).toBe('https://api.tcgdex.net/v2/en');
|
||||
});
|
||||
|
||||
it('parses TCGPlayer USD pricing into current and market prices', () => {
|
||||
const parsed = parseTcgdexPricing({
|
||||
pricing: {
|
||||
tcgplayer: {
|
||||
normal: {
|
||||
lowPrice: 0.5,
|
||||
marketPrice: 1.25,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
currentPrice: 0.5,
|
||||
marketPrice: 1.25,
|
||||
currency: 'USD',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to Cardmarket EUR for market_price when USD is missing', () => {
|
||||
const parsed = parseTcgdexPricing({
|
||||
pricing: {
|
||||
cardmarket: {
|
||||
trend: 2.5,
|
||||
unit: 'EUR',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.currentPrice).toBeNull();
|
||||
expect(parsed.marketPrice).toBe(2.5);
|
||||
expect(parsed.currency).toBe('EUR');
|
||||
});
|
||||
|
||||
it('matches cards by set card number with leading zero normalization', () => {
|
||||
const index = buildTcgdexPriceIndex([
|
||||
{
|
||||
id: 'me3-18',
|
||||
localId: '18',
|
||||
name: 'Seel',
|
||||
set: { cardCount: { official: 88 } },
|
||||
},
|
||||
]);
|
||||
|
||||
const match = matchTcgdexCard(index, {
|
||||
name: 'Seel',
|
||||
cardNumber: '018/88',
|
||||
});
|
||||
|
||||
expect(match?.id).toBe('me3-18');
|
||||
});
|
||||
|
||||
it('falls back to unique name match within a set index', () => {
|
||||
const index = buildTcgdexPriceIndex([
|
||||
{ id: 'sv1-001', localId: '1', name: 'Sprigatito' },
|
||||
]);
|
||||
|
||||
const match = matchTcgdexCard(index, {
|
||||
name: 'Sprigatito',
|
||||
cardNumber: null,
|
||||
});
|
||||
|
||||
expect(match?.id).toBe('sv1-001');
|
||||
});
|
||||
|
||||
it('fetchSetCards loads set summaries and full card details', async () => {
|
||||
const fetchMock = vi.fn(async (url) => {
|
||||
if (url.endsWith('/sets/me3')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cards: [{ id: 'me3-18', localId: '18', name: 'Seel' }],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.endsWith('/cards/me3-18')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 'me3-18',
|
||||
localId: '18',
|
||||
name: 'Seel',
|
||||
pricing: {
|
||||
tcgplayer: { normal: { lowPrice: 0.1, marketPrice: 0.2 } },
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected url ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { fetchSetCards } = await import('../../../lib/pricing/tcgdex-client.js');
|
||||
const cards = await fetchSetCards('me3');
|
||||
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0].pricing.tcgplayer.normal.marketPrice).toBe(0.2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue