Skip to content

feat(admin): bulk import products from the PriceCharting collection - #299

Merged
elstonyth merged 6 commits into
masterfrom
feat/pc-collection-import
Jul 30, 2026
Merged

feat(admin): bulk import products from the PriceCharting collection#299
elstonyth merged 6 commits into
masterfrom
feat/pc-collection-import

Conversation

@elstonyth

@elstonyth elstonyth commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Adds Inventory › "Add from PC Collection" — the bulk sibling of the existing single-item Add from PriceCharting page. Scan your own PriceCharting collection, filter it, onboard many holdings in one pass through the same creation path (same per-grade price lookup, same single-item endpoint, same batch runner), so an imported product is indistinguishable from a hand-searched one.

Shape follows the live API, not assumptions

Measured against the real collection (9,000+ offers, 8,122 cards):

Pagination The collection is five figures and growing, so GET /admin/pricecharting/collection serves one upstream page per request plus the cursor. The admin page scans with live counters, Stop/Continue, and a 300-row render cap. A server-side walk would be a multi-minute request that reports no progress and dies on any timeout.
seller is REQUIRED Corrected in b3900a5. The token only authenticates; /api/offers takes status as its only mandatory parameter, so a seller-less call returns every user's offers — verified live: thousands of strangers' cards, all user-viewing-own-offers: false, while the operator's own collection was empty. The route refuses to run without PRICECHARTING_SELLER_ID (a 26-char user id from the URL of pricecharting.com/selling-available).
Grade tags are free text Real values: Ungraded, PSA 10, Graded 9, Graded 9.5, BGS 10 Black, CGC 10, SGC 10, ACE 10, plus the video-game condition family (Item, Box, and Manual). gradeForIncludeString maps them onto PRICE_FIELDS labels and returns null when PriceCharting has no field for one — the operator picks the tier rather than the import guessing on a money field.

Money + inventory correctness

  • market_value is always the live per-grade FMV, re-read through /admin/pricecharting/product — never the offer's own valuation. The nightly sync skips moves beyond MAX_SYNC_DELTA_RATIO, so an offer-valued import wouldn't be corrected later, it would stick.
  • Duplicate holdings merge. Two copies of the same card at the same tier generate the same product handle (name-grader-grade-pc_product_id); importing them separately collided and undercounted stock. They now collapse into one draft with units summed.
  • The offer's grade tag beats the price tier for recording grader/grade — a collection offer is the slab, unlike a search hit. Half grades (BGS 9.5) stay blank rather than minting an off-scale grade.
  • Offer images accepted only from PriceCharting's own bucket — the create step stores non-PC URLs verbatim instead of ingesting them, i.e. hotlinks.

Also

CardPokemonFields takes an optional idPrefix (defaults to card, existing call sites unchanged) so N pickers on one screen stop sharing three hardcoded DOM ids. PSA_GRADES moved to lib/format.ts beside gradeToGrader.

Verification

  • backend unit 1038 pass — tag mapping and offer normalization from real upstream rows, plus a stubbed-fetch spec pinning the outgoing request (status=collection, cursor pass-through, seller omitted when unset, junk-row filtering, 502/503 paths)
  • http spec 2 pass on a booted app — route mounted, admin-protected, missing token reads as a setup message
  • admin vitest 155 pass, tsc clean (api + admin), eslint clean
  • Happy path verified once against the live API through the booted route: two cursor pages returned disjoint rows, and a real holding tagged Graded 9 mapped to tier Grade 9 priced at $10.50 with an image. That spec is not committed — CI has no token.

Reviewed via /superpowers:requesting-code-review (zero Critical); all 5 Important findings fixed in this branch.

Follow-up (not in this PR)

Mark already-imported offers in the table — needs a lookup of existing metadata.pc_product_id. Would save the operator scanning 9,000 rows for what they already onboarded.

🤖 Generated with Claude Code

Summary by Sourcery

Add an admin bulk-import flow that scans a seller’s live PriceCharting collection, normalizes offers server-side, and turns selected holdings into product drafts that share the existing single-item PriceCharting creation pipeline.

New Features:

  • Introduce a paginated /admin/pricecharting/collection API that proxies and normalizes PriceCharting seller-collection offers for use in bulk imports.
  • Add an Inventory › Add from PC Collection admin page to scan, filter, and select collection offers, then import them into product drafts using the existing per-grade pricing and batch creation endpoint.

Enhancements:

  • Share PSA grade constants between frontend and backend and add helper logic to derive grader/grade from PriceCharting collection tags while treating half grades as unset.
  • Extend CardPokemonFields with configurable DOM id prefixes so multiple instances can coexist on the same screen without ARIA/label collisions.
  • Normalize and validate PriceCharting collection offer money values and image URLs, ensuring only priceable grade tags and trusted image sources are used.

