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

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:
Randall Stillwell 2026-09-08 22:47:46 -05:00
parent 70be99421d
commit d0468015eb
69 changed files with 4662 additions and 136 deletions

View 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.**

View file

@ -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.

View 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.

View 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.

View 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.

View 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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View 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.

View 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.

View file

@ -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.

View file

@ -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>

View file

@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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.

View file

@ -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,

View file

@ -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));

View file

@ -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
View 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;
}

View 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)`;
}

View 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
View 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
View 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`,
};
}

View 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
View 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;
}

View 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,
}));
}

View 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),
};
}

View 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 };

View file

@ -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));
}

View file

@ -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) {

View file

@ -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,

View file

@ -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

View 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.'
);
};

View file

@ -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}

View 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' });
}
}

View 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' });
}
}

View file

@ -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}
`;

View file

@ -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'

View file

@ -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,

View 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' });
}
}

View file

@ -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,
}
};

View file

@ -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

View file

@ -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>

View file

@ -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>
)}

View 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');
});
});

View 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);
});
});

View 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');
});
});

View 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');
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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);
});
});

View 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)');
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});

View 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();
});
});

View 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));
});
});

View 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);
});
});

View 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);
});
});