Skip to content

TCGplayer Import PR 4 — Sealed Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Import the Unopened rows of a TCGplayer Pricing Custom Export as sealed products, matched to our catalog through TCGplayer's own product ids and stocked at absolute quantity.

Architecture: Sealed rows never join the singles lane. They keep match.status: 'sealed' and gain a match.sealedUuid, so PR 2's phase-3 query ('match.status': 'matched') is untouched by construction. Matching happens in the preview (runMatch) so the merchant sees sealed counts and pays for them in the tier estimate before confirming; execution is a new phase 4 in runImport that drives each matched sealed line through the existing quickAddSealedProduct with a new absolute-quantity mode. The name-to-uuid bridge is TCGCSV ProductsAndPrices.csv name -> productId -> MTGJSON identifiers.tcgplayerProductId -> uuid, fetched only for groups that actually contain sealed rows.

Tech Stack: Node 24 CommonJS server, Mongoose, Vitest (ESM tests) with the _setDeps()/_resetDeps() injection seam, BullMQ worker, React 18 + retroui client.

Spec: docs/superpowers/specs/2026-08-04-tcgplayer-inventory-import-design.md (section "Sealed matching (PR 4)", Phasing row 4)

Global Constraints

  • The join key is a string on both sides. TCGCSV ProductsAndPrices.csv yields row.productId as a string; MTGJSON's set.data.sealedProduct[].identifiers.tcgplayerProductId is a string. The per-store SealedProduct.identifiers.tcgplayerProductId is typed Number (server/models/SealedProduct.js:54, written via parseInt at server/plugins/mtg/index.js:537). Never compare these without String(...) on both sides — a Number/string mismatch does not throw, it silently matches nothing (§5.4, §5.3). Every map key and every lookup in this PR is String(id).trim().
  • Sealed rows keep match.status: 'sealed'. Do not reuse 'matched'. runImport phase 3 selects {'match.status': 'matched'} and runs distinct('match.setCode') on it (server/services/tcgImportRunService.js:227); promoting a sealed row to 'matched' would push it into syncSetDirect as if it were a card.
  • game is never defaulted — it is read from importDoc.game and passed explicitly (§5.5). No || 'mtg' anywhere in the diff.
  • All Shopify traffic goes through server/services/shopifyAPI.js (rule 6). setInventoryQuantity(inventoryItemId, locationId, quantity) already exists at shopifyAPI.js:1643 — do not add a second absolute-inventory primitive.
  • No price-lock work for sealed. Verified: priceUpdateService, priceUpdateProcessor and every file under server/queues/ contain zero references to SealedProduct, and no service bulk-rewrites currentPrice. Nothing automatically re-prices a sealed product, so PR 3's price_locked metafield has nothing to protect here. priceMode decides which price is written at create time and nothing else. Do not add a sealed price-lock metafield (§5.9 — nothing would read it).
  • Parity (§5.1): MTG-only by decision, unchanged from PRs 1–3. pokemon and riftbound are exempt — unverified: no real Pokemon or Riftbound export exists, so the Product Line literal they write is unknown and inventing it is the §5.4 failure mode. The classifier already routes unrecognized product lines to unsupported_line. The sealed path additionally inherits the existing plugin.supportsSealed gate in quickAddSealedProduct (server/services/sealedQuickAddService.js:284).
  • Coverage: every modified file ≥70% on all four metrics, read from the per-file table by hand — the Vitest threshold gate is silently inoperative (§7).
  • Reads must not materialise unbounded sets (§5.6). Sealed lines are bounded (4 rows in the reference export) and may use find().lean(); the sealed catalog scan is bounded by set count and already .lean().

Task 1: Verify the join on real documents

The spec gates this whole PR on one unverified assumption: that real catalog documents carry identifiers.tcgplayerProductId. If they don't, the fallback is setCode + exact name and the rest of the plan changes. This task writes no product code — it produces evidence.

Files:

  • Create: server/scripts/verify/verifySealedTcgplayerIds.js (throwaway verification script, deleted in Task 7)

Interfaces:

  • Consumes: server/models/SetModel.js (the MTG sealed catalog lives at set.data.sealedProduct[]).

  • Produces: evidence only — a coverage percentage and a typeof histogram quoted in the commit message. No code other tasks import.

  • [ ] Step 1: Confirm which database .env points at

.env is sometimes set to the production Atlas connection string (§8). This script is read-only so either is safe, but you must know which you measured.

bash
node -e "require('dotenv').config(); const u=process.env.MONGODB_URI||''; console.log(u.includes('localhost')?'LOCAL':'REMOTE/ATLAS', u.replace(/:[^:@]+@/,':***@'))"

Expected: prints LOCAL or REMOTE/ATLAS plus the redacted URI. Record which one in the commit message.

  • [ ] Step 2: Write the verification script
js
/**
 * Throwaway: proves the PR 4 join key exists on real catalog documents.
 * Read-only. Deleted in Task 7 once the real tests encode the same facts.
 */
require('dotenv').config();
const mongoose = require('mongoose');
const SetModel = require('../../models/SetModel');

(async () => {
    await mongoose.connect(process.env.MONGODB_URI);

    const sets = await SetModel.find({ 'data.sealedProduct.0': { $exists: true } })
        .select('data.code data.sealedProduct').lean();

    let total = 0;
    let withId = 0;
    const types = new Map();
    const samples = [];

    for (const set of sets) {
        for (const product of (set.data.sealedProduct || [])) {
            total++;
            const id = product.identifiers && product.identifiers.tcgplayerProductId;
            if (id === undefined || id === null || id === '') continue;
            withId++;
            types.set(typeof id, (types.get(typeof id) || 0) + 1);
            if (samples.length < 5) {
                samples.push({ set: set.data.code, name: product.name, uuid: product.uuid, id, type: typeof id });
            }
        }
    }

    console.log(`sets with sealed: ${sets.length}`);
    console.log(`sealed products: ${total}`);
    console.log(`carrying tcgplayerProductId: ${withId} (${((withId / total) * 100).toFixed(1)}%)`);
    console.log('typeof histogram:', Object.fromEntries(types));
    console.log('samples:', JSON.stringify(samples, null, 2));

    await mongoose.disconnect();
})();
  • [ ] Step 3: Run it and read the output