Tests:

  • Add unit tests for PriceCharting collection grade-tag mapping, offer normalization, and collection route URL building and error handling.
  • Add an integration test to verify the collection endpoint is mounted, admin-protected, and returns a setup error when the PriceCharting API token is missing.

Summary by CodeRabbit

  • New Features

    • Expanded the admin “Add from PC Collection” workflow with operator-controlled grader/grade fields, clearer scan failure visibility, and improved selection controls (including “clear selection”).
    • Added support in the admin collection API response for reporting how many offers were dropped due to mismatched seller ownership (foreign_dropped).
  • Bug Fixes

    • Improved grading parsing/normalization (including half-grade and unrecognized tag handling) and made offer normalization more permissive for offer-id.
    • Tightened collection cursor validation and filter behavior to exclude foreign offers.
  • Documentation / Chores

    • Updated the PriceCharting environment template to clarify requirements for the seller identifier.

Adds Inventory > "Add from PC Collection", the bulk sibling of the existing
single-item Add-from-PriceCharting page. The operator scans their own
PriceCharting collection, filters it, and onboards many holdings in one pass
through the SAME creation path — same per-grade price lookup, same single-item
endpoint, same batch runner — so an imported product is indistinguishable from
a hand-searched one.

Shape follows what the live API actually returns (measured against the real
collection, 9,000+ offers):

- The collection is five figures and still growing, so the route serves ONE
  upstream page per request and hands back the cursor; the admin page scans
  with live counters, a Stop/Continue control, and a 300-row render cap. A
  server-side walk would have been a multi-minute request that reports no
  progress and dies on any timeout.
- `seller` is NOT required: the API token identifies the account (a wrong
  seller 400s upstream with "user not found"). PRICECHARTING_SELLER_ID stays
  supported for the case where the token's account is not the owner.
- Grade tags upstream are free text — "Ungraded", "PSA 10", "Graded 9",
  "BGS 10 Black", "ACE 10", plus the video-game condition family.
  gradeForIncludeString maps them onto PRICE_FIELDS labels and returns null
  for anything PriceCharting has no field for, so the operator picks the tier
  rather than the import guessing on a money field.

Money and inventory correctness:

- market_value is ALWAYS the live per-grade FMV re-read through
  /admin/pricecharting/product, never the offer's own valuation. The nightly
  sync skips moves beyond MAX_SYNC_DELTA_RATIO, so an offer-valued import
  would not be corrected later — it would stick.
- Holdings that would mint the same product (same PriceCharting product at the
  same grade tag) are merged into one draft with units summed. Importing them
  separately collided on the generated handle and undercounted stock.
- The offer's own grade tag wins over the price tier when recording
  grader/grade: a collection offer IS the slab, unlike a search hit. Half
  grades stay blank rather than minting an off-scale grade.

Also: offer images are accepted only from PriceCharting's own bucket (the
create step stores non-PC URLs verbatim instead of ingesting them), and
CardPokemonFields takes an idPrefix so N pickers on one screen stop sharing
three hardcoded DOM ids.

Tests: unit coverage for the tag mapping and offer normalization from real
upstream rows, a stubbed-fetch spec pinning the outgoing request (status,
cursor pass-through, optional seller), and an http spec for mounting + auth +
the missing-token setup message. The happy path was verified once against the
live API through the booted route (cursor pages disjoint; a "Graded 9" holding
priced at its Grade 9 tier) — that spec is not committed, as CI has no token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Implements a new admin flow to bulk-import products from a seller’s PriceCharting collection, including backend collection proxy/normalization and a frontend inventory page that reuses the existing single-item creation path while handling pagination, filtering, per-offer grade mapping, and draft batching.

Sequence diagram for bulk import from PriceCharting collection

