Compare commits
No commits in common. "d80c20c75e9a14e122a4cf5fb8da7ab3c888737b" and "70be99421d5e173ead30aa6a43daf76f3fb41461" have entirely different histories.
d80c20c75e
...
70be99421d
69 changed files with 136 additions and 4662 deletions
|
|
@ -1,320 +0,0 @@
|
||||||
---
|
|
||||||
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.**
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
---
|
|
||||||
convoy: pricing-online-local
|
|
||||||
brief_number: 9
|
|
||||||
depends_on: [8]
|
|
||||||
recommended_model: composer-2.5
|
|
||||||
model_tier: standard
|
|
||||||
files:
|
|
||||||
- components/card-detail/CardDetailPurchaseTab.js
|
|
||||||
- components/card-detail/CardDetailPriceHistoryTab.js
|
|
||||||
- components/CardDetailView.js
|
|
||||||
- lib/use-card-detail.js
|
|
||||||
- test/components/CardDetailPurchaseTab.test.js
|
|
||||||
cross_brief_commitments:
|
|
||||||
- brief: 8
|
|
||||||
description: |
|
|
||||||
Consumes `/api/cards/[id]/pricing` and `/api/cards/[id]/price-history`
|
|
||||||
response shapes defined in Brief 8.
|
|
||||||
- brief: 13
|
|
||||||
description: |
|
|
||||||
Brief 13 will add `CardDetailLocalStores` below the purchase panel.
|
|
||||||
Leave a slot in purchase tab wrapper for Brief 13 insertion.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Brief 9: Card detail pricing UI
|
|
||||||
|
|
||||||
## Goal (1 sentence)
|
|
||||||
|
|
||||||
Replace placeholder Purchase and Price History tabs with live pricing data, staleness badge, and a simple history chart.
|
|
||||||
|
|
||||||
## Files in scope (do not edit anything else)
|
|
||||||
- `components/card-detail/CardDetailPurchaseTab.js`
|
|
||||||
- `components/card-detail/CardDetailPriceHistoryTab.js`
|
|
||||||
- `components/CardDetailView.js`
|
|
||||||
- `lib/use-card-detail.js`
|
|
||||||
- `test/components/CardDetailPurchaseTab.test.js`
|
|
||||||
|
|
||||||
## Conventions to follow
|
|
||||||
- Liquid Glass surfaces: `glass-panel`, CSS variables — no hardcoded hex.
|
|
||||||
- Purchase tab: staleness badge, outbound links from API `outbound`.
|
|
||||||
- Price History tab: line chart from `points`; empty state when none.
|
|
||||||
- Reserve insertion point for Brief 13 local stores section.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] Purchase tab shows DB prices and staleness badge
|
|
||||||
- [ ] Price History tab renders data when points exist; empty state when none
|
|
||||||
- [ ] No placeholder “available soon” copy remains
|
|
||||||
- [ ] Component test verifies staleness badge text
|
|
||||||
- [ ] Tests pass
|
|
||||||
- [ ] No scope expansion
|
|
||||||
|
|
||||||
## Rationale (≤3 sentences)
|
|
||||||
|
|
||||||
Delivers Phase 1 user-facing value on existing tab chrome. Keeps `CardDetailView` thin by extracting tab bodies. Documents insertion point for Phase 2 local stores.
|
|
||||||
|
|
@ -5,9 +5,6 @@ import CardDetailDeckModal from './CardDetailDeckModal';
|
||||||
import { ManaCost, ColorIdentity, AdvancedManaCost } from './ManaSymbols';
|
import { ManaCost, ColorIdentity, AdvancedManaCost } from './ManaSymbols';
|
||||||
import ManaSymbolSettings from './ManaSymbolSettings';
|
import ManaSymbolSettings from './ManaSymbolSettings';
|
||||||
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
|
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) {
|
export default function CardDetailView(props) {
|
||||||
const {
|
const {
|
||||||
activeTab,
|
activeTab,
|
||||||
|
|
@ -33,16 +30,8 @@ export default function CardDetailView(props) {
|
||||||
isFavorited,
|
isFavorited,
|
||||||
loading,
|
loading,
|
||||||
manaSymbolSettings,
|
manaSymbolSettings,
|
||||||
nearbyStores,
|
|
||||||
nearbyStoresError,
|
|
||||||
nearbyStoresLoading,
|
|
||||||
nearbyStoresNoHomeZip,
|
|
||||||
ownedQuantity,
|
ownedQuantity,
|
||||||
particleStyles,
|
particleStyles,
|
||||||
priceHistory,
|
|
||||||
priceHistoryLoading,
|
|
||||||
pricing,
|
|
||||||
pricingLoading,
|
|
||||||
quantity,
|
quantity,
|
||||||
router,
|
router,
|
||||||
selectedCollection,
|
selectedCollection,
|
||||||
|
|
@ -355,24 +344,85 @@ export default function CardDetailView(props) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'price-history' && (
|
{activeTab === 'price-history' && (
|
||||||
<CardDetailPriceHistoryTab
|
<div className="space-y-6">
|
||||||
points={priceHistory}
|
<h3 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||||
priceHistoryLoading={priceHistoryLoading}
|
Price History
|
||||||
formatCurrency={formatCurrency}
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'purchase' && (
|
{activeTab === 'purchase' && (
|
||||||
<CardDetailPurchaseTab
|
<div className="space-y-6">
|
||||||
card={card}
|
<h3 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||||
pricing={pricing}
|
Where to Buy
|
||||||
pricingLoading={pricingLoading}
|
</h3>
|
||||||
formatCurrency={formatCurrency}
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
nearbyStores={nearbyStores}
|
<a
|
||||||
nearbyStoresLoading={nearbyStoresLoading}
|
href={`https://www.tcgplayer.com/search/${card.game.toLowerCase()}/product?productName=${encodeURIComponent(card.name)}`}
|
||||||
nearbyStoresError={nearbyStoresError}
|
target="_blank"
|
||||||
nearbyStoresNoHomeZip={nearbyStoresNoHomeZip}
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,6 @@ export default function CollectionPageView(props) {
|
||||||
setShowShareModal,
|
setShowShareModal,
|
||||||
setShowUploadModal,
|
setShowUploadModal,
|
||||||
setSortBy,
|
setSortBy,
|
||||||
setSortOrder,
|
|
||||||
setViewMode,
|
setViewMode,
|
||||||
showDeleteModal,
|
showDeleteModal,
|
||||||
showEditModal,
|
showEditModal,
|
||||||
|
|
@ -72,7 +71,6 @@ export default function CollectionPageView(props) {
|
||||||
showShareModal,
|
showShareModal,
|
||||||
showUploadModal,
|
showUploadModal,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
togglePublic,
|
togglePublic,
|
||||||
user,
|
user,
|
||||||
|
|
@ -346,22 +344,11 @@ export default function CollectionPageView(props) {
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value)}
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
aria-label="Sort cards"
|
|
||||||
>
|
>
|
||||||
<option value="name">Sort by Name</option>
|
<option>Sort by Name</option>
|
||||||
<option value="price">Sort by Price</option>
|
<option>Sort by Price</option>
|
||||||
<option value="rarity">Sort by Rarity</option>
|
<option>Sort by Rarity</option>
|
||||||
<option value="date">Sort by Date Added</option>
|
<option>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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
function formatUpdatedLabel(pricesUpdatedAt) {
|
|
||||||
if (!pricesUpdatedAt) return 'Price date unknown';
|
|
||||||
const date = new Date(pricesUpdatedAt);
|
|
||||||
if (Number.isNaN(date.getTime())) return 'Price date unknown';
|
|
||||||
return `Updated ${date.toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
})}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stalenessBadgeText({ stale, pricesUpdatedAt }) {
|
|
||||||
if (stale) return 'Prices may be outdated';
|
|
||||||
return formatUpdatedLabel(pricesUpdatedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
import CardDetailLocalStores from './CardDetailLocalStores';
|
|
||||||
|
|
||||||
export default function CardDetailPurchaseTab({
|
|
||||||
card,
|
|
||||||
pricing,
|
|
||||||
pricingLoading,
|
|
||||||
formatCurrency,
|
|
||||||
nearbyStores = [],
|
|
||||||
nearbyStoresLoading = false,
|
|
||||||
nearbyStoresError = null,
|
|
||||||
nearbyStoresNoHomeZip = false,
|
|
||||||
}) {
|
|
||||||
const displayPrice = pricing?.prices?.current ?? pricing?.prices?.market ?? card?.current_price ?? null;
|
|
||||||
const foilPrice = pricing?.prices?.market ?? card?.market_price ?? null;
|
|
||||||
const badgeText = pricing ? stalenessBadgeText(pricing) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<h3 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Where to Buy
|
|
||||||
</h3>
|
|
||||||
{badgeText && (
|
|
||||||
<span
|
|
||||||
className="px-3 py-1 rounded-full text-xs font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-tertiary)',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
data-testid="pricing-staleness-badge"
|
|
||||||
>
|
|
||||||
{badgeText}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="glass-panel p-6 rounded-xl">
|
|
||||||
<h4 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Online Price Estimate
|
|
||||||
</h4>
|
|
||||||
{pricingLoading ? (
|
|
||||||
<p style={{ color: 'var(--text-secondary)' }} aria-live="polite">Loading prices…</p>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>NM / Non-foil</p>
|
|
||||||
<p className="text-2xl font-bold gradient-text-gold">
|
|
||||||
{formatCurrency(displayPrice || 0)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{foilPrice != null && foilPrice !== displayPrice && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Foil / Market</p>
|
|
||||||
<p className="text-2xl font-bold gradient-text-gold">
|
|
||||||
{formatCurrency(foilPrice)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{pricing?.outbound?.tcgplayer && (
|
|
||||||
<a
|
|
||||||
href={pricing.outbound.tcgplayer}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="glass-panel p-6 rounded-xl transition-all duration-200 hover:shadow-lg hover:scale-105"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl" aria-hidden="true">🃏</span>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
TCGPlayer
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
View on TCGPlayer
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{pricing?.outbound?.ebay && (
|
|
||||||
<a
|
|
||||||
href={pricing.outbound.ebay}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="glass-panel p-6 rounded-xl transition-all duration-200 hover:shadow-lg hover:scale-105"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl" aria-hidden="true">🛒</span>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
eBay
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
View on eBay
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardDetailLocalStores
|
|
||||||
stores={nearbyStores}
|
|
||||||
loading={nearbyStoresLoading}
|
|
||||||
error={nearbyStoresError}
|
|
||||||
noHomeZip={nearbyStoresNoHomeZip}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -15,8 +15,8 @@
|
||||||
> historical effects into the migration history; until then this file
|
> historical effects into the migration history; until then this file
|
||||||
> remains the curated reference for the full prod shape.
|
> remains the curated reference for the full prod shape.
|
||||||
>
|
>
|
||||||
> **Last reviewed:** 2026-09-08 against `migrations/` HEAD (through
|
> **Last reviewed:** 2026-06-14 against `migrations/` HEAD (through
|
||||||
> `1783000000001_add-pricing-schema.js`).
|
> `1781442340000_cleanup-lorcana-orphans.js`).
|
||||||
|
|
||||||
## Quick model groups
|
## Quick model groups
|
||||||
|
|
||||||
|
|
@ -29,7 +29,6 @@
|
||||||
| **Collections** | `collections`, `collection_cards`, `collection_permissions`, `collection_activity` | Curated card lists with sharing (UI: **Lists**) |
|
| **Collections** | `collections`, `collection_cards`, `collection_permissions`, `collection_activity` | Curated card lists with sharing (UI: **Lists**) |
|
||||||
| **Decks** | `decks`, `deck_cards` | Playable deck definitions |
|
| **Decks** | `decks`, `deck_cards` | Playable deck definitions |
|
||||||
| **Scanning** | `card_submissions`, `scan_attempts` | Unknown-card review queue + scan telemetry |
|
| **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 |
|
| **Invitations** | `invitations` (referenced; verify) | Pending share requests |
|
||||||
|
|
||||||
## Tables
|
## Tables
|
||||||
|
|
@ -58,7 +57,6 @@
|
||||||
| `theme` | `VARCHAR(10)` default `'system'` | `'light' \| 'dark' \| 'system'` |
|
| `theme` | `VARCHAR(10)` default `'system'` | `'light' \| 'dark' \| 'system'` |
|
||||||
| `language` | `VARCHAR(5)` default `'en'` | |
|
| `language` | `VARCHAR(5)` default `'en'` | |
|
||||||
| `is_pending` | `BOOLEAN` default `false` | Set by invitation flow before signup completes |
|
| `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
|
### cards
|
||||||
|
|
||||||
|
|
@ -76,8 +74,7 @@
|
||||||
| `oracle_text` | `TEXT` | LARGE — never `SELECT *` |
|
| `oracle_text` | `TEXT` | LARGE — never `SELECT *` |
|
||||||
| `power`, `toughness` | `VARCHAR(10)` | MTG creatures |
|
| `power`, `toughness` | `VARCHAR(10)` | MTG creatures |
|
||||||
| `image_url`, `stock_image_url` | `TEXT` | |
|
| `image_url`, `stock_image_url` | `TEXT` | |
|
||||||
| `current_price`, `market_price` | `DECIMAL(10,2)` | MTG: `usd` / `usd_foil` from Scryfall |
|
| `current_price`, `market_price` | `DECIMAL(10,2)` | |
|
||||||
| `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`) |
|
| `scryfall_id` | `VARCHAR(255) UNIQUE` | External dedupe key (Scryfall UUID, Pokémon TCG id, Lorcana `Unique_ID`) |
|
||||||
| `verified` | `BOOLEAN` default `false` | Admin-edited cards |
|
| `verified` | `BOOLEAN` default `false` | Admin-edited cards |
|
||||||
| `quantity` | `INTEGER` default `0` | **Unused; consider dropping — quantity lives in `user_cards`** (`1781442330001_reconcile-cards-columns`) |
|
| `quantity` | `INTEGER` default `0` | **Unused; consider dropping — quantity lives in `user_cards`** (`1781442330001_reconcile-cards-columns`) |
|
||||||
|
|
@ -287,49 +284,6 @@ 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)`.
|
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)
|
### 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.
|
Defined in `add-user-profile-fields.js`. Mirrors several `users.*` columns — there's redundancy that needs to be reconciled.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { sql } from '../sql.js';
|
import { sql } from '../sql.js';
|
||||||
import { recordPriceSnapshots } from '../pricing/price-history.js';
|
|
||||||
|
|
||||||
const BATCH_SIZE = 100;
|
const BATCH_SIZE = 100;
|
||||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
@ -24,7 +23,7 @@ async function fetchBulkDownloadUrl() {
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapCard(card) {
|
function mapCard(card) {
|
||||||
let currentPrice = null;
|
let currentPrice = null;
|
||||||
let marketPrice = null;
|
let marketPrice = null;
|
||||||
if (card.prices) {
|
if (card.prices) {
|
||||||
|
|
@ -66,64 +65,14 @@ export 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) {
|
async function upsertBatch(cards) {
|
||||||
if (cards.length === 0) return { upserted: 0, snapshotsWritten: 0 };
|
if (cards.length === 0) return 0;
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
'scryfall_id', 'oracle_id', 'illustration_id', 'name', 'set_name', 'set_code',
|
'scryfall_id', 'oracle_id', 'illustration_id', 'name', 'set_name', 'set_code',
|
||||||
'card_number', 'rarity', 'game', 'mana_cost', 'cmc', 'card_type', 'colors',
|
'card_number', 'rarity', 'game', 'mana_cost', 'cmc', 'card_type', 'colors',
|
||||||
'color_identity', 'oracle_text', 'power', 'toughness', 'image_url', 'stock_image_url',
|
'color_identity', 'oracle_text', 'power', 'toughness', 'image_url', 'stock_image_url',
|
||||||
'current_price', 'market_price', 'prices_updated_at', 'keywords', 'legalities', 'flavor_text', 'artist',
|
'current_price', 'market_price', 'keywords', 'legalities', 'flavor_text', 'artist',
|
||||||
'released_at', 'layout', 'edhrec_rank', 'reserved', 'reprint', 'finishes', 'verified',
|
'released_at', 'layout', 'edhrec_rank', 'reserved', 'reprint', 'finishes', 'verified',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -132,12 +81,11 @@ async function upsertBatch(cards) {
|
||||||
let paramIdx = 1;
|
let paramIdx = 1;
|
||||||
|
|
||||||
for (const row of cards) {
|
for (const row of cards) {
|
||||||
const pricesUpdatedAt = hasPrice(row) ? new Date() : null;
|
|
||||||
const rowValues = [
|
const rowValues = [
|
||||||
row.scryfallId, row.oracleId, row.illustrationId, row.name, row.setName, row.setCode,
|
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.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.colorIdentity, row.oracleText, row.power, row.toughness, row.imageUrl, row.stockImageUrl,
|
||||||
row.currentPrice, row.marketPrice, pricesUpdatedAt, row.keywords, row.legalities, row.flavorText, row.artist,
|
row.currentPrice, row.marketPrice, row.keywords, row.legalities, row.flavorText, row.artist,
|
||||||
row.releasedAt, row.layout, row.edhrecRank, row.reserved, row.reprint, row.finishes, true,
|
row.releasedAt, row.layout, row.edhrecRank, row.reserved, row.reprint, row.finishes, true,
|
||||||
];
|
];
|
||||||
const rowP = rowValues.map(() => `$${paramIdx++}`);
|
const rowP = rowValues.map(() => `$${paramIdx++}`);
|
||||||
|
|
@ -157,9 +105,7 @@ async function upsertBatch(cards) {
|
||||||
values
|
values
|
||||||
);
|
);
|
||||||
|
|
||||||
const snapshotsWritten = await recordBatchSnapshots(cards);
|
return cards.length;
|
||||||
|
|
||||||
return { upserted: cards.length, snapshotsWritten };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -180,7 +126,6 @@ export async function runBulkMtgSync() {
|
||||||
const mtgCards = allCards.filter((c) => c.lang === 'en' && !c.digital);
|
const mtgCards = allCards.filter((c) => c.lang === 'en' && !c.digital);
|
||||||
|
|
||||||
let upserted = 0;
|
let upserted = 0;
|
||||||
let snapshotsWritten = 0;
|
|
||||||
let errors = 0;
|
let errors = 0;
|
||||||
let batch = [];
|
let batch = [];
|
||||||
|
|
||||||
|
|
@ -188,9 +133,7 @@ export async function runBulkMtgSync() {
|
||||||
batch.push(mapCard(card));
|
batch.push(mapCard(card));
|
||||||
if (batch.length >= BATCH_SIZE) {
|
if (batch.length >= BATCH_SIZE) {
|
||||||
try {
|
try {
|
||||||
const result = await upsertBatch(batch);
|
upserted += await upsertBatch(batch);
|
||||||
upserted += result.upserted;
|
|
||||||
snapshotsWritten += result.snapshotsWritten;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[bulk-sync] Batch error:', err.message);
|
console.error('[bulk-sync] Batch error:', err.message);
|
||||||
errors += batch.length;
|
errors += batch.length;
|
||||||
|
|
@ -201,9 +144,7 @@ export async function runBulkMtgSync() {
|
||||||
|
|
||||||
if (batch.length > 0) {
|
if (batch.length > 0) {
|
||||||
try {
|
try {
|
||||||
const result = await upsertBatch(batch);
|
upserted += await upsertBatch(batch);
|
||||||
upserted += result.upserted;
|
|
||||||
snapshotsWritten += result.snapshotsWritten;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[bulk-sync] Final batch error:', err.message);
|
console.error('[bulk-sync] Final batch error:', err.message);
|
||||||
errors += batch.length;
|
errors += batch.length;
|
||||||
|
|
@ -230,7 +171,6 @@ export async function runBulkMtgSync() {
|
||||||
mode: 'bulk',
|
mode: 'bulk',
|
||||||
totalInFile: mtgCards.length,
|
totalInFile: mtgCards.length,
|
||||||
upserted,
|
upserted,
|
||||||
snapshotsWritten,
|
|
||||||
errors,
|
errors,
|
||||||
backfilledOracleId: backfill.rowCount || 0,
|
backfilledOracleId: backfill.rowCount || 0,
|
||||||
updatedAt: meta.updated_at,
|
updatedAt: meta.updated_at,
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,17 @@
|
||||||
* Bulk catalog sync for Disney Lorcana via lorcana-api.com /bulk/cards.
|
* 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).
|
* 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 { 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 BATCH_SIZE = 100;
|
||||||
const BULK_URL = 'https://api.lorcana-api.com/bulk/cards';
|
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.
|
* Map a Lorcana bulk API card object to cards-table insert shape.
|
||||||
*/
|
*/
|
||||||
export function mapLorcanaCard(card, pricing = null) {
|
export function mapLorcanaCard(card) {
|
||||||
const colors = card.Color
|
const colors = card.Color
|
||||||
? card.Color.split(',').map((entry) => entry.trim()).filter(Boolean)
|
? card.Color.split(',').map((entry) => entry.trim()).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
|
@ -39,7 +32,7 @@ export function mapLorcanaCard(card, pricing = null) {
|
||||||
|
|
||||||
const cost = card.Cost != null ? Number(card.Cost) : null;
|
const cost = card.Cost != null ? Number(card.Cost) : null;
|
||||||
|
|
||||||
const row = {
|
return {
|
||||||
scryfallId: card.Unique_ID,
|
scryfallId: card.Unique_ID,
|
||||||
name: card.Name,
|
name: card.Name,
|
||||||
setName: card.Set_Name,
|
setName: card.Set_Name,
|
||||||
|
|
@ -61,42 +54,14 @@ export function mapLorcanaCard(card, pricing = null) {
|
||||||
edhrecRank: card.Lore != null ? Number(card.Lore) : null,
|
edhrecRank: card.Lore != null ? Number(card.Lore) : null,
|
||||||
keywords: JSON.stringify(keywords),
|
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) {
|
export function buildUpsertQuery(batch) {
|
||||||
const columns = [
|
const columns = [
|
||||||
'scryfall_id', 'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game',
|
'scryfall_id', 'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game',
|
||||||
'mana_cost', 'cmc', 'card_type', 'colors', 'color_identity', 'oracle_text',
|
'mana_cost', 'cmc', 'card_type', 'colors', 'color_identity', 'oracle_text',
|
||||||
'power', 'toughness', 'image_url', 'stock_image_url', 'current_price', 'market_price',
|
'power', 'toughness', 'image_url', 'stock_image_url', 'keywords', 'flavor_text',
|
||||||
'prices_updated_at', 'keywords', 'flavor_text', 'artist', 'edhrec_rank', 'verified',
|
'artist', 'edhrec_rank', 'verified',
|
||||||
];
|
];
|
||||||
|
|
||||||
const placeholders = [];
|
const placeholders = [];
|
||||||
|
|
@ -109,7 +74,6 @@ export function buildUpsertQuery(batch) {
|
||||||
row.scryfallId, row.name, row.setName, row.setCode, row.cardNumber, row.rarity,
|
row.scryfallId, row.name, row.setName, row.setCode, row.cardNumber, row.rarity,
|
||||||
'Lorcana', row.manaCost, row.cmc, row.cardType, row.colors, row.colorIdentity,
|
'Lorcana', row.manaCost, row.cmc, row.cardType, row.colors, row.colorIdentity,
|
||||||
row.oracleText, row.power, row.toughness, row.imageUrl, row.stockImageUrl,
|
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,
|
row.keywords, row.flavorText, row.artist, row.edhrecRank, true,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -137,70 +101,11 @@ export function buildUpsertQuery(batch) {
|
||||||
return { query, values };
|
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) {
|
async function upsertBatch(sql, batch) {
|
||||||
if (batch.length === 0) return { upserted: 0, snapshotsWritten: 0 };
|
if (batch.length === 0) return 0;
|
||||||
|
|
||||||
const { query, values } = buildUpsertQuery(batch);
|
const { query, values } = buildUpsertQuery(batch);
|
||||||
await sql.query(query, values);
|
await sql.query(query, values);
|
||||||
const snapshotsWritten = await recordBatchSnapshots(sql, batch);
|
return batch.length;
|
||||||
|
|
||||||
return { upserted: batch.length, snapshotsWritten };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLorcanaBulkCards() {
|
export async function fetchLorcanaBulkCards() {
|
||||||
|
|
@ -216,21 +121,11 @@ export async function fetchLorcanaBulkCards() {
|
||||||
/**
|
/**
|
||||||
* Run a full Lorcana bulk sync. Returns summary compatible with catalog sync reporting.
|
* Run a full Lorcana bulk sync. Returns summary compatible with catalog sync reporting.
|
||||||
*
|
*
|
||||||
* @param {{ sql?: { query: (q: string, v: unknown[]) => Promise<unknown> }, dryRun?: boolean, fetchFolioIndex?: () => Promise<{ byCardId: Map<string, unknown>, byNameSet: Map<string, unknown> }> }} options
|
* @param {{ sql?: { query: (q: string, v: unknown[]) => Promise<unknown> }, dryRun?: boolean }} options
|
||||||
*/
|
*/
|
||||||
export async function runBulkLorcanaSync(options = {}) {
|
export async function runBulkLorcanaSync(options = {}) {
|
||||||
const dryRun = options.dryRun ?? false;
|
const dryRun = options.dryRun ?? false;
|
||||||
const sql = options.sql ?? vercelSql;
|
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 allCards = await fetchLorcanaBulkCards();
|
||||||
const lorcanaCards = allCards.filter(
|
const lorcanaCards = allCards.filter(
|
||||||
|
|
@ -238,33 +133,21 @@ export async function runBulkLorcanaSync(options = {}) {
|
||||||
);
|
);
|
||||||
|
|
||||||
let upserted = 0;
|
let upserted = 0;
|
||||||
let snapshotsWritten = 0;
|
|
||||||
let errors = 0;
|
let errors = 0;
|
||||||
let pricesMatched = 0;
|
|
||||||
let unmatchedCount = 0;
|
|
||||||
let batch = [];
|
let batch = [];
|
||||||
|
|
||||||
const matchStats = {
|
|
||||||
pricesMatched: 0,
|
|
||||||
unmatchedCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const card of lorcanaCards) {
|
for (const card of lorcanaCards) {
|
||||||
if (!card.Unique_ID) {
|
if (!card.Unique_ID) {
|
||||||
errors += 1;
|
errors += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mapped = mapLorcanaCard(card);
|
batch.push(mapLorcanaCard(card));
|
||||||
mapped = enrichLorcanaRowWithFolio(mapped, folioIndex, matchStats);
|
|
||||||
batch.push(mapped);
|
|
||||||
|
|
||||||
if (batch.length >= BATCH_SIZE) {
|
if (batch.length >= BATCH_SIZE) {
|
||||||
try {
|
try {
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
const result = await upsertBatch(sql, batch);
|
upserted += await upsertBatch(sql, batch);
|
||||||
upserted += result.upserted;
|
|
||||||
snapshotsWritten += result.snapshotsWritten;
|
|
||||||
} else {
|
} else {
|
||||||
upserted += batch.length;
|
upserted += batch.length;
|
||||||
}
|
}
|
||||||
|
|
@ -279,9 +162,7 @@ export async function runBulkLorcanaSync(options = {}) {
|
||||||
if (batch.length > 0) {
|
if (batch.length > 0) {
|
||||||
try {
|
try {
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
const result = await upsertBatch(sql, batch);
|
upserted += await upsertBatch(sql, batch);
|
||||||
upserted += result.upserted;
|
|
||||||
snapshotsWritten += result.snapshotsWritten;
|
|
||||||
} else {
|
} else {
|
||||||
upserted += batch.length;
|
upserted += batch.length;
|
||||||
}
|
}
|
||||||
|
|
@ -291,18 +172,12 @@ export async function runBulkLorcanaSync(options = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pricesMatched = matchStats.pricesMatched;
|
|
||||||
unmatchedCount = matchStats.unmatchedCount;
|
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
mode: 'bulk',
|
mode: 'bulk',
|
||||||
game: 'lorcana',
|
game: 'lorcana',
|
||||||
totalInFile: lorcanaCards.length,
|
totalInFile: lorcanaCards.length,
|
||||||
upserted,
|
upserted,
|
||||||
snapshotsWritten,
|
|
||||||
errors,
|
errors,
|
||||||
pricesMatched,
|
|
||||||
unmatchedCount,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[lorcana-bulk]', JSON.stringify(summary));
|
console.log('[lorcana-bulk]', JSON.stringify(summary));
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,9 @@
|
||||||
*
|
*
|
||||||
* Fetches all sets from sets/en.json, then upserts every card in each set
|
* 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.
|
* (keyed on scryfall_id = external card id). Populates legalities from set metadata.
|
||||||
* Enriches prices from TCGdex when available.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { sql } from '../sql.js';
|
import { sql } from '../sql.js';
|
||||||
import { recordPriceSnapshots } from '../pricing/price-history.js';
|
|
||||||
import {
|
|
||||||
buildTcgdexPriceIndex,
|
|
||||||
fetchSetCards,
|
|
||||||
matchTcgdexCard,
|
|
||||||
parseTcgdexPricing,
|
|
||||||
} from '../pricing/tcgdex-client.js';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchPokemonSetCards,
|
fetchPokemonSetCards,
|
||||||
|
|
@ -33,12 +25,8 @@ function getSetLegalities(setMeta) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasPrice(row) {
|
export function mapPokemonBulkRow(mapped, setLegalities) {
|
||||||
return row.currentPrice != null || row.marketPrice != null;
|
return {
|
||||||
}
|
|
||||||
|
|
||||||
export function mapPokemonBulkRow(mapped, setLegalities, pricing = null) {
|
|
||||||
const row = {
|
|
||||||
scryfallId: mapped.externalId,
|
scryfallId: mapped.externalId,
|
||||||
name: mapped.name,
|
name: mapped.name,
|
||||||
setName: mapped.setName,
|
setName: mapped.setName,
|
||||||
|
|
@ -52,29 +40,14 @@ export function mapPokemonBulkRow(mapped, setLegalities, pricing = null) {
|
||||||
imageUrl: mapped.imageSmall,
|
imageUrl: mapped.imageSmall,
|
||||||
stockImageUrl: mapped.imageLarge,
|
stockImageUrl: mapped.imageLarge,
|
||||||
legalities: JSON.stringify(setLegalities),
|
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) {
|
function buildUpsertQuery(batch) {
|
||||||
const columns = [
|
const columns = [
|
||||||
'scryfall_id', 'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game',
|
'scryfall_id', 'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game',
|
||||||
'mana_cost', 'cmc', 'card_type', 'colors', 'oracle_text', 'power', 'toughness',
|
'mana_cost', 'cmc', 'card_type', 'colors', 'oracle_text', 'power', 'toughness',
|
||||||
'image_url', 'stock_image_url', 'current_price', 'market_price', 'prices_updated_at', 'legalities', 'verified',
|
'image_url', 'stock_image_url', 'current_price', 'market_price', 'legalities', 'verified',
|
||||||
];
|
];
|
||||||
|
|
||||||
const placeholders = [];
|
const placeholders = [];
|
||||||
|
|
@ -100,9 +73,8 @@ function buildUpsertQuery(batch) {
|
||||||
null,
|
null,
|
||||||
row.imageUrl,
|
row.imageUrl,
|
||||||
row.stockImageUrl,
|
row.stockImageUrl,
|
||||||
row.currentPrice,
|
null,
|
||||||
row.marketPrice,
|
null,
|
||||||
row.pricesUpdatedAt,
|
|
||||||
row.legalities,
|
row.legalities,
|
||||||
true,
|
true,
|
||||||
];
|
];
|
||||||
|
|
@ -131,62 +103,6 @@ function buildUpsertQuery(batch) {
|
||||||
return { query, values };
|
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) {
|
async function upsertBatch(batch, queryFn, stats) {
|
||||||
if (batch.length === 0) return;
|
if (batch.length === 0) return;
|
||||||
|
|
||||||
|
|
@ -199,7 +115,6 @@ async function upsertBatch(batch, queryFn, stats) {
|
||||||
try {
|
try {
|
||||||
await queryFn(query, values);
|
await queryFn(query, values);
|
||||||
stats.upserted += batch.length;
|
stats.upserted += batch.length;
|
||||||
stats.snapshotsWritten += await recordBatchSnapshots(batch, queryFn);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[pokemon-bulk] Batch failed (${batch.length} rows):`, error.message);
|
console.error(`[pokemon-bulk] Batch failed (${batch.length} rows):`, error.message);
|
||||||
for (const row of batch) {
|
for (const row of batch) {
|
||||||
|
|
@ -207,7 +122,6 @@ async function upsertBatch(batch, queryFn, stats) {
|
||||||
const { query: singleQ, values: singleV } = buildUpsertQuery([row]);
|
const { query: singleQ, values: singleV } = buildUpsertQuery([row]);
|
||||||
await queryFn(singleQ, singleV);
|
await queryFn(singleQ, singleV);
|
||||||
stats.upserted += 1;
|
stats.upserted += 1;
|
||||||
stats.snapshotsWritten += await recordBatchSnapshots([row], queryFn);
|
|
||||||
} catch (singleErr) {
|
} catch (singleErr) {
|
||||||
console.error(`[pokemon-bulk] Failed: ${row.name} (${row.scryfallId}):`, singleErr.message);
|
console.error(`[pokemon-bulk] Failed: ${row.name} (${row.scryfallId}):`, singleErr.message);
|
||||||
stats.errors += 1;
|
stats.errors += 1;
|
||||||
|
|
@ -216,49 +130,13 @@ 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) {
|
async function upsertSetCards(setMeta, cards, options, stats) {
|
||||||
const setLegalities = getSetLegalities(setMeta);
|
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 = [];
|
let batch = [];
|
||||||
|
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
const mapped = mapGithubCardForInsert(card, setMeta);
|
const mapped = mapGithubCardForInsert(card, setMeta);
|
||||||
let row = mapPokemonBulkRow(mapped, setLegalities);
|
batch.push(mapPokemonBulkRow(mapped, setLegalities));
|
||||||
row = enrichRowWithTcgdex(row, tcgIndex, stats);
|
|
||||||
batch.push(row);
|
|
||||||
|
|
||||||
if (batch.length >= options.batchSize) {
|
if (batch.length >= options.batchSize) {
|
||||||
await upsertBatch(batch, options.queryFn, stats);
|
await upsertBatch(batch, options.queryFn, stats);
|
||||||
|
|
@ -280,7 +158,6 @@ export async function runBulkPokemonSync(options = {}) {
|
||||||
const dryRun = options.dryRun ?? process.env.DRY_RUN === 'true';
|
const dryRun = options.dryRun ?? process.env.DRY_RUN === 'true';
|
||||||
const setDelayMs = options.setDelayMs ?? DEFAULT_SET_DELAY_MS;
|
const setDelayMs = options.setDelayMs ?? DEFAULT_SET_DELAY_MS;
|
||||||
const queryFn = options.query ?? ((query, values) => sql.query(query, values));
|
const queryFn = options.query ?? ((query, values) => sql.query(query, values));
|
||||||
const fetchTcgdexSetCards = options.fetchTcgdexSetCards ?? fetchSetCards;
|
|
||||||
|
|
||||||
const sets = await fetchPokemonSets();
|
const sets = await fetchPokemonSets();
|
||||||
console.log(`[pokemon-bulk] Found ${sets.length} sets in pokemon-tcg-data`);
|
console.log(`[pokemon-bulk] Found ${sets.length} sets in pokemon-tcg-data`);
|
||||||
|
|
@ -291,9 +168,6 @@ export async function runBulkPokemonSync(options = {}) {
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
setsProcessed: 0,
|
setsProcessed: 0,
|
||||||
pricesMatched: 0,
|
|
||||||
unmatchedCount: 0,
|
|
||||||
snapshotsWritten: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let index = 0; index < sets.length; index += 1) {
|
for (let index = 0; index < sets.length; index += 1) {
|
||||||
|
|
@ -312,7 +186,7 @@ export async function runBulkPokemonSync(options = {}) {
|
||||||
if (cards.length === 0) {
|
if (cards.length === 0) {
|
||||||
console.log(`[pokemon-bulk] Set ${setCode}: no cards (empty or missing file)`);
|
console.log(`[pokemon-bulk] Set ${setCode}: no cards (empty or missing file)`);
|
||||||
} else {
|
} else {
|
||||||
await upsertSetCards(setMeta, cards, { batchSize, queryFn, fetchTcgdexSetCards }, stats);
|
await upsertSetCards(setMeta, cards, { batchSize, queryFn }, stats);
|
||||||
console.log(`[pokemon-bulk] Set ${setCode}: processed ${cards.length} cards`);
|
console.log(`[pokemon-bulk] Set ${setCode}: processed ${cards.length} cards`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -333,9 +207,6 @@ export async function runBulkPokemonSync(options = {}) {
|
||||||
upserted: stats.upserted,
|
upserted: stats.upserted,
|
||||||
skipped: stats.skipped,
|
skipped: stats.skipped,
|
||||||
errors: stats.errors,
|
errors: stats.errors,
|
||||||
pricesMatched: stats.pricesMatched,
|
|
||||||
unmatchedCount: stats.unmatchedCount,
|
|
||||||
snapshotsWritten: stats.snapshotsWritten,
|
|
||||||
dryRun,
|
dryRun,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
/** 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
/**
|
|
||||||
* 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)`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
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`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
/**
|
|
||||||
* @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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
const DEFAULT_TCGDEX_BASE_URL = 'https://api.tcgdex.net/v2/en';
|
|
||||||
|
|
||||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
export function getTcgdexBaseUrl() {
|
|
||||||
return process.env.TCGDEX_BASE_URL ?? DEFAULT_TCGDEX_BASE_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tcgplayerVariant(tcgplayer) {
|
|
||||||
if (!tcgplayer || typeof tcgplayer !== 'object') return null;
|
|
||||||
|
|
||||||
const preferredKeys = ['normal', 'holofoil', 'reverse', 'reverse-holofoil', 'unlimited', '1st-edition'];
|
|
||||||
for (const key of preferredKeys) {
|
|
||||||
const variant = tcgplayer[key];
|
|
||||||
if (variant && typeof variant === 'object') {
|
|
||||||
return variant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.values(tcgplayer).find(
|
|
||||||
(value) => value && typeof value === 'object' && ('marketPrice' in value || 'lowPrice' in value)
|
|
||||||
) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse TCGdex pricing into Deck Hearth card price fields.
|
|
||||||
* USD from TCGPlayer; Cardmarket EUR fills market_price only when USD is missing.
|
|
||||||
*/
|
|
||||||
export function parseTcgdexPricing(card) {
|
|
||||||
let currentPrice = null;
|
|
||||||
let marketPrice = null;
|
|
||||||
let currency = 'USD';
|
|
||||||
|
|
||||||
const variant = tcgplayerVariant(card.pricing?.tcgplayer);
|
|
||||||
if (variant) {
|
|
||||||
const low = variant.lowPrice ?? variant.directLowPrice;
|
|
||||||
const market = variant.marketPrice ?? variant.midPrice;
|
|
||||||
currentPrice = low != null ? Number(low) : (market != null ? Number(market) : null);
|
|
||||||
marketPrice = market != null ? Number(market) : currentPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cardmarket (EUR) — use for market_price only when TCGPlayer USD is unavailable.
|
|
||||||
if (currentPrice == null && marketPrice == null) {
|
|
||||||
const cardmarket = card.pricing?.cardmarket;
|
|
||||||
const eur = cardmarket?.trend ?? cardmarket?.avg ?? cardmarket?.low;
|
|
||||||
if (eur != null) {
|
|
||||||
marketPrice = Number(eur);
|
|
||||||
currency = 'EUR';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPrice != null && Number.isNaN(currentPrice)) currentPrice = null;
|
|
||||||
if (marketPrice != null && Number.isNaN(marketPrice)) marketPrice = null;
|
|
||||||
|
|
||||||
return { currentPrice, marketPrice, currency };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeTcgdexName(name) {
|
|
||||||
return String(name ?? '').trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cardNumberKeys(card) {
|
|
||||||
const keys = new Set();
|
|
||||||
const localId = card.localId ?? card.id?.split('-').pop();
|
|
||||||
if (localId != null && localId !== '') {
|
|
||||||
keys.add(String(localId).trim().toLowerCase());
|
|
||||||
const official = card.set?.cardCount?.official;
|
|
||||||
if (official) {
|
|
||||||
keys.add(`${String(parseInt(localId, 10))}/${official}`.toLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build lookup indexes for matching DB rows by set_code + card_number or name.
|
|
||||||
*/
|
|
||||||
export function buildTcgdexPriceIndex(cards) {
|
|
||||||
const byNumber = new Map();
|
|
||||||
const byName = new Map();
|
|
||||||
|
|
||||||
for (const card of cards) {
|
|
||||||
for (const key of cardNumberKeys(card)) {
|
|
||||||
if (!byNumber.has(key)) {
|
|
||||||
byNumber.set(key, card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nameKey = normalizeTcgdexName(card.name);
|
|
||||||
if (!byName.has(nameKey)) {
|
|
||||||
byName.set(nameKey, []);
|
|
||||||
}
|
|
||||||
byName.get(nameKey).push(card);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { byNumber, byName };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match a catalog row to a TCGdex card within a set.
|
|
||||||
*/
|
|
||||||
export function matchTcgdexCard(index, row) {
|
|
||||||
const cardNumber = String(row.cardNumber ?? '').trim().toLowerCase();
|
|
||||||
if (cardNumber) {
|
|
||||||
const direct = index.byNumber.get(cardNumber);
|
|
||||||
if (direct) return direct;
|
|
||||||
|
|
||||||
const slashIndex = cardNumber.indexOf('/');
|
|
||||||
if (slashIndex !== -1) {
|
|
||||||
const numerator = String(parseInt(cardNumber.slice(0, slashIndex), 10));
|
|
||||||
if (numerator !== 'NaN') {
|
|
||||||
const byNumerator = index.byNumber.get(numerator);
|
|
||||||
if (byNumerator) return byNumerator;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nameKey = normalizeTcgdexName(row.name);
|
|
||||||
const candidates = index.byName.get(nameKey) ?? [];
|
|
||||||
return candidates.length === 1 ? candidates[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchTcgdexJson(path) {
|
|
||||||
const response = await fetch(`${getTcgdexBaseUrl()}${path}`, {
|
|
||||||
headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`TCGdex API error: ${response.status} for ${path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all cards for a Pokémon set, including pricing when available.
|
|
||||||
*/
|
|
||||||
export async function fetchSetCards(setId) {
|
|
||||||
const setData = await fetchTcgdexJson(`/sets/${encodeURIComponent(setId)}`);
|
|
||||||
const summaries = Array.isArray(setData?.cards) ? setData.cards : [];
|
|
||||||
|
|
||||||
const cards = [];
|
|
||||||
for (const summary of summaries) {
|
|
||||||
if (!summary?.id) continue;
|
|
||||||
|
|
||||||
if (summary.pricing) {
|
|
||||||
cards.push(summary);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fullCard = await fetchTcgdexJson(`/cards/${encodeURIComponent(summary.id)}`);
|
|
||||||
cards.push(fullCard);
|
|
||||||
} catch {
|
|
||||||
cards.push(summary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cards;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { delay };
|
|
||||||
|
|
@ -9,8 +9,6 @@ const LIMITER_CONFIG = {
|
||||||
import: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:import' },
|
import: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:import' },
|
||||||
// One camera verify may escalate L0→L2; vision path is the expensive step.
|
// One camera verify may escalate L0→L2; vision path is the expensive step.
|
||||||
scan: { limit: 15, durationSec: 60, prefix: 'deckhearth:scan' },
|
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;
|
let cached = null;
|
||||||
|
|
@ -124,11 +122,3 @@ export async function checkImportRateLimit(req, userId) {
|
||||||
export async function checkScanRateLimit(req, userId) {
|
export async function checkScanRateLimit(req, userId) {
|
||||||
return check('scan', extractUserIdentifier(userId));
|
return check('scan', extractUserIdentifier(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkStoresRateLimit(req, userId) {
|
|
||||||
const userResult = await check('stores', extractUserIdentifier(userId));
|
|
||||||
if (!userResult.allowed) {
|
|
||||||
return userResult;
|
|
||||||
}
|
|
||||||
return check('storesIp', extractIpIdentifier(req));
|
|
||||||
}
|
|
||||||
|
|
|
||||||
17
lib/sql.js
17
lib/sql.js
|
|
@ -65,23 +65,6 @@ 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). */
|
/** Close the pooled client (tests / long-running scripts). */
|
||||||
export async function closeSqlPool() {
|
export async function closeSqlPool() {
|
||||||
if (client) {
|
if (client) {
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,6 @@ export function useCardDetail({ user = null, authLoading = true } = {}) {
|
||||||
const [decks, setDecks] = useState([]);
|
const [decks, setDecks] = useState([]);
|
||||||
const [cardCollections, setCardCollections] = useState([]);
|
const [cardCollections, setCardCollections] = useState([]);
|
||||||
const [cardDecks, setCardDecks] = 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
|
// Mana symbol settings
|
||||||
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
||||||
|
|
@ -91,55 +83,6 @@ export function useCardDetail({ user = null, authLoading = true } = {}) {
|
||||||
fetchCard();
|
fetchCard();
|
||||||
}, [id]);
|
}, [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
|
// Fetch user's collections and decks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUserData = async () => {
|
const fetchUserData = async () => {
|
||||||
|
|
@ -187,67 +130,6 @@ export function useCardDetail({ user = null, authLoading = true } = {}) {
|
||||||
}
|
}
|
||||||
}, [card, id]);
|
}, [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 getRarityGradient = (rarity) => {
|
||||||
const rarityKey = rarity?.toLowerCase();
|
const rarityKey = rarity?.toLowerCase();
|
||||||
const gradients = {
|
const gradients = {
|
||||||
|
|
@ -487,17 +369,9 @@ export function useCardDetail({ user = null, authLoading = true } = {}) {
|
||||||
isFavorited,
|
isFavorited,
|
||||||
loading,
|
loading,
|
||||||
manaSymbolSettings,
|
manaSymbolSettings,
|
||||||
nearbyStores,
|
|
||||||
nearbyStoresError,
|
|
||||||
nearbyStoresLoading,
|
|
||||||
nearbyStoresNoHomeZip,
|
|
||||||
ownedQuantity,
|
ownedQuantity,
|
||||||
particleStyles,
|
particleStyles,
|
||||||
getParticleCount,
|
getParticleCount,
|
||||||
priceHistory,
|
|
||||||
priceHistoryLoading,
|
|
||||||
pricing,
|
|
||||||
pricingLoading,
|
|
||||||
quantity,
|
quantity,
|
||||||
router,
|
router,
|
||||||
selectedCollection,
|
selectedCollection,
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,7 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
|
||||||
const [selectedRarity, setSelectedRarity] = useState('All Rarities');
|
const [selectedRarity, setSelectedRarity] = useState('All Rarities');
|
||||||
const [selectedType, setSelectedType] = useState('All Types');
|
const [selectedType, setSelectedType] = useState('All Types');
|
||||||
const [groupBy, setGroupBy] = useState('Group by Game');
|
const [groupBy, setGroupBy] = useState('Group by Game');
|
||||||
const [sortBy, setSortBy] = useState('name');
|
const [sortBy, setSortBy] = useState('Sort by Name');
|
||||||
const [sortOrder, setSortOrder] = useState('asc');
|
|
||||||
const [viewMode, setViewMode] = useState('grid');
|
const [viewMode, setViewMode] = useState('grid');
|
||||||
const [searchCards, setSearchCards] = useState('');
|
const [searchCards, setSearchCards] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState([]);
|
const [searchResults, setSearchResults] = useState([]);
|
||||||
|
|
@ -119,14 +118,11 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
|
||||||
tags: Array.isArray(data.tags) ? data.tags : (data.tags ? data.tags.split(',') : [])
|
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)
|
// Fetch collection cards (try public first, then authenticated)
|
||||||
let cardsResponse = await fetch(cardsUrl);
|
let cardsResponse = await fetch(`/api/collections/${identifier}/cards`);
|
||||||
|
|
||||||
if (!cardsResponse.ok && user) {
|
if (!cardsResponse.ok && user) {
|
||||||
cardsResponse = await fetch(cardsUrl, {
|
cardsResponse = await fetch(`/api/collections/${identifier}/cards`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
}
|
}
|
||||||
|
|
@ -164,8 +160,8 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
|
||||||
loadFavoritedCards();
|
loadFavoritedCards();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when list slug, auth, or sort changes
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when list slug or auth changes
|
||||||
}, [identifier, user, sortBy, sortOrder]);
|
}, [identifier, user]);
|
||||||
|
|
||||||
const handleEditCollection = async () => {
|
const handleEditCollection = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -492,7 +488,6 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
|
||||||
setShowShareModal,
|
setShowShareModal,
|
||||||
setShowUploadModal,
|
setShowUploadModal,
|
||||||
setSortBy,
|
setSortBy,
|
||||||
setSortOrder,
|
|
||||||
setViewMode,
|
setViewMode,
|
||||||
showDeleteModal,
|
showDeleteModal,
|
||||||
showEditModal,
|
showEditModal,
|
||||||
|
|
@ -501,7 +496,6 @@ export function useCollectionView({ user = null, authLoading = true } = {}) {
|
||||||
showShareModal,
|
showShareModal,
|
||||||
showUploadModal,
|
showUploadModal,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
togglePublic,
|
togglePublic,
|
||||||
viewMode
|
viewMode
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
/**
|
|
||||||
* Pricing convoy schema: price timestamps, history snapshots, home zip, geo caches.
|
|
||||||
*
|
|
||||||
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
|
||||||
*/
|
|
||||||
export const shorthands = undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
|
||||||
*/
|
|
||||||
export const up = (pgm) => {
|
|
||||||
pgm.sql(`
|
|
||||||
ALTER TABLE cards
|
|
||||||
ADD COLUMN IF NOT EXISTS prices_updated_at TIMESTAMPTZ;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS card_price_history (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
card_id INTEGER NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
|
|
||||||
source VARCHAR(32) NOT NULL,
|
|
||||||
price_type VARCHAR(32) NOT NULL DEFAULT 'market',
|
|
||||||
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
|
|
||||||
amount DECIMAL(10,2) NOT NULL,
|
|
||||||
recorded_at DATE NOT NULL DEFAULT CURRENT_DATE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE (card_id, source, price_type, currency, recorded_at)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_card_price_history_card_date
|
|
||||||
ON card_price_history (card_id, recorded_at DESC);
|
|
||||||
|
|
||||||
ALTER TABLE users
|
|
||||||
ADD COLUMN IF NOT EXISTS home_zip VARCHAR(10);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS geo_zip_cache (
|
|
||||||
zip VARCHAR(10) PRIMARY KEY,
|
|
||||||
lat DOUBLE PRECISION NOT NULL,
|
|
||||||
lng DOUBLE PRECISION NOT NULL,
|
|
||||||
display_name TEXT,
|
|
||||||
geocoded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS geo_stores_cache (
|
|
||||||
cache_key VARCHAR(64) PRIMARY KEY,
|
|
||||||
lat DOUBLE PRECISION NOT NULL,
|
|
||||||
lng DOUBLE PRECISION NOT NULL,
|
|
||||||
radius_meters INTEGER NOT NULL,
|
|
||||||
stores JSONB NOT NULL,
|
|
||||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_geo_stores_cache_fetched
|
|
||||||
ON geo_stores_cache (fetched_at);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
|
||||||
*/
|
|
||||||
export const down = (pgm) => {
|
|
||||||
throw new Error(
|
|
||||||
'[migration:1783000000001_add-pricing-schema] Refusing to drop pricing schema. ' +
|
|
||||||
'Rolling back would erase card_price_history snapshots and geo caches.'
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -10,7 +10,7 @@ export default async function handler(req, res) {
|
||||||
id, name, set_name, set_code, card_number, rarity, game,
|
id, name, set_name, set_code, card_number, rarity, game,
|
||||||
mana_cost, cmc, card_type, colors, oracle_text,
|
mana_cost, cmc, card_type, colors, oracle_text,
|
||||||
power, toughness, image_url, stock_image_url,
|
power, toughness, image_url, stock_image_url,
|
||||||
current_price, market_price, prices_updated_at, scryfall_id, verified,
|
current_price, market_price, scryfall_id, verified,
|
||||||
quantity
|
quantity
|
||||||
FROM cards
|
FROM cards
|
||||||
WHERE id = ${id}
|
WHERE id = ${id}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { getCardPricing } from '../../../../lib/pricing/pricing-service.js';
|
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
|
||||||
if (req.method !== 'GET') {
|
|
||||||
return res.status(405).json({ error: 'Method not allowed' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cardId = parseInt(req.query.id, 10);
|
|
||||||
if (!req.query.id || Number.isNaN(cardId) || cardId <= 0) {
|
|
||||||
return res.status(400).json({ error: 'Valid card ID is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const pricing = await getCardPricing(cardId);
|
|
||||||
if (!pricing) {
|
|
||||||
return res.status(404).json({ error: 'Card not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json(pricing);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[GET /api/cards/[id]/pricing]', err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +1,5 @@
|
||||||
import { sql } from '../../../lib/sql.js';
|
import { sql } from '../../../lib/sql.js';
|
||||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
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) {
|
export default async function handler(req, res) {
|
||||||
if (req.method !== 'GET') {
|
if (req.method !== 'GET') {
|
||||||
|
|
@ -42,15 +20,9 @@ export default async function handler(req, res) {
|
||||||
set = 'all',
|
set = 'all',
|
||||||
valueRange = 'all',
|
valueRange = 'all',
|
||||||
page = '1',
|
page = '1',
|
||||||
limit = '50',
|
limit = '50'
|
||||||
sort: sortParam = 'name',
|
|
||||||
order: orderParam = 'asc',
|
|
||||||
} = req.query;
|
} = 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 pageNum = parseInt(page) || 1;
|
||||||
const limitNum = parseInt(limit) || 50;
|
const limitNum = parseInt(limit) || 50;
|
||||||
const offset = (pageNum - 1) * limitNum;
|
const offset = (pageNum - 1) * limitNum;
|
||||||
|
|
@ -85,7 +57,6 @@ export default async function handler(req, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (valueRange !== 'all') {
|
if (valueRange !== 'all') {
|
||||||
const priceExpr = listUnitPriceExpr({ cardAlias: 'c' });
|
|
||||||
// Handle value range filtering
|
// Handle value range filtering
|
||||||
const ranges = {
|
const ranges = {
|
||||||
'under-1': [0, 1],
|
'under-1': [0, 1],
|
||||||
|
|
@ -98,11 +69,11 @@ export default async function handler(req, res) {
|
||||||
if (ranges[valueRange]) {
|
if (ranges[valueRange]) {
|
||||||
const [min, max] = ranges[valueRange];
|
const [min, max] = ranges[valueRange];
|
||||||
if (valueRange === 'over-100') {
|
if (valueRange === 'over-100') {
|
||||||
whereConditions.push(`${priceExpr} >= $${paramIndex}`);
|
whereConditions.push(`c.market_price >= $${paramIndex}`);
|
||||||
params.push(min);
|
params.push(min);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
} else {
|
} else {
|
||||||
whereConditions.push(`${priceExpr} >= $${paramIndex} AND ${priceExpr} <= $${paramIndex + 1}`);
|
whereConditions.push(`c.market_price >= $${paramIndex} AND c.market_price <= $${paramIndex + 1}`);
|
||||||
params.push(min, max);
|
params.push(min, max);
|
||||||
paramIndex += 2;
|
paramIndex += 2;
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +95,7 @@ export default async function handler(req, res) {
|
||||||
FROM cards c
|
FROM cards c
|
||||||
INNER JOIN user_cards uc ON c.id = uc.card_id
|
INNER JOIN user_cards uc ON c.id = uc.card_id
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
${orderClause}
|
ORDER BY c.name ASC
|
||||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import { sql } from '../../../lib/sql.js';
|
import { sql } from '../../../lib/sql.js';
|
||||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
import { isValidSlug } from '../../../lib/slug-utils';
|
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) {
|
export default async function handler(req, res) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -28,7 +24,7 @@ export default async function handler(req, res) {
|
||||||
c.*,
|
c.*,
|
||||||
u.email as creator_email,
|
u.email as creator_email,
|
||||||
COUNT(cc.card_id) as card_count,
|
COUNT(cc.card_id) as card_count,
|
||||||
COALESCE(SUM(${LIST_VALUED_QUANTITY}), 0) as total_value,
|
COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value,
|
||||||
${user ? sql`cp.role as user_role,
|
${user ? sql`cp.role as user_role,
|
||||||
CASE
|
CASE
|
||||||
WHEN c.user_id = ${user.userId} THEN 'owner'
|
WHEN c.user_id = ${user.userId} THEN 'owner'
|
||||||
|
|
@ -55,7 +51,7 @@ export default async function handler(req, res) {
|
||||||
c.*,
|
c.*,
|
||||||
u.email as creator_email,
|
u.email as creator_email,
|
||||||
COUNT(cc.card_id) as card_count,
|
COUNT(cc.card_id) as card_count,
|
||||||
COALESCE(SUM(${LIST_VALUED_QUANTITY}), 0) as total_value,
|
COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value,
|
||||||
${user ? sql`cp.role as user_role,
|
${user ? sql`cp.role as user_role,
|
||||||
CASE
|
CASE
|
||||||
WHEN c.user_id = ${user.userId} THEN 'owner'
|
WHEN c.user_id = ${user.userId} THEN 'owner'
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,6 @@
|
||||||
import { sql } from '../../../../lib/sql.js';
|
import { sql } from '../../../../lib/sql.js';
|
||||||
import { getUserFromRequest, logCollectionActivity } from '../../../../lib/permission-middleware';
|
import { getUserFromRequest, logCollectionActivity } from '../../../../lib/permission-middleware';
|
||||||
import { isValidSlug } from '../../../../lib/slug-utils';
|
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) {
|
export default async function handler(req, res) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -72,25 +50,17 @@ export default async function handler(req, res) {
|
||||||
const collection = collectionResult.rows[0];
|
const collection = collectionResult.rows[0];
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
const sortParam = req.query.sort ?? 'name';
|
// Get all cards in the collection
|
||||||
const orderParam = req.query.order ?? 'asc';
|
const cardsResult = await sql`
|
||||||
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
|
SELECT
|
||||||
cards.*,
|
cards.*,
|
||||||
cc.quantity,
|
cc.quantity,
|
||||||
cc.created_at as added_at
|
cc.created_at as added_at
|
||||||
FROM collection_cards cc
|
FROM collection_cards cc
|
||||||
JOIN cards ON cc.card_id = cards.id
|
JOIN cards ON cc.card_id = cards.id
|
||||||
WHERE cc.collection_id = $1
|
WHERE cc.collection_id = ${collection.id}
|
||||||
${orderClause}
|
ORDER BY cc.created_at DESC
|
||||||
`,
|
`;
|
||||||
[collection.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const cards = cardsResult.rows.map(card => ({
|
const cards = cardsResult.rows.map(card => ({
|
||||||
id: card.id,
|
id: card.id,
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { sql } from '../../../lib/sql.js';
|
|
||||||
import { getUserFromRequest } from '../../../lib/permission-middleware.js';
|
|
||||||
import { checkStoresRateLimit } from '../../../lib/rate-limit.js';
|
|
||||||
import { getZipCoords, getStoresNear } from '../../../lib/pricing/geo-cache.js';
|
|
||||||
import { clampRadiusMiles, milesToMeters } from '../../../lib/pricing/overpass.js';
|
|
||||||
|
|
||||||
function isLocalPricingEnabled() {
|
|
||||||
const flag = process.env.PRICING_LOCAL_ENABLED;
|
|
||||||
return flag === undefined || flag === '' || flag === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
|
||||||
if (req.method !== 'GET') {
|
|
||||||
return res.status(405).json({ error: 'Method not allowed' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isLocalPricingEnabled()) {
|
|
||||||
return res.status(503).json({ error: 'Local store discovery is disabled' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await getUserFromRequest(req);
|
|
||||||
if (!user) {
|
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const rateLimit = await checkStoresRateLimit(req, user.userId);
|
|
||||||
if (!rateLimit.allowed) {
|
|
||||||
const retryAfterSec = Math.max(1, Math.ceil((rateLimit.reset - Date.now()) / 1000));
|
|
||||||
res.setHeader('Retry-After', String(retryAfterSec));
|
|
||||||
return res.status(429).json({ error: 'Rate limit exceeded' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const userRow = await sql`
|
|
||||||
SELECT home_zip
|
|
||||||
FROM users
|
|
||||||
WHERE id = ${user.userId}
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (userRow.rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'User not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const homeZip = userRow.rows[0].home_zip;
|
|
||||||
if (!homeZip) {
|
|
||||||
return res.status(400).json({ error: 'Home ZIP not configured', code: 'NO_HOME_ZIP' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const radiusMiles = clampRadiusMiles(
|
|
||||||
req.query.radiusMiles != null ? Number(req.query.radiusMiles) : undefined
|
|
||||||
);
|
|
||||||
const radiusMeters = milesToMeters(radiusMiles);
|
|
||||||
|
|
||||||
const coords = await getZipCoords(homeZip);
|
|
||||||
if (!coords) {
|
|
||||||
return res.status(404).json({ error: 'Could not geocode home ZIP' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { stores, cached } = await getStoresNear(coords.lat, coords.lng, radiusMeters);
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
homeZip,
|
|
||||||
radiusMiles,
|
|
||||||
stores,
|
|
||||||
cached,
|
|
||||||
coordsCached: coords.cached,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[stores/nearby]', error);
|
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default async function handler(req, res) {
|
||||||
id, email, role, first_name, last_name, username,
|
id, email, role, first_name, last_name, username,
|
||||||
collection_visibility, preferred_currency, cards_per_page,
|
collection_visibility, preferred_currency, cards_per_page,
|
||||||
default_view, notifications_email, notifications_marketing,
|
default_view, notifications_email, notifications_marketing,
|
||||||
two_factor_enabled, theme, language, home_zip,
|
two_factor_enabled, theme, language,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ${user.userId}
|
WHERE id = ${user.userId}
|
||||||
|
|
@ -54,10 +54,7 @@ export default async function handler(req, res) {
|
||||||
|
|
||||||
// Display Settings
|
// Display Settings
|
||||||
theme: userData.theme || 'system',
|
theme: userData.theme || 'system',
|
||||||
language: userData.language || 'en',
|
language: userData.language || 'en'
|
||||||
|
|
||||||
// Local stores
|
|
||||||
home_zip: userData.home_zip ?? null,
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -74,8 +71,7 @@ export default async function handler(req, res) {
|
||||||
notifications_email,
|
notifications_email,
|
||||||
notifications_marketing,
|
notifications_marketing,
|
||||||
theme,
|
theme,
|
||||||
language,
|
language
|
||||||
home_zip
|
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Validate enum values
|
// Validate enum values
|
||||||
|
|
@ -105,9 +101,6 @@ export default async function handler(req, res) {
|
||||||
if (language && !validLanguage.includes(language)) {
|
if (language && !validLanguage.includes(language)) {
|
||||||
return res.status(400).json({ error: 'Invalid 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
|
// Build update query dynamically
|
||||||
const updateFields = [];
|
const updateFields = [];
|
||||||
|
|
@ -159,11 +152,6 @@ export default async function handler(req, res) {
|
||||||
updateValues.push(language);
|
updateValues.push(language);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
if (home_zip !== undefined) {
|
|
||||||
updateFields.push(`home_zip = $${paramIndex}`);
|
|
||||||
updateValues.push(home_zip === '' ? null : home_zip);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateFields.length === 0) {
|
if (updateFields.length === 0) {
|
||||||
return res.status(400).json({ error: 'No settings to update' });
|
return res.status(400).json({ error: 'No settings to update' });
|
||||||
|
|
@ -182,7 +170,7 @@ export default async function handler(req, res) {
|
||||||
id, email, role, first_name, last_name, username,
|
id, email, role, first_name, last_name, username,
|
||||||
collection_visibility, preferred_currency, cards_per_page,
|
collection_visibility, preferred_currency, cards_per_page,
|
||||||
default_view, notifications_email, notifications_marketing,
|
default_view, notifications_email, notifications_marketing,
|
||||||
two_factor_enabled, theme, language, home_zip,
|
two_factor_enabled, theme, language,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -213,8 +201,7 @@ export default async function handler(req, res) {
|
||||||
notifications_email: userData.notifications_email,
|
notifications_email: userData.notifications_email,
|
||||||
notifications_marketing: userData.notifications_marketing,
|
notifications_marketing: userData.notifications_marketing,
|
||||||
theme: userData.theme,
|
theme: userData.theme,
|
||||||
language: userData.language,
|
language: userData.language
|
||||||
home_zip: userData.home_zip ?? null,
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import { sql } from '../../../lib/sql.js';
|
import { sql } from '../../../lib/sql.js';
|
||||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
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) {
|
export default async function handler(req, res) {
|
||||||
if (req.method !== 'GET') {
|
if (req.method !== 'GET') {
|
||||||
|
|
@ -41,11 +36,11 @@ export default async function handler(req, res) {
|
||||||
|
|
||||||
// Get total value of user's cards
|
// Get total value of user's cards
|
||||||
const valueResult = await sql`
|
const valueResult = await sql`
|
||||||
SELECT COALESCE(SUM(${VALUED_QUANTITY}), 0) as total_value
|
SELECT COALESCE(SUM(cards.market_price * user_cards.quantity), 0) as total_value
|
||||||
FROM user_cards
|
FROM user_cards
|
||||||
JOIN cards ON user_cards.card_id = cards.id
|
JOIN cards ON user_cards.card_id = cards.id
|
||||||
WHERE user_cards.user_id = ${user.userId}
|
WHERE user_cards.user_id = ${user.userId}
|
||||||
AND ${HAS_UNIT_PRICE}
|
AND cards.market_price IS NOT NULL
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Get game breakdown
|
// Get game breakdown
|
||||||
|
|
@ -54,7 +49,7 @@ export default async function handler(req, res) {
|
||||||
cards.game,
|
cards.game,
|
||||||
COUNT(DISTINCT cards.id) as unique_cards,
|
COUNT(DISTINCT cards.id) as unique_cards,
|
||||||
COALESCE(SUM(user_cards.quantity), 0) as total_quantity,
|
COALESCE(SUM(user_cards.quantity), 0) as total_quantity,
|
||||||
COALESCE(SUM(${VALUED_QUANTITY}), 0) as total_value
|
COALESCE(SUM(cards.market_price * user_cards.quantity), 0) as total_value
|
||||||
FROM user_cards
|
FROM user_cards
|
||||||
JOIN cards ON user_cards.card_id = cards.id
|
JOIN cards ON user_cards.card_id = cards.id
|
||||||
WHERE user_cards.user_id = ${user.userId}
|
WHERE user_cards.user_id = ${user.userId}
|
||||||
|
|
@ -68,7 +63,7 @@ export default async function handler(req, res) {
|
||||||
cards.rarity,
|
cards.rarity,
|
||||||
COUNT(DISTINCT cards.id) as unique_cards,
|
COUNT(DISTINCT cards.id) as unique_cards,
|
||||||
COALESCE(SUM(user_cards.quantity), 0) as total_quantity,
|
COALESCE(SUM(user_cards.quantity), 0) as total_quantity,
|
||||||
COALESCE(SUM(${VALUED_QUANTITY}), 0) as total_value
|
COALESCE(SUM(cards.market_price * user_cards.quantity), 0) as total_value
|
||||||
FROM user_cards
|
FROM user_cards
|
||||||
JOIN cards ON user_cards.card_id = cards.id
|
JOIN cards ON user_cards.card_id = cards.id
|
||||||
WHERE user_cards.user_id = ${user.userId}
|
WHERE user_cards.user_id = ${user.userId}
|
||||||
|
|
@ -83,7 +78,7 @@ export default async function handler(req, res) {
|
||||||
cards.game,
|
cards.game,
|
||||||
cards.rarity,
|
cards.rarity,
|
||||||
cards.image_url,
|
cards.image_url,
|
||||||
${sql([`${UNIT_PRICE_EXPR} as market_price`])},
|
cards.market_price,
|
||||||
user_cards.quantity,
|
user_cards.quantity,
|
||||||
user_cards.created_at
|
user_cards.created_at
|
||||||
FROM user_cards
|
FROM user_cards
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,6 @@ export default function MyCards() {
|
||||||
const [selectedRarity, setSelectedRarity] = useState('all');
|
const [selectedRarity, setSelectedRarity] = useState('all');
|
||||||
const [selectedSet, setSelectedSet] = useState('all');
|
const [selectedSet, setSelectedSet] = useState('all');
|
||||||
const [selectedValueRange, setSelectedValueRange] = 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 [viewMode, setViewMode] = useState('grid'); // grid or list
|
||||||
const [pagination, setPagination] = useState({
|
const [pagination, setPagination] = useState({
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|
@ -77,8 +75,6 @@ export default function MyCards() {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: currentPage.toString(),
|
page: currentPage.toString(),
|
||||||
limit: pagination.limit.toString(),
|
limit: pagination.limit.toString(),
|
||||||
sort: sortBy,
|
|
||||||
order: sortOrder,
|
|
||||||
...(searchQuery && { search: searchQuery }),
|
...(searchQuery && { search: searchQuery }),
|
||||||
...(selectedTCG !== 'all' && { tcg: selectedTCG }),
|
...(selectedTCG !== 'all' && { tcg: selectedTCG }),
|
||||||
...(selectedRarity !== 'all' && { rarity: selectedRarity }),
|
...(selectedRarity !== 'all' && { rarity: selectedRarity }),
|
||||||
|
|
@ -189,7 +185,7 @@ export default function MyCards() {
|
||||||
loadFavoritedCards();
|
loadFavoritedCards();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- filter-driven reload; fetchCards closes over latest search state
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- filter-driven reload; fetchCards closes over latest search state
|
||||||
}, [user, searchQuery, selectedTCG, selectedRarity, selectedSet, selectedValueRange, sortBy, sortOrder]);
|
}, [user, searchQuery, selectedTCG, selectedRarity, selectedSet, selectedValueRange]);
|
||||||
|
|
||||||
// Infinite scroll
|
// Infinite scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -386,28 +382,6 @@ export default function MyCards() {
|
||||||
<option key={rarity} value={rarity}>{rarity}</option>
|
<option key={rarity} value={rarity}>{rarity}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,7 @@ export default function Settings() {
|
||||||
|
|
||||||
// Display Settings
|
// Display Settings
|
||||||
theme: 'system', // light, dark, system
|
theme: 'system', // light, dark, system
|
||||||
language: 'en',
|
language: 'en'
|
||||||
|
|
||||||
// Local stores
|
|
||||||
home_zip: '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Password change state
|
// Password change state
|
||||||
|
|
@ -534,26 +531,6 @@ export default function Settings() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
// @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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
// @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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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)');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildTcgdexPriceIndex,
|
|
||||||
getTcgdexBaseUrl,
|
|
||||||
matchTcgdexCard,
|
|
||||||
parseTcgdexPricing,
|
|
||||||
} from '../../../lib/pricing/tcgdex-client.js';
|
|
||||||
|
|
||||||
describe('tcgdex-client', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
delete process.env.TCGDEX_BASE_URL;
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses default TCGdex base URL', () => {
|
|
||||||
expect(getTcgdexBaseUrl()).toBe('https://api.tcgdex.net/v2/en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses TCGPlayer USD pricing into current and market prices', () => {
|
|
||||||
const parsed = parseTcgdexPricing({
|
|
||||||
pricing: {
|
|
||||||
tcgplayer: {
|
|
||||||
normal: {
|
|
||||||
lowPrice: 0.5,
|
|
||||||
marketPrice: 1.25,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed).toEqual({
|
|
||||||
currentPrice: 0.5,
|
|
||||||
marketPrice: 1.25,
|
|
||||||
currency: 'USD',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to Cardmarket EUR for market_price when USD is missing', () => {
|
|
||||||
const parsed = parseTcgdexPricing({
|
|
||||||
pricing: {
|
|
||||||
cardmarket: {
|
|
||||||
trend: 2.5,
|
|
||||||
unit: 'EUR',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed.currentPrice).toBeNull();
|
|
||||||
expect(parsed.marketPrice).toBe(2.5);
|
|
||||||
expect(parsed.currency).toBe('EUR');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('matches cards by set card number with leading zero normalization', () => {
|
|
||||||
const index = buildTcgdexPriceIndex([
|
|
||||||
{
|
|
||||||
id: 'me3-18',
|
|
||||||
localId: '18',
|
|
||||||
name: 'Seel',
|
|
||||||
set: { cardCount: { official: 88 } },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const match = matchTcgdexCard(index, {
|
|
||||||
name: 'Seel',
|
|
||||||
cardNumber: '018/88',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(match?.id).toBe('me3-18');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to unique name match within a set index', () => {
|
|
||||||
const index = buildTcgdexPriceIndex([
|
|
||||||
{ id: 'sv1-001', localId: '1', name: 'Sprigatito' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const match = matchTcgdexCard(index, {
|
|
||||||
name: 'Sprigatito',
|
|
||||||
cardNumber: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(match?.id).toBe('sv1-001');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetchSetCards loads set summaries and full card details', async () => {
|
|
||||||
const fetchMock = vi.fn(async (url) => {
|
|
||||||
if (url.endsWith('/sets/me3')) {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({
|
|
||||||
cards: [{ id: 'me3-18', localId: '18', name: 'Seel' }],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (url.endsWith('/cards/me3-18')) {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({
|
|
||||||
id: 'me3-18',
|
|
||||||
localId: '18',
|
|
||||||
name: 'Seel',
|
|
||||||
pricing: {
|
|
||||||
tcgplayer: { normal: { lowPrice: 0.1, marketPrice: 0.2 } },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected url ${url}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
const { fetchSetCards } = await import('../../../lib/pricing/tcgdex-client.js');
|
|
||||||
const cards = await fetchSetCards('me3');
|
|
||||||
|
|
||||||
expect(cards).toHaveLength(1);
|
|
||||||
expect(cards[0].pricing.tcgplayer.normal.marketPrice).toBe(0.2);
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in a new issue