bash
node server/scripts/verify/verifySealedTcgplayerIds.js

Expected: a coverage percentage and a typeof histogram. The histogram is the deliverable. Paste the real output into the commit message.

Decision gate — do not skip:

  • Coverage is high and typeof is string → proceed to Task 2 as written.

  • Coverage is high but typeof is number → still proceed; the String() normalization in Tasks 3 and 4 already covers it, and Task 4 Step 1's test pins both. Note it in the commit.

  • Coverage is low (most sealed products carry no id) → stop and report. The spec's fallback is setCode + exact name against the catalog, which changes Tasks 3 and 4 materially. Bring the numbers back before writing code.

  • [ ] Step 4: Commit the evidence

bash
git add server/scripts/verify/verifySealedTcgplayerIds.js
git commit -m "Verify sealed catalog products carry TCGplayer product ids"

Task 2: Absolute-quantity mode for sealed quick-add

Files:

  • Modify: server/services/sealedQuickAddService.js (the two if (quantity > 0) inventory blocks — one in the existing-product branch, one in the new-product branch — plus the function's JSDoc)
  • Test: server/services/sealedQuickAddService.test.js

Interfaces:

  • Consumes: shopifyAPI.setInventoryQuantity(inventoryItemId, locationId, quantity) (server/services/shopifyAPI.js:1643), already shipped in PR 2.

  • Produces: quickAddSealedProduct({ shop, store, accessToken, game, uuid, quantity, price, quantityMode }) where quantityMode is 'add' (default — existing additive behaviour) or 'set' (absolute). Return shape unchanged: { created, product, priceUpdated, quantityAdded, quantityWarning }.

  • [ ] Step 1: Write the failing tests — both behaviours

The spec requires a test for each mode; the additive test is the regression guard for the catalog quick-add UI, which means "add N more". Follow the existing file's stubbing style rather than inventing one — it already builds a ShopifyAPI stub for these paths.

js
describe('quickAddSealedProduct quantityMode', () => {
    it('defaults to additive inventory so the catalog quick-add keeps meaning "add N more"', async () => {
        const shopifyAPI = makeShopifyAPIStub();
        await quickAddSealedProduct({ ...baseArgs, quantity: 3 });

        expect(shopifyAPI.addInventoryQuantity).toHaveBeenCalledWith('gid://inv/1', 'gid://loc/1', 3);
        expect(shopifyAPI.setInventoryQuantity).not.toHaveBeenCalled();
    });

    it('sets absolute inventory when quantityMode is "set" so a re-run is idempotent', async () => {
        const shopifyAPI = makeShopifyAPIStub();
        await quickAddSealedProduct({ ...baseArgs, quantity: 3, quantityMode: 'set' });

        expect(shopifyAPI.setInventoryQuantity).toHaveBeenCalledWith('gid://inv/1', 'gid://loc/1', 3);
        expect(shopifyAPI.addInventoryQuantity).not.toHaveBeenCalled();
    });
});

Write the equivalent pair for the new-product branch (no existing SealedProduct document), since the two inventory blocks are separate code paths and a change applied to only one is exactly the §5.8 shape.

  • [ ] Step 2: Run to verify they fail
bash
npx vitest run server/services/sealedQuickAddService.test.js

Expected: FAIL — setInventoryQuantity is never called.

  • [ ] Step 3: Implement

Add the parameter with an explicit default:

js
async function quickAddSealedProduct({ shop, store, accessToken, game, uuid, quantity, price, quantityMode = 'add' }) {

Extract the write so both branches share it — the two blocks are already near-duplicates, and adding a mode to each independently is how they drift (§5.8):

js
/**
 * Apply a quantity to a sealed variant.
 *
 * 'add' is the default because that is what the catalog quick-add UI means: a
 * merchant typing 3 has three more boxes on the shelf. The TCGplayer import
 * passes 'set' — its Total Quantity column is the merchant's whole on-hand
 * count, so re-running the same file must converge rather than double the shelf.
 */
async function applySealedQuantity(shopifyAPI, shopifyVariantId, quantity, quantityMode) {
    const inventoryItemId = await shopifyAPI.getVariantInventoryItemId(shopifyVariantId);
    const locationId = await shopifyAPI.getDefaultLocationId();
    if (!locationId || !inventoryItemId) {
        throw new Error('No active Shopify location found');
    }
    if (quantityMode === 'set') {
        await shopifyAPI.setInventoryQuantity(inventoryItemId, locationId, quantity);
    } else {
        await shopifyAPI.addInventoryQuantity(inventoryItemId, locationId, quantity);
    }
}

Replace the body of both if (quantity > 0) blocks with a call to it, keeping each block's existing try/catch, its distinct quantityWarning copy, and quantityAdded = quantity.

  • [ ] Step 4: Run tests
bash
npx vitest run server/services/sealedQuickAddService.test.js server/services/buylistIntakeService.test.js

Expected: PASS. buylistIntakeService is the existing second caller — it passes no quantityMode and must keep additive behaviour.

  • [ ] Step 5: Commit
bash
git add server/services/sealedQuickAddService.js server/services/sealedQuickAddService.test.js
git commit -m "Let a sealed quick-add set an absolute quantity instead of adding to it"

Task 3: TCGCSV sealed name-to-product-id resolver

Files:

  • Create: server/services/tcgcsvSealedService.js
  • Modify: server/services/tcgcsvGroupService.js (add and export httpsGetText)
  • Test: server/services/tcgcsvSealedService.test.js

Interfaces:

  • Consumes: REQUEST_OPTIONS and REQUEST_TIMEOUT_MS already in tcgcsvGroupService.js; parseCsvRows from server/utils/tcgplayerCsv.js.

  • Produces: resolveSealedProductIds(groupIds)Promise<Map<number, Map<string, string>>> — outer key is the groupId, inner map is normalized product name → productId as a string. Also exports normalizeName(name) => string (Task 4 uses it to normalize the CSV's productName), plus _setDeps({ httpGet, redis }) / _resetDeps().

  • [ ] Step 1: Confirm parseCsvRows's real signature

tcgImportService.js calls await parseCsvRows(csvText). Confirm that is what server/utils/tcgplayerCsv.js exports before writing against it — do not adapt the call from memory (§5.4).

bash
grep -n "parseCsvRows" server/utils/tcgplayerCsv.js

Expected: a function taking CSV text and returning parsed row objects. If the signature differs, match the real one.

  • [ ] Step 2: Write the failing tests
js
import { describe, it, expect, afterEach } from 'vitest';
import service from '../../server/services/tcgcsvSealedService.js';

const CSV = [
    'productId,name,cleanName,extUPC,marketPrice',
    '522559,Secret Lair Drop: Calling All Hydra Heads,Calling All Hydra Heads,,44.21',
    '123456,Ugins Fate Booster Pack,Ugins Fate Booster Pack,,9.99'
].join('\n');

afterEach(() => service._resetDeps());

describe('resolveSealedProductIds', () => {
    it('returns product ids as strings, keyed by normalized name', async () => {
        service._setDeps({ httpGet: async () => CSV, redis: null });

        const group = (await service.resolveSealedProductIds([23874])).get(23874);
        const id = group.get('secret lair drop: calling all hydra heads');

        expect(id).toBe('522559');
        // A string, not a number: the catalog side is MTGJSON's string, and a
        // Number key silently matches nothing (§5.4).
        expect(typeof id).toBe('string');
    });

    it('matches case- and whitespace-insensitively', async () => {
        service._setDeps({ httpGet: async () => CSV, redis: null });
        const group = (await service.resolveSealedProductIds([23874])).get(23874);
        expect(group.get('ugins fate booster pack')).toBe('123456');
    });

    it('fetches each group exactly once', async () => {
        let calls = 0;
        service._setDeps({ httpGet: async () => { calls++; return CSV; }, redis: null });
        await service.resolveSealedProductIds([23874, 23874]);
        expect(calls).toBe(1);
    });

    it('degrades to an empty map for a group TCGCSV will not serve', async () => {
        service._setDeps({ httpGet: async () => { throw new Error('HTTP 404'); }, redis: null });
        const out = await service.resolveSealedProductIds([999999]);
        // One unreachable group must not fail the whole preview — its rows fall
        // to unmatched with a plain reason instead.
        expect(out.get(999999).size).toBe(0);
    });
});
  • [ ] Step 3: Run to verify they fail
bash
npx vitest run server/services/tcgcsvSealedService.test.js

Expected: FAIL — module not found.

  • [ ] Step 4: Add a text fetch to tcgcsvGroupService

httpsGetJSON there parses JSON; ProductsAndPrices.csv is text. Add the sibling next to it and export it, rather than writing a second https.get wrapper (§5.8). Keep REQUEST_OPTIONS — TCGCSV blocks unidentified agents with a plaintext body (verified 2026-08-04).

js
function httpsGetText(url, get = https.get) {
    return new Promise((resolve, reject) => {
        const request = get(url, REQUEST_OPTIONS, (res) => {
            if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
                res.resume();
                return httpsGetText(res.headers.location, get).then(resolve, reject);
            }
            if (res.statusCode !== 200) {
                res.resume();
                return reject(new Error(`TCGCSV returned HTTP ${res.statusCode}`));
            }
            let body = '';
            res.on('data', (chunk) => { body += chunk; });
            res.on('end', () => resolve(body));
        }).on('error', reject);
        request.setTimeout(REQUEST_TIMEOUT_MS, () => {
            request.destroy();
            reject(new Error(`TCGCSV request timed out after ${REQUEST_TIMEOUT_MS}ms`));
        });
    });
}

Add httpsGetText to that file's module.exports.

  • [ ] Step 5: Implement the resolver
js
/**
 * TCGplayer sealed product-name bridge.
 *
 * A sealed CSV row names a product the way TCGplayer does; our catalog knows it
 * by MTGJSON uuid. The two are joined through TCGplayer's own product id, which
 * MTGJSON records as identifiers.tcgplayerProductId:
 *
 *   Product Name -> TCGCSV product row -> productId -> uuid
 *
 * The export's own `TCGplayer Id` column cannot be used: it is SKU-level, not
 * product-level. Measured on the reference export — "Secret Lair Drop: Calling
 * All Hydra Heads (WPN Exclusive) - Traditional Foil Edition" is TCGCSV
 * productId 522559 while the export writes 7488026.
 *
 * DEMAND-DRIVEN. ProductsAndPrices.csv runs to ~18,764 rows for a group like
 * Secret Lair, so it is fetched only for groups that actually contain sealed
 * rows — 2 of 319 groups on the reference export.
 *
 * productId is returned as a STRING. The catalog side is MTGJSON's string and
 * the per-store SealedProduct field is a Number; a mismatched type does not
 * throw, it matches nothing (§5.4).
 */
'use strict';

const { httpsGetText } = require('./tcgcsvGroupService');
const { getRedisConnection } = require('../config/redis');
const { parseCsvRows } = require('../utils/tcgplayerCsv');
const logger = require('../utils/logger');

const TCGCSV_BASE = 'https://tcgcsv.com/tcgplayer';
const MTG_CATEGORY_ID = 1;
const CACHE_TTL_SECONDS = 86400; // 24h — TCGCSV publishes daily.

let _deps = null;
function _setDeps(deps) { _deps = deps; }
function _resetDeps() { _deps = null; }
function getDeps() {
    if (_deps) return _deps;
    let redis;
    try { redis = getRedisConnection(); } catch { redis = null; }
    return { httpGet: httpsGetText, redis };
}

function normalizeName(name) {
    return String(name || '').toLowerCase().trim();
}

async function loadGroupProducts(groupId, deps) {
    const cacheKey = `tcgcsv:mtg:sealed:v1:${groupId}`;
    if (deps.redis) {
        try {
            const cached = await deps.redis.get(cacheKey);
            if (cached) return new Map(JSON.parse(cached));
        } catch (err) {
            logger.warn('TCGCSV sealed cache read failed; fetching live', { groupId, error: err.message });
        }
    }

    // Built from a groupId resolved through our own set bridge, never from CSV
    // text — no user-controlled outbound fetch.
    const url = `${TCGCSV_BASE}/${MTG_CATEGORY_ID}/${groupId}/ProductsAndPrices.csv`;

    let byName;
    try {
        const rows = await parseCsvRows(await deps.httpGet(url));
        byName = new Map();
        for (const row of rows) {
            const productId = String(row.productId || '').trim();
            const name = normalizeName(row.name);
            if (!productId || !name) continue;
            // First row wins: ProductsAndPrices carries one row per product, but
            // a duplicate name would otherwise resolve to whichever row happened
            // to come last.
            if (!byName.has(name)) byName.set(name, productId);
        }
    } catch (err) {
        // One unreachable group must not fail the whole preview. Its rows fall
        // to unmatched with a plain reason, which the merchant can act on; a
        // thrown preview is not.
        logger.warn('TCGCSV sealed product list unavailable for group', { groupId, error: err.message });
        return new Map();
    }

    if (deps.redis && byName.size) {
        try {
            await deps.redis.setex(cacheKey, CACHE_TTL_SECONDS, JSON.stringify([...byName]));
        } catch (err) {
            logger.warn('TCGCSV sealed cache write failed', { groupId, error: err.message });
        }
    }
    return byName;
}

/**
 * @param {number[]} groupIds - TCGCSV group ids, from our own set bridge.
 * @returns {Promise<Map<number, Map<string, string>>>} groupId -> (normalized
 *   product name -> productId as a string). An unreachable group yields an
 *   empty inner map, never a throw.
 */
async function resolveSealedProductIds(groupIds) {
    const deps = getDeps();
    const out = new Map();
    for (const groupId of new Set(groupIds)) {
        out.set(groupId, await loadGroupProducts(groupId, deps));
    }
    return out;
}

module.exports = { resolveSealedProductIds, normalizeName, _setDeps, _resetDeps };
  • [ ] Step 6: Run tests
bash
npx vitest run server/services/tcgcsvSealedService.test.js server/services/tcgcsvGroupService.test.js

Expected: PASS, including the existing group-service tests.

  • [ ] Step 7: Commit
bash
git add server/services/tcgcsvSealedService.js server/services/tcgcsvSealedService.test.js server/services/tcgcsvGroupService.js
git commit -m "Resolve TCGplayer sealed product names to product ids"

Task 4: Match sealed rows during the preview

Files:

  • Modify: server/models/TcgImportLine.js (add match.sealedUuid)
  • Modify: server/models/TcgImport.js (add counts.sealedMatched, counts.sealedUnmatched)
  • Modify: server/services/tcgImportService.js (runMatch — sealed pass; getDeps — two new deps)
  • Test: server/models/TcgImportLine.test.js (§5.2 round-trip)
  • Test: server/services/tcgImportService.test.js

Interfaces:

  • Consumes: resolveSealedProductIds, normalizeName (Task 3); plugin.findSealedProducts({ setCodes }) (server/plugins/mtg/index.js:444) which returns [{ product, setData }] where product carries uuid, name and identifiers.

  • Produces: on each sealed line, match.sealedUuid (String, null when unmatched) and a merchant-facing match.reason; on the import, counts.sealedMatched and counts.sealedUnmatched. Matched sealed rows are added to distinctProducts, so the tier estimate includes them.

  • [ ] Step 1: Write the failing model round-trip test (§5.2)

A new sub-document field that is not declared is dropped by strict mode with no error. This test fails if the schema line is deleted.

js
it('round-trips match.sealedUuid so strict mode cannot drop it', async () => {
    const line = await TcgImportLine.create({
        importId: new mongoose.Types.ObjectId(),
        shop: 'test.myshopify.com',
        match: { status: 'sealed', sealedUuid: 'b3f1a2c4-0000-4000-8000-000000000001' }
    });

    const read = await TcgImportLine.findById(line._id).lean();
    expect(read.match.sealedUuid).toBe('b3f1a2c4-0000-4000-8000-000000000001');
});
  • [ ] Step 2: Write the failing matcher tests
js
describe('runMatch sealed pass', () => {
    it('joins a sealed row to a catalog uuid through the TCGplayer product id', async () => {
        // Catalog side is MTGJSON's STRING id; TCGCSV side is a string too.
        // Both are normalized with String() so a Number on either side still
        // joins (§5.4) — this is the one trap in PR 4.
        const line = await readLine(importId, 'Secret Lair Drop: Calling All Hydra Heads');
        expect(line.match.status).toBe('sealed');
        expect(line.match.sealedUuid).toBe('uuid-hydra-heads');
        expect(line.match.setCode).toBe('SLD');
    });

    it('joins even when the catalog stores the product id as a Number', async () => {
        // findSealedProducts stub returns identifiers.tcgplayerProductId: 522559
        const line = await readLine(importId, 'Secret Lair Drop: Calling All Hydra Heads');
        expect(line.match.sealedUuid).toBe('uuid-hydra-heads');
    });

    it('leaves an unmatched sealed row with a plain reason and no uuid', async () => {
        const line = await readLine(importId, 'Some Product We Do Not Carry');
        expect(line.match.status).toBe('sealed');
        expect(line.match.sealedUuid).toBeNull();
        expect(line.match.reason).toMatch(/couldn.t match/i);
    });

    it('never promotes a sealed row to matched, so phase 3 cannot pick it up', async () => {
        const matched = await TcgImportLine.find({ importId, 'match.status': 'matched' }).lean();
        expect(matched.every((l) => !l.match.sealedUuid)).toBe(true);
    });

    it('counts matched sealed products toward the tier estimate', async () => {
        const doc = await TcgImport.findById(importId).lean();
        expect(doc.counts.sealedMatched).toBe(1);
        expect(doc.counts.sealedUnmatched).toBe(1);
        // One matched card + one matched sealed product = two products to create.
        expect(doc.counts.distinctProducts).toBe(2);
    });
});
  • [ ] Step 3: Run to verify they fail
bash
npx vitest run server/models/TcgImportLine.test.js server/services/tcgImportService.test.js

Expected: FAIL — sealedUuid is dropped by strict mode; sealed rows still carry the PR 1 placeholder reason.

  • [ ] Step 4: Declare the new fields

In server/models/TcgImportLine.js, inside the match sub-document, after finish:

js
        // Sealed only. Null on an unmatched sealed row: match.status stays
        // 'sealed' either way so runImport's phase-3 query
        // ({'match.status': 'matched'}) can never pull a sealed row into
        // syncSetDirect. Phase 4 selects on sealedUuid != null instead.
        sealedUuid: String

In server/models/TcgImport.js, inside counts, beside the existing sealed:

js
        // Sub-counts of `sealed`, which stays the total of sealed rows seen so
        // totalRows still reconciles across buckets.
        sealedMatched: Number,
        sealedUnmatched: Number,
  • [ ] Step 5: Add the two deps

In tcgImportService.js's getDeps() default object, alongside the existing entries:

js
    resolveSealedProductIds: require('./tcgcsvSealedService').resolveSealedProductIds,
    normalizeSealedName: require('./tcgcsvSealedService').normalizeName,
  • [ ] Step 6: Implement the sealed pass in runMatch

Insert after the singles cursor loop's await flushWrites(); and before counts.distinctProducts = distinctProducts.size;, so matched sealed products are counted in the tier estimate:

js
        // === Sealed pass ===
        // Runs after the singles loop and before the tier verdict: a sealed
        // product is a product, and a merchant must not be told they fit a tier
        // that these push them past.
        //
        // Sealed rows keep match.status 'sealed' whether or not they match. The
        // uuid is the signal phase 4 selects on. Promoting them to 'matched'
        // would feed them to syncSetDirect as if they were cards.
        const sealedLines = await deps.TcgImportLine
            .find({ importId: importDoc._id, shop, 'match.status': 'sealed' })
            .lean();

        if (sealedLines.length) {
            // Demand-driven: only groups that actually carry sealed rows, so the
            // 18,764-row Secret Lair payload is paid for only when needed.
            const sealedGroupIds = [...new Set(
                sealedLines
                    .map((line) => (setMapRaw.get((line.raw || {}).setName || '') || {}).groupId)
                    .filter((id) => id != null)
            )];
            const sealedSetCodes = [...new Set(
                sealedLines
                    .flatMap((line) => ((setMapRaw.get((line.raw || {}).setName || '') || {}).setCodes) || [])
            )];

            const productIdsByGroup = sealedGroupIds.length
                ? await deps.resolveSealedProductIds(sealedGroupIds)
                : new Map();

            // Catalog side of the join, keyed by STRING product id. MTGJSON
            // writes this as a string and the per-store SealedProduct field is a
            // Number (models/SealedProduct.js:54); String() on both sides is
            // what keeps a type difference from silently matching nothing
            // (§5.4). Bounded by the sealed sets in this file, not the catalog.
            const catalogByProductId = new Map();
            if (sealedSetCodes.length) {
                const plugin = deps.getPlugin(game);
                if (plugin.supportsSealed) {
                    for (const { product, setData } of await plugin.findSealedProducts({ setCodes: sealedSetCodes })) {
                        const rawId = product.identifiers && product.identifiers.tcgplayerProductId;
                        if (rawId === undefined || rawId === null || rawId === '') continue;
                        const key = String(rawId).trim();
                        if (!catalogByProductId.has(key)) {
                            catalogByProductId.set(key, { uuid: product.uuid, setCode: setData.code });
                        }
                    }
                }
            }

            let sealedWrites = [];
            for (const line of sealedLines) {
                const raw = line.raw || {};
                const resolved = setMapRaw.get(raw.setName || '') || null;
                const group = resolved && resolved.groupId != null
                    ? productIdsByGroup.get(resolved.groupId)
                    : null;
                const productId = group
                    ? group.get(deps.normalizeSealedName(raw.productName || ''))
                    : null;
                const hit = productId ? catalogByProductId.get(String(productId).trim()) : null;

                const match = hit
                    ? { status: 'sealed', reason: null, sealedUuid: hit.uuid, setCode: hit.setCode,
                        cardUuid: null, collectorNumber: null, rarity: null, finish: null }
                    : { status: 'sealed', sealedUuid: null, setCode: null,
                        reason: `We couldn't match this sealed product to our catalog: ${raw.productName || 'unnamed row'}`,
                        cardUuid: null, collectorNumber: null, rarity: null, finish: null };

                if (hit) {
                    counts.sealedMatched = (counts.sealedMatched || 0) + 1;
                    counts.units += (line.parsed && line.parsed.quantity) || 0;
                    distinctProducts.add(`sealed|${hit.uuid}`);
                    distinctSets.add(hit.setCode);
                } else {
                    counts.sealedUnmatched = (counts.sealedUnmatched || 0) + 1;
                    if (unmatchedSample.length < UNMATCHED_SAMPLE_SIZE) {
                        unmatchedSample.push({
                            setName: raw.setName || '',
                            productName: raw.productName || '',
                            number: '',
                            reason: match.reason
                        });
                    }
                }

                sealedWrites.push({ updateOne: { filter: { _id: line._id }, update: { $set: { match } } } });
                if (sealedWrites.length >= MATCH_WRITE_BATCH_SIZE) {
                    await deps.TcgImportLine.bulkWrite(sealedWrites);
                    sealedWrites = [];
                }
            }
            if (sealedWrites.length) await deps.TcgImportLine.bulkWrite(sealedWrites);
        }

Initialise both sub-counts to 0 where counts is built at the top of runMatch, beside matched: 0, unmatched: 0:

js
        sealedMatched: 0, sealedUnmatched: 0,

sealedLines uses find().lean(), not a cursor, deliberately: sealed rows are 4 of 8,514 on the reference export, and the sealed lane is bounded by how many Unopened rows a merchant stocks. If that assumption ever changes, this becomes the §5.6 shape and must move to a cursor.

  • [ ] Step 7: Run tests
bash
npx vitest run server/models/TcgImportLine.test.js server/models/TcgImport.test.js server/services/tcgImportService.test.js

Expected: PASS.

  • [ ] Step 8: Commit
bash
git add server/models/TcgImportLine.js server/models/TcgImport.js server/services/tcgImportService.js server/models/TcgImportLine.test.js server/services/tcgImportService.test.js
git commit -m "Match sealed rows in a TCGplayer import preview to catalog products"

Task 5: Phase 4 — stock the sealed products

Files:

  • Modify: server/services/tcgImportRunService.js (getDeps + a new phase 4 after the phase-3 loop)
  • Test: server/services/tcgImportRunService.test.js

Interfaces:

  • Consumes: quickAddSealedProduct with quantityMode: 'set' (Task 2); match.sealedUuid (Task 4).

  • Produces: per-line result.action of 'stocked' or 'failed' with result.error; result.productId set to the Shopify product id; summary.sealedStocked folded into the import's summary line and progress.sealedDone as the checkpoint.

  • [ ] Step 1: Write the failing tests

js
describe('runImport phase 4 (sealed)', () => {
    it('stocks a matched sealed line at absolute quantity', async () => {
        await runImport({ ...baseArgs });

        expect(deps.quickAddSealedProduct).toHaveBeenCalledWith(expect.objectContaining({
            game: 'mtg',
            uuid: 'uuid-hydra-heads',
            quantity: 2,
            quantityMode: 'set'
        }));
    });

    it('passes no price in sync mode so catalog pricing applies', async () => {
        await runImport({ ...baseArgs, priceMode: 'sync' });
        const call = deps.quickAddSealedProduct.mock.calls[0][0];
        // undefined, not null or 0: quickAddSealedProduct treats undefined as
        // "no opinion" and prices from the catalog the way Browse & Import does.
        expect(call.price).toBeUndefined();
    });

    it('passes the file price in locked mode', async () => {
        await runImport({ ...baseArgs, priceMode: 'locked' });
        expect(deps.quickAddSealedProduct.mock.calls[0][0].price).toBe(44.21);
    });

    it('skips sealed lines that never matched', async () => {
        await runImport({ ...baseArgs });
        const uuids = deps.quickAddSealedProduct.mock.calls.map((c) => c[0].uuid);
        expect(uuids).not.toContain(null);
    });

    it('records a failing sealed line and keeps going', async () => {
        deps.quickAddSealedProduct
            .mockRejectedValueOnce(new Error('Shopify said no'))
            .mockResolvedValueOnce({ created: true, product: { shopifyProductId: 'gid://p/2' }, quantityAdded: 1 });

        const out = await runImport({ ...baseArgs });

        const failed = await TcgImportLine.findOne({ importId, 'result.action': 'failed' }).lean();
        expect(failed.result.error).toMatch(/Shopify said no/);
        expect(out.status).toBe('completed');
    });

    it('does not re-stock sealed lines on a resumed run', async () => {
        // progress.sealedDone already set
        await runImport({ ...baseArgs });
        expect(deps.quickAddSealedProduct).not.toHaveBeenCalled();
    });
});
  • [ ] Step 2: Run to verify they fail
bash
npx vitest run server/services/tcgImportRunService.test.js

Expected: FAIL — quickAddSealedProduct is never called.

  • [ ] Step 3: Add the dep

In tcgImportRunService.js's getDeps() default object — the same seam buylistIntakeService uses:

js
    quickAddSealedProduct: require('./sealedQuickAddService').quickAddSealedProduct,
  • [ ] Step 4: Implement phase 4

Insert after the phase-3 for (const setCode of setCodes) loop closes and before the final invalidateShopSyncCaches(shop) and the status: 'completed' update. Add sealedStocked: 0 to the summary object where it is initialised.

js
        // === PHASE 4: sealed ===
        // Sealed products never went through syncSetDirect — they are not cards
        // and have no set collection, no condition variants and no finish. They
        // go through the same quickAddSealedProduct a merchant's manual sealed
        // quick-add uses, so a barcode/MSRP/metafield rule fixed there stays
        // fixed here (§5.8). The only difference is quantityMode: the CSV's
        // Total Quantity is the whole on-hand count, so it is set, not added.
        if (!(importDoc.progress && importDoc.progress.sealedDone)) {
            await TcgImport.updateOne(
                { _id: importId, shop },
                { $set: { 'progress.phase': 'sealed', heartbeatAt: new Date() } }
            );

            // Bounded by the merchant's Unopened rows (4 on the reference
            // export); not a cursor for that reason. See the same note in
            // tcgImportService.runMatch.
            const sealedLines = await TcgImportLine.find({
                importId: importDoc._id, shop,
                'match.status': 'sealed',
                'match.sealedUuid': { $ne: null },
                'result.action': { $ne: 'stocked' }
            }).lean();

            for (const line of sealedLines) {
                const quantity = (line.parsed && line.parsed.quantity) || 0;
                try {
                    const outcome = await deps.quickAddSealedProduct({
                        shop,
                        store,
                        accessToken,
                        game,
                        uuid: line.match.sealedUuid,
                        quantity,
                        // undefined in 'sync' mode is load-bearing: it means "no
                        // opinion on retail price", so a new product is priced
                        // from the catalog and an existing one keeps the
                        // merchant's price. 'locked' writes the file's price.
                        price: priceMode === 'locked' ? line.raw.marketplacePrice : undefined,
                        quantityMode: 'set'
                    });

                    await TcgImportLine.updateOne(
                        { _id: line._id },
                        {
                            $set: {
                                'result.action': 'stocked',
                                'result.productId': (outcome.product && outcome.product.shopifyProductId) || null,
                                'result.variantId': (outcome.product && outcome.product.shopifyVariantId) || null,
                                'result.error': outcome.quantityWarning || null
                            }
                        }
                    );
                    summary.sealedStocked++;
                    if (outcome.created) summary.productsCreated++;
                } catch (error) {
                    // Per line, not per job: three bad sealed rows must not cost
                    // the merchant the rest of the import.
                    logger.error('TCGplayer import: sealed line failed', {
                        shop, importId: String(importId), uuid: line.match.sealedUuid, error: error.message
                    });
                    await TcgImportLine.updateOne(
                        { _id: line._id },
                        { $set: { 'result.action': 'failed', 'result.error': error.message } }
                    );
                    summary.failed++;
                }
            }

            // One checkpoint for the whole sealed phase rather than per line:
            // the phase is bounded and re-entering it is cheap, and every line
            // already carries its own terminal result.action.
            await TcgImport.updateOne(
                { _id: importId, shop },
                {
                    $set: {
                        'progress.sealedDone': true,
                        'progress.sealedStocked': summary.sealedStocked,
                        'progress.failed': summary.failed,
                        heartbeatAt: new Date()
                    }
                }
            );
        }

Declare the two new progress fields in server/models/TcgImport.js's progress sub-document (§5.2 — an undeclared sub-doc field is dropped silently):

js
        sealedDone: Boolean,
        sealedStocked: Number,

Extend the completion summary string so the merchant sees the sealed work:

js
                    summary: `Stocked ${summary.stocked} cards and ${summary.sealedStocked} sealed products across ${summary.setsDone} sets (${summary.productsCreated} new products, ${summary.failed} failed)`
  • [ ] Step 5: Add the progress round-trip test (§5.2)
js
it('round-trips progress.sealedDone and progress.sealedStocked', async () => {
    const doc = await TcgImport.create({
        shop: 'test.myshopify.com', game: 'mtg', status: 'running',
        progress: { phase: 'sealed', sealedDone: true, sealedStocked: 4 }
    });
    const read = await TcgImport.findById(doc._id).lean();
    expect(read.progress.sealedDone).toBe(true);
    expect(read.progress.sealedStocked).toBe(4);
});
  • [ ] Step 6: Run tests
bash
npx vitest run server/services/tcgImportRunService.test.js server/models/TcgImport.test.js

Expected: PASS.

  • [ ] Step 7: Commit
bash
git add server/services/tcgImportRunService.js server/models/TcgImport.js server/services/tcgImportRunService.test.js server/models/TcgImport.test.js
git commit -m "Stock sealed products from a TCGplayer import"

Task 6: Show sealed results in the preview

Files:

  • Modify: client/src/pages/catalog/CatalogImportPage.jsx (the counts grid at lines 236–243 and the caveat copy at line 177)
  • Test: client/src/pages/catalog/CatalogImportPage.test.jsx

Interfaces:

  • Consumes: report.counts.sealedMatched and report.counts.sealedUnmatched (Task 4). report.counts.sealed keeps its existing meaning — every sealed row seen — so totalRows still reconciles.

  • [ ] Step 1: Write the failing test

js
it('reports sealed products separately from cards', async () => {
    renderWithReport({ counts: { ...baseCounts, sealed: 3, sealedMatched: 2, sealedUnmatched: 1 } });

    expect(await screen.findByText('Sealed products to import')).toBeInTheDocument();
    expect(screen.getByText('2')).toBeInTheDocument();
});

it('no longer claims sealed rows are skipped', () => {
    renderWithReport({ counts: { ...baseCounts, sealed: 3, sealedMatched: 2, sealedUnmatched: 1 } });
    expect(screen.queryByText(/Sealed rows aren't imported yet/i)).not.toBeInTheDocument();
});
  • [ ] Step 2: Run to verify it fails
bash
npx vitest run --config vitest.client.config.js client/src/pages/catalog/CatalogImportPage.test.jsx

Expected: FAIL — the label does not exist and the stale caveat is still rendered.

  • [ ] Step 3: Implement

Replace the single sealed row in the counts grid:

jsx
              <div><dt>Sealed products to import</dt><dd>{n(report.counts.sealedMatched)}</dd></div>
              <div><dt>Sealed rows we couldn&apos;t match</dt><dd>{n(report.counts.sealedUnmatched)}</dd></div>

At line 177, delete the sentence Sealed rows aren&apos;t imported yet. — it is now false. Leave the rest of that caveat intact.

  • [ ] Step 4: Run tests and build
bash
npx vitest run --config vitest.client.config.js client/src/pages/catalog/CatalogImportPage.test.jsx && npm run build

Expected: both PASS.

  • [ ] Step 5: Commit
bash
git add client/src/pages/catalog/CatalogImportPage.jsx client/src/pages/catalog/CatalogImportPage.test.jsx
git commit -m "Show how many sealed products a TCGplayer import will bring in"

Task 7: Documentation, cleanup, and the full gate

Files:

  • Modify: docs/guides/tcgplayer-import.md

  • Delete: server/scripts/verify/verifySealedTcgplayerIds.js

  • [ ] Step 1: Update the merchant guide

Find the passage saying sealed rows are not imported and replace it with what now happens:

markdown
### Sealed products

Rows with a condition of `Unopened` are imported as sealed products. We match
them by name against TCGplayer's own product list, so a sealed product we don't
carry in our catalog is reported on the preview rather than guessed at.

Sealed quantities are **set**, not added: the `Total Quantity` column is treated
as your whole on-hand count, so re-running the same file leaves you with the same
number on the shelf rather than double.

Sealed products aren't re-priced automatically the way singles are. If you chose
to keep your own prices, that price stays until you change it in Shopify.
  • [ ] Step 2: Delete the throwaway verification script

Its facts are now encoded in Task 3's and Task 4's tests, which fail if the join breaks.

bash
git rm server/scripts/verify/verifySealedTcgplayerIds.js
  • [ ] Step 3: Run the full gate (§7)
bash
npm test && npm run test:client && npm run lint && npm run build && npm run docs:build

Expected: all exit 0. npm run lint must introduce zero new warnings — compare against pre-change output if unsure.

  • [ ] Step 4: Read the per-file coverage table by hand
bash
npm run test:coverage

The configured threshold gate is silently inoperative under Vitest 4, so the command exits 0 regardless (§7). Read the table yourself and confirm every file this PR modified reports ≥70% on all four metrics: tcgcsvSealedService.js, tcgcsvGroupService.js, tcgImportService.js, tcgImportRunService.js, sealedQuickAddService.js, TcgImport.js, TcgImportLine.js.

  • [ ] Step 5: Run the §5.5 and rule-6 greps on the diff
bash
git diff main --stat && git diff main | grep -nE "\|\| *'mtg'|\?\? *'mtg'|myshopify\.com" || echo "clean"

Expected: clean — no identity defaults, no direct Shopify host references outside shopifyAPI.js.

  • [ ] Step 6: End-to-end bar (§7, sync-affecting change)

Against the dev store (ufkes-dev-2.myshopify.com, worker running, ngrok up), upload a small export slice containing at least one Unopened row, confirm the import, and then verify in Shopify admin that:

  1. the sealed product exists and is published,
  2. its inventory equals the CSV's Total Quantity exactly (not double), and
  3. re-running the same file leaves the quantity unchanged.

Point 3 is the whole reason quantityMode exists — do not skip it.

  • [ ] Step 7: Commit and open the PR
bash
git add docs/guides/tcgplayer-import.md
git commit -m "Document how a TCGplayer import handles sealed products"

The PR description must state the §5.1 parity verdict explicitly: mtg implemented; pokemon and riftbound exempt — unverified, no real export file exists, unblocked by one.


Self-Review

Spec coverage. Every PR-4 requirement in the spec maps to a task: the opening verification of identifiers.tcgplayerProductId → Task 1; the name → productId → uuid join → Tasks 3 and 4; demand-driven ProductsAndPrices.csv fetch → Task 3 Step 5; quickAddSealedProduct with an absolute-quantity mode, both behaviours tested → Task 2; phase 4 in the worker → Task 5; the §5.2 round-trip on new sub-document fields → Tasks 4 and 5; parity statement → Global Constraints and Task 7.

Deliberate scope reductions, both stated in the Global Constraints. No sealed price-lock metafield (nothing re-prices sealed products, so it would be unread scaffolding — §5.9). No new Shopify primitive (setInventoryQuantity shipped in PR 2).

Type consistency. resolveSealedProductIds returns Map<number, Map<string, string>> in Task 3 and is consumed that way in Task 4 (productIdsByGroup.get(resolved.groupId) → inner .get(normalizedName) → string). normalizeName is exported by Task 3 and injected as normalizeSealedName in Task 4. match.sealedUuid is written in Task 4 and selected on in Task 5. quantityMode is introduced in Task 2 and passed in Task 5.

Known risk not resolved by this plan. Task 4 matches on the CSV's Product Name against TCGCSV's name. The spec verified the name side against live TCGCSV, but variant-suffix formatting ("- Traditional Foil Edition") is where an exact-match join is most likely to miss. Task 4's unmatched path reports those rows to the merchant by name rather than guessing, which is the correct failure. If the real match rate on the reference export comes back low, that is a follow-up PR on name normalization — not a reason to loosen the join into a fuzzy match.