sequenceDiagram
  actor AdminUser
  participant AddFromPcCollectionPage
  participant AdminRest as admin-rest
  participant PcCollectionRoute as pc_collection_route_GET
  participant PriceChartingAPI as pricecharting_api
  participant ProductsBatch as useCreateProductsFromPriceChartingBatch

  AdminUser->>AddFromPcCollectionPage: click_scan
  AddFromPcCollectionPage->>AdminRest: getPriceChartingCollection(cursor)
  AdminRest->>PcCollectionRoute: GET /admin/pricecharting/collection
  PcCollectionRoute->>PriceChartingAPI: pcFetch /api/offers status=collection
  PriceChartingAPI-->>PcCollectionRoute: offers_page + cursor
  PcCollectionRoute->>PcCollectionRoute: normalizeOffer(raw)
  PcCollectionRoute-->>AdminRest: {offers, cursor}
  AdminRest-->>AddFromPcCollectionPage: PcOffer[]
  AddFromPcCollectionPage->>AddFromPcCollectionPage: filter_offers(cardsOnly, gradedOnly, text)

  AdminUser->>AddFromPcCollectionPage: select_offers
  AdminUser->>AddFromPcCollectionPage: click_import_selected
  loop per_group(product_id, include)
    AddFromPcCollectionPage->>AdminRest: getPriceChartingProduct(product_id)
    AdminRest-->>AddFromPcCollectionPage: product_with_prices
    AddFromPcCollectionPage->>AddFromPcCollectionPage: graderFromInclude(include)
    AddFromPcCollectionPage->>AddFromPcCollectionPage: gradeToGrader(pcGrade)
    AddFromPcCollectionPage->>AddFromPcCollectionPage: build Draft
  end

  AdminUser->>AddFromPcCollectionPage: complete_drafts(pixel_pokemon_id, stock,...)
  AddFromPcCollectionPage->>AddFromPcCollectionPage: toItem(Draft) -> PcQueueItem
  AdminUser->>AddFromPcCollectionPage: click_save_drafts
  AddFromPcCollectionPage->>ProductsBatch: mutateAsync(PcQueueItem[])
  ProductsBatch-->>AddFromPcCollectionPage: {created, skipped}
  AddFromPcCollectionPage-->>AdminUser: toasts + updated_inventory
Loading

File-Level Changes

Change Details Files
Expose normalized PriceCharting collection offers via a new admin API route for use by the bulk import UI.
  • Add PcOffer normalization utilities, including pennies-to-USD conversion, grade-tag mapping via gradeForIncludeString, and strict PriceCharting image allowlisting
  • Implement GET /admin/pricecharting/collection route that proxies /api/offers?status=collection with cursor pagination, optional seller id, and returns normalized offers plus cursor
  • Add unit tests for offer normalization and route behavior (cursor handling, token/seller behavior, error statuses, junk-row filtering) and an integration test asserting mounting, admin protection, and no-token behavior
backend/packages/api/src/api/admin/pricecharting/collection-offers.ts
backend/packages/api/src/api/admin/pricecharting/collection/route.ts
backend/packages/api/src/api/admin/pricecharting/__tests__/collection-offers.unit.spec.ts
backend/packages/api/src/api/admin/pricecharting/__tests__/collection-route.unit.spec.ts
backend/packages/api/integration-tests/http/pc-collection.spec.ts
Extend grading utilities so the bulk import can preserve slab grader/grade from collection tags while pricing off catalog-supported PSA scale and PriceCharting tiers.
  • Move PSA_GRADES constant into shared admin lib/format and reuse it in GraderGradeSelect to keep client/server grade scales aligned
  • Add graderFromInclude helper that parses collection include strings like “PSA 9”, validates against PSA_GRADES, and intentionally drops half grades to avoid off-scale catalog grades
  • Introduce gradeForIncludeString in packs/pricecharting-grades to map free-text include strings to PRICE_FIELDS labels (including handling Ungraded/raw tags, Gem Mint, graded-number tags, BGS Black Label, and non-priceable tags returning null) with unit coverage ensuring only priceField-resolvable labels are returned
backend/apps/admin/src/lib/format.ts
backend/apps/admin/src/components/GraderGradeSelect.tsx
backend/packages/api/src/modules/packs/pricecharting-grades.ts
backend/apps/admin/src/lib/format.test.ts
backend/packages/api/src/api/admin/pricecharting/__tests__/collection-offers.unit.spec.ts
Add the admin UI for bulk importing products from the PriceCharting collection, reusing the single-item PriceCharting creation path but with collection scanning, filtering, per-offer import, and draft completion before batch save.
  • Create Inventory › Add from PC Collection page that scans the operator’s PriceCharting collection page-by-page via getPriceChartingCollection, shows progress/exhaustion badges, and allows cards-only/graded-only filtering plus text search
  • Implement selection and import of offers into Draft objects keyed by product+grade, collapsing multiple holdings of the same product/tier into one draft with merged stock count, and re-reading per-grade prices via getPriceChartingProduct (including image fallback and label prefill via getTcgCardMeta)
  • Wire USD/MYR display, stock validation/max, and conversion of drafts to PcQueueItem using graderFromInclude (offer tag wins) falling back to gradeToGrader; submit via useCreateProductsFromPriceChartingBatch with capped error toasts and success/skip summaries
  • Render per-draft form controls for tier select, stock, label year/note, and required CardPokemonFields (now namespaced via idPrefix), plus ability to remove drafts and final batch save button whose enabled state depends on all drafts being complete
backend/apps/admin/src/routes/products/from-pc-collection/page.tsx
backend/apps/admin/src/lib/admin-rest.ts
backend/apps/admin/src/routes/cards/CardPokemonFields.tsx

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@elstonyth, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e35d2df6-07c9-4a2c-b020-e33272996006

📥 Commits

Reviewing files that changed from the base of the PR and between 2783dfc and 2fbd970.

📒 Files selected for processing (4)
  • backend/packages/api/src/api/admin/pricecharting/__tests__/collection-offers.unit.spec.ts
  • backend/packages/api/src/api/admin/pricecharting/__tests__/collection-route.unit.spec.ts
  • backend/packages/api/src/api/admin/pricecharting/collection-offers.ts
  • backend/packages/api/src/api/admin/pricecharting/collection/route.ts
📝 Walkthrough

Walkthrough

Adds PriceCharting collection retrieval and admin import support with offer normalization, seller filtering, grading helpers, cursor validation, draft grading, product handle generation, localized copy, and namespaced Pokémon picker identifiers.

Changes

PriceCharting collection import

Layer / File(s) Summary
Grade parsing and shared grade scale
backend/apps/admin/src/lib/format.ts, backend/apps/admin/src/lib/format.test.ts, backend/apps/admin/src/components/GraderGradeSelect.tsx, backend/packages/api/src/modules/packs/pricecharting-grades.ts
Adds canonical PSA grades and PriceCharting include-string parsing/mapping helpers, updates grade selection, and tests valid, half-grade, and invalid formats.
Collection API and offer normalization
backend/packages/api/src/api/admin/pricecharting/..., backend/packages/api/integration-tests/http/pc-collection.spec.ts, backend/packages/api/.env.template
Adds normalized offers, seller filtering, pagination, cursor validation, foreign-row counts, configuration guidance, upstream error handling, and route/unit/integration coverage.
Admin collection import workflow
backend/apps/admin/src/routes/products/from-pc-collection/page.tsx, backend/apps/admin/src/lib/admin-rest.ts, backend/apps/admin/src/i18n/en.json
Adds scan error handling, selection controls, operator-editable grading, draft validation, batch import wiring, and localized workflow messages.
PriceCharting product handles
backend/packages/api/src/workflows/steps/create-product-from-pricecharting.ts, backend/packages/api/src/workflows/steps/__tests__/pc-product-handle.unit.spec.ts
Centralizes handle generation with grader-grade fallback behavior and tests uniqueness, idempotence, and legacy formatting.
Picker instance namespacing
backend/apps/admin/src/routes/cards/CardPokemonFields.tsx
Adds configurable DOM and ARIA identifiers for multiple picker instances.

Estimated code review effort: 4 (Complex) | ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding bulk import from the PriceCharting collection in admin.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/pc-collection-import

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • The new AddFromPcCollectionPage component is very large and mixes scanning, filtering, importing, and draft editing logic in one file; consider extracting some of this into reusable hooks or smaller subcomponents to simplify maintenance and future changes.
  • The cursor length guard in the collection route currently just drops overlong values; you may want to add some lightweight logging/metrics around that branch so it’s easier to detect and debug upstream or client-side issues that produce invalid cursors.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new AddFromPcCollectionPage component is very large and mixes scanning, filtering, importing, and draft editing logic in one file; consider extracting some of this into reusable hooks or smaller subcomponents to simplify maintenance and future changes.
- The cursor length guard in the collection route currently just drops overlong values; you may want to add some lightweight logging/metrics around that branch so it’s easier to detect and debug upstream or client-side issues that produce invalid cursors.

## Individual Comments

### Comment 1
<location path="backend/apps/admin/src/routes/products/from-pc-collection/page.tsx" line_range="80-91" />
<code_context>
+ *  the created handle is name-grader-grade-pc_product_id, so importing them
+ *  separately would collide (the second create fails) and the units held would
+ *  be undercounted. Grouped on import, with stock summed instead. */
+const productKey = (o: PcOffer): string => `${o.product_id}|${o.include}`;
+
+/** Graded in PriceCharting's sense: anything it did not tag "Ungraded". The
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Normalize the include string when building productKey so semantically identical tags dedupe correctly.

Because `productKey` uses the raw `include` value, minor formatting differences (e.g. casing, trailing spaces, or extra spaces) will cause logically identical offers to be grouped separately, which can undercount units by preventing drafts from merging. Please normalize `include` (trim, lowercase, and consider collapsing internal whitespace) before building `productKey` so equivalent tags consistently dedupe.

```suggestion
const offerKey = (o: PcOffer): string =>
  o.offer_id ?? `${o.product_id}|${o.include}`;

/** Normalize the include/grade tag so semantically identical tags dedupe:
 *  - trim leading/trailing whitespace
 *  - lowercase
 *  - collapse internal whitespace runs to a single space */
const normalizedInclude = (include: string): string =>
  include.trim().toLowerCase().replace(/\s+/g, " ");

/** Two holdings of the SAME product at the SAME grade tier become ONE product:
 *  the created handle is name-grader-grade-pc_product_id, so importing them
 *  separately would collide (the second create fails) and the units held would
 *  be undercounted. Grouped on import, with stock summed instead. */
const productKey = (o: PcOffer): string =>
  `${o.product_id}|${normalizedInclude(o.include)}`;

/** Graded in PriceCharting's sense: anything it did not tag "Ungraded". The
 *  slab tier is what a gacha storefront onboards, so the table defaults to it. */
const isGraded = (o: PcOffer): boolean => !/^\s*ungraded\s*$/i.test(o.include);
```
</issue_to_address>

### Comment 2
<location path="backend/apps/admin/src/routes/products/from-pc-collection/page.tsx" line_range="244-254" />
<code_context>
+    }
+  };
+
+  const matches = useMemo(() => {
+    const q = filter.trim().toLowerCase();
+    return (offers ?? []).filter((o) => {
+      if (cardsOnly && !o.is_card) return false;
+      if (gradedOnly && !isGraded(o)) return false;
+      if (q === '') return true;
+      return (
+        o.name.toLowerCase().includes(q) || o.set.toLowerCase().includes(q)
+      );
+    });
+  }, [offers, filter, cardsOnly, gradedOnly]);
+
+  const visible = matches.slice(0, MAX_VISIBLE_ROWS);
+
+  // Picks survive a filter change: selecting a row and then narrowing the
</code_context>
<issue_to_address>
**suggestion:** Filtering logic ignores the include/condition fields, which might limit findability of certain offers.

Currently the filter only matches on `name` and `set`. Since operators may search by grade or condition (e.g. "PSA 10", "Ungraded"), consider also matching against `include` and/or `condition` so users can more easily target specific tiers within large collections.

```suggestion
  const matches = useMemo(() => {
    const q = filter.trim().toLowerCase();
    return (offers ?? []).filter((o) => {
      if (cardsOnly && !o.is_card) return false;
      if (gradedOnly && !isGraded(o)) return false;
      if (q === '') return true;

      const { name, set, include, condition } = o;

      return (
        name.toLowerCase().includes(q) ||
        set.toLowerCase().includes(q) ||
        (include && include.toLowerCase().includes(q)) ||
        (condition && condition.toLowerCase().includes(q))
      );
    });
  }, [offers, filter, cardsOnly, gradedOnly]);
```
</issue_to_address>

### Comment 3
<location path="backend/packages/api/src/api/admin/pricecharting/__tests__/collection-offers.unit.spec.ts" line_range="88-101" />
<code_context>
+  });
+});
+
+describe('penniesToUsd', () => {
+  it('converts integer pennies from a number or a string', () => {
+    expect(penniesToUsd(1999)).toBe(19.99);
+    expect(penniesToUsd('1999')).toBe(19.99);
+    expect(penniesToUsd('$1,999')).toBe(19.99);
+  });
+
+  it('treats absent / zero / junk as no price', () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Extend `penniesToUsd` tests to cover negative and non-integer values.

Current tests only exercise standard integer inputs plus zero/null/undefined/junk strings. Since `penniesToUsd` also handles negatives and non-finite values, please add assertions for cases like `-100`, `1.5`, `Number.POSITIVE_INFINITY`, and a whitespace-padded string (e.g. `' 1999 '`) to capture the expected behavior and protect against regressions if the parsing logic changes.

```suggestion
describe('penniesToUsd', () => {
  it('converts integer pennies from a number or a string', () => {
    expect(penniesToUsd(1999)).toBe(19.99);
    expect(penniesToUsd('1999')).toBe(19.99);
    expect(penniesToUsd('$1,999')).toBe(19.99);
    expect(penniesToUsd(' 1999 ')).toBe(19.99);
  });

  it('handles negative and non-integer values', () => {
    expect(penniesToUsd(-100)).toBe(-1);
    expect(penniesToUsd(1.5)).toBe(0.02);
    expect(penniesToUsd(Number.POSITIVE_INFINITY)).toBeNull();
  });

  it('treats absent / zero / junk as no price', () => {
    expect(penniesToUsd(0)).toBeNull();
    expect(penniesToUsd(null)).toBeNull();
    expect(penniesToUsd(undefined)).toBeNull();
    expect(penniesToUsd('abc')).toBeNull();
  });
});
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread backend/apps/admin/src/routes/products/from-pc-collection/page.tsx Outdated
Comment thread backend/apps/admin/src/routes/products/from-pc-collection/page.tsx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/apps/admin/src/routes/products/from-pc-collection/page.tsx`:
- Around line 614-621: Add an accessible aria-label to the row Checkbox in the
product table, deriving the label from the current offer associated with key.
Keep the existing checked state and setSelected behavior unchanged, and ensure
each row’s label identifies its offer clearly.
- Around line 80-91: The API normalization boundary in normalizeOffer must
reject rows whose offer-id is null, empty, or whitespace-only before they reach
the admin offers list; update the check there and ensure collection/route.ts
excludes those normalization results. Tighten PcOffer.offer_id from nullable to
string, while preserving offerKey and productKey behavior for valid offers.
- Around line 403-407: Update saveDrafts to wrap batchCreate.mutateAsync in a
try/catch, showing err.message through the existing error-toast mechanism when
the batch request fails, then clear drafts as required. Preserve the current
created/skipped handling for successful requests.
- Around line 316-363: Update the draft creation flow around rows.push and
getTcgCardMeta so each newly created draft is appended to drafts state
immediately before starting the metadata lookup, allowing the continuation to
match its key. Remove the trailing bulk append of rows after the loop, while
preserving the existing metadata fill-only behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d49c4a85-77af-454e-9409-b580669c1c09

📥 Commits

Reviewing files that changed from the base of the PR and between 556d55c and bde20c8.

📒 Files selected for processing (14)
  • backend/apps/admin/src/components/GraderGradeSelect.tsx
  • backend/apps/admin/src/i18n/en.json
  • backend/apps/admin/src/lib/admin-rest.ts
  • backend/apps/admin/src/lib/format.test.ts
  • backend/apps/admin/src/lib/format.ts
  • backend/apps/admin/src/routes/cards/CardPokemonFields.tsx
  • backend/apps/admin/src/routes/products/from-pc-collection/page.tsx
  • backend/packages/api/.env.template
  • backend/packages/api/integration-tests/http/pc-collection.spec.ts
  • backend/packages/api/src/api/admin/pricecharting/__tests__/collection-offers.unit.spec.ts
  • backend/packages/api/src/api/admin/pricecharting/__tests__/collection-route.unit.spec.ts
  • backend/packages/api/src/api/admin/pricecharting/collection-offers.ts
  • backend/packages/api/src/api/admin/pricecharting/collection/route.ts
  • backend/packages/api/src/modules/packs/pricecharting-grades.ts

Comment thread backend/apps/admin/src/routes/products/from-pc-collection/page.tsx Outdated
Comment thread backend/apps/admin/src/routes/products/from-pc-collection/page.tsx Outdated
Comment thread backend/apps/admin/src/routes/products/from-pc-collection/page.tsx Outdated
Comment thread backend/apps/admin/src/routes/products/from-pc-collection/page.tsx
elstonyth and others added 2 commits July 30, 2026 02:15
Label prefill was a real bug: drafts were held in a local array and committed
to state only after the whole import loop finished, while the fire-and-forget
getTcgCardMeta continuation patched state by key. Any response arriving while
later products were still being priced found no such key and no-oped, so
year/rarity were dropped for every draft but (maybe) the last. Each draft is
now committed as it is created.

Also from review:

- Require a non-empty per-row offer id at the API normalization boundary and
  tighten PcOffer.offer_id to string. The admin keyed rows by offer_id with a
  fallback to product+tag — which is exactly productKey, so two distinct
  holdings of the same card at the same grade shared a key, one was deduped
  away during the scan, and the units held were undercounted.
- Normalize the grade tag (case + whitespace) when grouping holdings, so
  semantically identical tags still merge into one product.
- Search the grade tag and condition too: inside a five-figure collection
  "psa 10" is as natural a query as a card name.
- Catch a rejected batch save. Per-item failures already fold into `skipped`,
  but a rejection of the mutation itself was an unhandled promise from an
  onClick — nothing happened and the drafts stayed on screen unexplained.
- Give each row checkbox an accessible name (card + grade tag).
- penniesToUsd: cover padded strings, sub-cent rounding, and the no-price
  cases. Note the review's suggested `-100 -> -1` is wrong: a negative
  valuation is not a price, and the guard returns null — asserted as such.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The collection page was importing STRANGERS' cards. PriceCharting's
/api/offers takes `status` as its only required parameter and the API token
authenticates but does NOT scope the query to the token's account, so a call
without `seller` returns every user's offers with that status. Verified live:
a seller-less call came back with thousands of other people's cards (baseball,
Magic, video games), every row carrying `user-viewing-own-offers: false`,
while the operator's own PriceCharting collection page showed 0 items.

An earlier commit removed the seller requirement on the strength of two bad
inferences: that a 200 with data meant "our account", and that `seller=me`
400ing meant the parameter was unnecessary. It 400s because the parameter
takes a 26-character user id, not a username — as does the account's own
display name.

The route now refuses to run without PRICECHARTING_SELLER_ID, with a message
naming where to find the id (the URL of pricecharting.com/selling-available).
Refusing is the only safe default here: the seller-less response is not a
degraded result, it is confidently wrong data that would onboard other
people's cards as our stock at our prices.

Specs updated to match: the unit spec now asserts the seller is sent and that
a missing one short-circuits before any fetch; the http spec covers both
configuration guards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/packages/api/.env.template`:
- Around line 73-78: Add the missing PRICECHARTING_SELLER_ID= declaration
directly beneath the existing PriceCharting user ID documentation in the
environment template, preserving the comments and leaving the value empty for
users to configure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9855a737-4edb-4fbc-99fe-e41653f20d36

📥 Commits

Reviewing files that changed from the base of the PR and between bde20c8 and b3900a5.

📒 Files selected for processing (9)
  • backend/apps/admin/src/i18n/en.json
  • backend/apps/admin/src/lib/admin-rest.ts
  • backend/apps/admin/src/routes/products/from-pc-collection/page.tsx
  • backend/packages/api/.env.template
  • backend/packages/api/integration-tests/http/pc-collection.spec.ts
  • backend/packages/api/src/api/admin/pricecharting/__tests__/collection-offers.unit.spec.ts
  • backend/packages/api/src/api/admin/pricecharting/__tests__/collection-route.unit.spec.ts
  • backend/packages/api/src/api/admin/pricecharting/collection-offers.ts
  • backend/packages/api/src/api/admin/pricecharting/collection/route.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • backend/apps/admin/src/lib/admin-rest.ts
  • backend/apps/admin/src/i18n/en.json
  • backend/packages/api/src/api/admin/pricecharting/tests/collection-offers.unit.spec.ts
  • backend/packages/api/src/api/admin/pricecharting/collection-offers.ts
  • backend/apps/admin/src/routes/products/from-pc-collection/page.tsx

Comment thread backend/packages/api/.env.template
elstonyth and others added 2 commits July 30, 2026 03:27
… checks

Second review round. The headline fix is a collision the FIRST round's fix did
not actually cover.

Product handle collided across generic tiers. The handle is
`name-grader-grade-pc_product_id`, and PriceCharting drops the grading company
below its own top-tier price fields — so a "Graded 9" holding imports with
grader='' AND grade='' (a grade is unrepresentable without a grader, §3a).
"Ungraded" and "Graded 9" of the same card therefore slugged identically
(charizard-v-79-836945 for both) and the second create died on the unique
handle/sku index. Grouping picks by (product_id, tag) did not help: that key is
finer than the handle key, and it does nothing at all across separate batches,
which is exactly how this page is meant to be used. Fixed at the one place
every caller routes through — buildPcProductHandle falls back to the
PriceCharting tier when neither grader nor grade is asserted. Handles that
carry a grader are byte-identical to before, so nothing in the catalog moves;
only previously-impossible handles change shape. Re-importing the same holding
at the same tier still collides, which is what keeps the unique index working
as the idempotency guard.

Ownership is now verified per row rather than assumed from configuration.
PRICECHARTING_SELLER_ID being SET only proves it is set; a stale or typo'd
26-char id returns another account's holdings that import exactly like ours.
Upstream marks every row with `user-viewing-own-offers` — the field that
exposed the original site-wide-feed bug — so the route drops rows explicitly
marked as not ours and reports the count, which the page surfaces as a warning
naming the likely cause. An absent flag is tolerated: a correctly-scoped
response with rows in it has not been observable while the collection is empty.

Grader/grade is now one operator-controlled value. It was derived by two
different rules — display used the offer tag alone, submit used the tag with a
tier fallback — so "BGS 10 Black" showed "no grader" while submitting BGS 10,
and "Graded 9" (the most common graded tag upstream) submitted as raw: no
isGraded, no slab bake, a real PSA 9 landing in the catalog as ungraded with
Grade-9 FMV and no way to correct it on the page. Each draft now mounts
GraderGradeSelect, seeded from the tag or the tier, and that single value is
both shown and submitted.

Also: a failed scan no longer renders as "your collection is empty" (the 503
message is shown in place, and the partial badge is suppressed); numeric
offer-ids are accepted rather than silently emptying the page; clear-selection
for picks hidden behind a filter; label fields capped at the server's 64 chars;
filter placeholder now mentions the grade tag it already searched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the scan

Review feedback: the guard on the upstream cursor silently dropped an overlong
value and answered with page 1. That restart is invisible to the scan loop —
its dedupe discards every row it already has — so the loop spins to the page
cap looking like a hang. A cursor that long is not a cursor, so the route 400s
with a message telling the operator to rescan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@elstonyth

Copy link
Copy Markdown
Owner Author

Addressing the two high-level review points (no inline thread for these):

Cursor guard — done, and stronger than logging (2783dfcd). Logging that branch would have recorded a failure the operator still could not see: dropping the cursor answers with page 1, the scan loop’s dedupe discards every row it already holds, and the loop spins to the 500-page cap looking like a hang. The route now returns 400 with “rescan from the start”, and the unit spec asserts the 400 and that nothing was fetched.

Component size — declining, with reasoning. AddFromPcCollectionPage’s state is one coupled flow: scan feeds the filter, which feeds selection, which feeds the import, which feeds the drafts. Extracting hooks would spread that single flow’s state across files without changing behaviour, on a page that has already been through two review rounds with green tests. The split becomes worthwhile when the page grows a second mode (e.g. a “sold” or “wishlist” source); doing it as a parting refactor buys indirection and regression risk, not clarity. Happy to do it if a maintainer disagrees.

Also for the record: CodeRabbit hit its review rate limit ("next review available in 26 minutes") before it could review 0d819402, so its latest pass never ran — @coderabbitai review if a fresh pass is wanted on the current head.

🤖 Addressed by Claude Code

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Your plan includes PR reviews subject to rate limits. More reviews will be available in 59 minutes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/packages/api/src/api/admin/pricecharting/collection-offers.ts`:
- Around line 80-83: Update the offerId normalization in the collection-offers
parsing flow to accept only string values or finite numeric values from
raw['offer-id']; trim strings and stringify valid numbers, while converting
objects, booleans, nullish values, and non-finite numbers to the existing
empty-value path so they cannot be deduplicated as "[object Object]".

In `@backend/packages/api/src/api/admin/pricecharting/collection/route.ts`:
- Around line 66-71: Update the cursor validation in the collection route so a
supplied req.query.cursor is rejected with HTTP 400 when it is not a string,
rather than coerced to an empty cursor; continue trimming and enforcing
MAX_CURSOR_LENGTH for valid strings. Add a route test covering a non-string
cursor input and verify PriceCharting is not called.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 17e1d656-6265-4cfd-9856-33f040f9916d

📥 Commits

Reviewing files that changed from the base of the PR and between b3900a5 and 2783dfc.

📒 Files selected for processing (9)
  • backend/apps/admin/src/i18n/en.json
  • backend/apps/admin/src/lib/admin-rest.ts
  • backend/apps/admin/src/routes/products/from-pc-collection/page.tsx
  • backend/packages/api/src/api/admin/pricecharting/__tests__/collection-offers.unit.spec.ts
  • backend/packages/api/src/api/admin/pricecharting/__tests__/collection-route.unit.spec.ts
  • backend/packages/api/src/api/admin/pricecharting/collection-offers.ts
  • backend/packages/api/src/api/admin/pricecharting/collection/route.ts
  • backend/packages/api/src/workflows/steps/__tests__/pc-product-handle.unit.spec.ts
  • backend/packages/api/src/workflows/steps/create-product-from-pricecharting.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/apps/admin/src/i18n/en.json

Comment thread backend/packages/api/src/api/admin/pricecharting/collection-offers.ts Outdated
Two review findings, both the same shape: a value coerced instead of refused,
producing a wrong result that looks like a normal one.

- offer-id: String() on an object yields "[object Object]" — a key EVERY such
  row would share. The scan keys rows by offer_id, so two distinct holdings
  would dedupe into one and undercount the units held. Now only a string or a
  finite number is accepted; anything else takes the existing drop path.
- cursor: a repeated ?cursor=a&cursor=b arrives as an ARRAY, which was coerced
  to '' and answered with page 1. That restart is invisible to the scan loop
  (its dedupe discards every row it already holds) and spins to the page cap
  looking like a hang — exactly what the length guard already rejects, so a
  non-string now 400s the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@elstonyth
elstonyth merged commit a45c180 into master Jul 30, 2026
21 checks passed
@elstonyth
elstonyth deleted the feat/pc-collection-import branch July 30, 2026 09:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant