Skip to content

Pokemon Sealed Products โ€” Sync Enablement Implementation Plan (PR 2 of 2) โ€‹

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: Make Pokemon sealed products actually usable โ€” plugin SEALED INTERFACE, the supportsSealed capability, and game threaded (as a required field) through every /sealed-products/* write endpoint and /catalog/sealed's browse endpoint โ€” so a merchant can import a real Pokemon sealed product and see it land in Shopify as a DRAFT with the correct price.

Architecture: PR 1 (docs/superpowers/plans/2026-07-16-pokemon-sealed-data-layer.md, merged via PR #361) gave Pokemon sealed rows a home in pokemon_products/pokemon_prices and staged an optional game field on SealedProduct/SealedProductMSRP. This PR adds a SEALED INTERFACE to BaseGamePlugin (findSealedProducts, transformSealedToProduct, preloadSealedPrices) and a supportsSealed capability flag; extracts MTG's existing inline sealed-import logic into its plugin (behavior-preserving, not a rewrite) so both games are driven by the same interface; implements the same interface fresh for Pokemon; and rewrites the write-side /sealed-products/* routes to dispatch through plugin.X(...) instead of branching on game === 'mtg'. /catalog/sealed's read-only browse endpoint gets a new Pokemon branch alongside its existing Riftbound one (its MTG branch is a hand-tuned paginated aggregation and is deliberately left untouched โ€” see Global Constraints).

Scope decision โ€” client is out of scope for this PR. MULTI_GAME_ARCHITECTURE.md's sealed exit bar is a backend capability proof ("import one sealed product in testMode โ†’ DRAFT in Shopify with correct price"), not a UI requirement. Wiring the client (CatalogSealedPage.jsx's sealedSupported/importSupported checks, useSealedQuickAdd, SealedProductsManager.jsx) to actually expose this to a merchant, and browser-verifying it per CLAUDE.md's UI rule, is real, separate work โ€” deferred to PR 3. This PR's exit bar is a direct API call against a real dev Shopify store.

Tech Stack: Node 24 CommonJS (server), Vitest + ESM (tests), Mongoose, Zod, existing plugin registry (server/plugins/index.js).

Global Constraints โ€‹

  • Server code is CommonJS (require); test files are ESM (import), co-located as x.test.js. Plugin tests use const XPlugin = require('./index'); directly (see server/plugins/mtg/index.test.js) โ€” no dynamic import needed, unlike Mongoose model tests.
  • Never default game (CLAUDE.md ยง5.5): every schema this PR adds game to uses a schema with NO .optional()/.default() โ€” a new requiredGameSchema (Task 4), distinct from the pre-existing, deliberately-defaulting gameSchema used by read-only catalog browsing (server/schemas/catalog.js:21, defaults to 'mtg' for backward-compatible unauthenticated browse calls โ€” pre-existing, not touched by this PR).
  • MTG behavior must not change. Task 2 extracts MTG's existing inline sealed-import/quick-add/preview logic into plugin methods verbatim โ€” same fields, same Shopify payload shape, same pricing/MSRP-fallback behavior. This is a refactor, not a rewrite. Any accidental behavior change here is Critical.
  • Riftbound is explicitly exempt from supportsSealed in this PR (stays false, the BaseGamePlugin default). Its existing sealed browse still works through its own hardcoded /catalog/sealed branch (unchanged, added before this PR). Full Riftbound sealed enablement (import/quick-add) is its own future effort per MULTI_GAME_ARCHITECTURE.md.
  • Pokemon sealed pricing is market-price-only in this PR โ€” no MSRP fallback (SealedProductMSRP per-game threading is explicitly out of scope; MTG keeps using it exactly as today). A Pokemon sealed product with no market price in pokemon_prices simply gets price: null โ€” the route already handles a null price by skipping/flagging (same as MTG's existing "no pricing" path).
  • Literal-format pitfall already identified: /catalog/sealed's existing sets query param is uppercased for MTG/Riftbound (req.query.sets.split(',').map(s => s.trim().toUpperCase())), but Pokemon set ids are lowercase TCGCSV slugs (e.g. tcgcsv-1663) โ€” Task 9 re-derives an un-uppercased rawSets for the Pokemon branch instead of reusing the shared uppercased variable. Verified against server/plugins/pokemon/index.js's findSet (checks both id and uppercased abbreviation).
  • Coverage โ‰ฅ70% on all four metrics for every modified/created file except anything under server/scripts/** (already-confirmed project-wide vitest.config.js exclusion, not this PR's concern). npm test and npm run lint exit 0, zero new warnings, before each commit. Husky pre-commit runs ESLint โ€” never --no-verify.
  • Zero edits to syncService, syncProcessor, previewSyncProducts, or Shopify collection-creation code โ€” this PR only touches sealed-product routes/plugins/models, never the card-sync pipeline.
  • Aggregations: $project before $sort (ยง5.6) โ€” applies to Task 3's preloadSealedPrices for Pokemon.
  • Commit messages: one imperative sentence, sentence case, no trailing period, ending with the trailer Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>.
  • Before starting: git fetch origin main and merge if this branch (claude/pokemon-sealed-sync-enablement, based on the still-unmerged claude/sealed-products-pokemon-0d42a1) is behind its base (ยง5.10) โ€” note this branch intentionally stacks on PR 1's branch, not main, until PR 1 merges.
  • The game-parity skill's spirit applies throughout: every task that touches a per-game plugin method names MTG and Pokemon explicitly (mirrored), and states why Riftbound is exempt.

Task 1: BaseGamePlugin โ€” supportsSealed + SEALED INTERFACE stubs โ€‹

Files:

  • Modify: server/plugins/BaseGamePlugin.js
  • Test: server/plugins/pricingDefaults.test.js is unrelated โ€” no new test file needed for this task; the interface is exercised by Tasks 2/3's plugin tests. (No placeholder โ€” this task has no test step of its own because an abstract base class with only throw-ing stubs has nothing to assert beyond "the method exists and throws," which Tasks 2/3 already cover by testing the concrete overrides.)

Interfaces:

  • Produces: plugin.supportsSealed (boolean, default false); plugin.findSealedProducts({setCodes, uuids}) โ†’ Promise<Array<{product: Object, setData: {code, name, releaseDate}}>>; plugin.transformSealedToProduct(product, setData) โ†’ Object; plugin.preloadSealedPrices(products: Array<{uuid, setCode, category}>) โ†’ Promise<Map<string, {price: number|null, source: string|null, timestamp: Date|null}>>. Consumed by Tasks 2, 3, 6-9.

  • [ ] Step 1: Add the capability flag and interface stubs

In server/plugins/BaseGamePlugin.js, add immediately before the final closing } of the class (after findExistingProductLive):

javascript
    /**
     * Whether this game has full sealed-product support (browse, import,
     * quick-add) via the SEALED INTERFACE below. Riftbound's sealed browse
     * today is a separate, hardcoded route branch (not this interface) โ€” see
     * MULTI_GAME_ARCHITECTURE.md "Sealed Products" for why Riftbound stays
     * false until its own future effort implements this interface.
     * @returns {boolean}
     */
    get supportsSealed() {
        return false;
    }

    // ==================================================================
    // SEALED INTERFACE
    // Everything the /sealed-products/* and /catalog/sealed routes need for
    // a game whose supportsSealed is true. See MULTI_GAME_ARCHITECTURE.md
    // "Sealed Products โ€” Requirements for game-generic support".
    // ==================================================================

    /**
     * Find sealed products by set code(s) or by uuid(s). Callers pass
     * exactly one of setCodes/uuids; both are accepted so one method serves
     * import (by setCodes or uuids), preview (by uuids), quick-add (a single
     * uuid), and catalog browse (by setCodes).
     * @param {{setCodes?: string[], uuids?: string[]}} filter
     * @returns {Promise<Array<{product: Object, setData: {code: string, name: string, releaseDate: string|Date|null}}>>}
     */
    async findSealedProducts(_filter) {
        throw new Error('findSealedProducts must be implemented by subclass');
    }

    /**
     * Transform one sealed product + its set data into a combined shape
     * carrying both the Shopify product-creation fields (handle, title,
     * body, vendor, type, tags, sku, imagesrc, imagealttext, metafields) and
     * the SealedProduct document fields (game, uuid, name, category,
     * setCode, setName, releaseDate, identifiers, purchaseUrls) โ€” single
     * variant, no Finish option (sealed products aren't printed in multiple
     * finishes).
     * @param {Object} product
     * @param {Object} setData
     * @returns {Object}
     */
    transformSealedToProduct(_product, _setData) {
        throw new Error('transformSealedToProduct must be implemented by subclass');
    }

    /**
     * Pre-load prices for a batch of sealed products.
     * @param {Array<{uuid: string, setCode: string, category: string|null}>} products
     * @returns {Promise<Map<string, {price: number|null, source: string|null, timestamp: Date|null}>>}
     */
    async preloadSealedPrices(_products) {
        throw new Error('preloadSealedPrices must be implemented by subclass');
    }
  • [ ] Step 2: Run the full plugin test suite to confirm nothing broke

Run: npx vitest run server/plugins/ Expected: all existing plugin tests still PASS (this is a pure addition โ€” no existing method changed).

  • [ ] Step 3: Commit
bash
git add server/plugins/BaseGamePlugin.js
git commit -m "$(cat <<'EOF'
Add supportsSealed capability and SEALED INTERFACE stubs to BaseGamePlugin

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 2: MTG plugin โ€” implement SEALED INTERFACE (extracted, behavior-preserving) โ€‹

Files:

  • Modify: server/plugins/mtg/index.js
  • Modify: server/plugins/mtg/index.test.js

Interfaces:

  • Consumes: Task 1's stubs; server/utils/sealedProductUtils.js's categorizeProduct(name); server/services/priceLookupService.js's getSealedProductPrice(uuid, setCode, category, options).

  • Produces: MTGPlugin.supportsSealed === true; the three SEALED INTERFACE methods, extracted verbatim from the current inline logic in server/routes/api.js's /sealed-products/import (lines ~3430-3472 for the lookup, ~3502-3563 for the transform) and getSealedProductPrice calls. Consumed by Task 6-9's route rewrites.

  • [ ] Step 1: Write the failing tests

Add to server/plugins/mtg/index.test.js (a new top-level describe block, after the existing ones):

javascript
describe('MTGPlugin sealed interface', () => {
    const plugin = new MTGPlugin();

    it('supportsSealed is true', () => {
        expect(plugin.supportsSealed).toBe(true);
    });

    describe('findSealedProducts', () => {
        it('finds sealed products by uuid across all sets with sealed data', async () => {
            const SetModel = require('../../models/SetModel');
            const originalFind = SetModel.find;
            SetModel.find = vi.fn().mockReturnValue({
                select: vi.fn().mockReturnValue({
                    lean: vi.fn().mockResolvedValue([
                        {
                            data: {
                                code: 'BLB', name: 'Bloomburrow', releaseDate: '2024-08-02',
                                sealedProduct: [
                                    { uuid: 'uuid-1', name: 'Draft Booster Box' },
                                    { uuid: 'uuid-2', name: 'Bundle' }
                                ]
                            }
                        }
                    ])
                })
            });

            const results = await plugin.findSealedProducts({ uuids: ['uuid-1'] });

            expect(results).toHaveLength(1);
            expect(results[0].product.uuid).toBe('uuid-1');
            expect(results[0].setData).toEqual({ code: 'BLB', name: 'Bloomburrow', releaseDate: '2024-08-02' });

            SetModel.find = originalFind;
        });

        it('finds sealed products by set code, uppercased', async () => {
            const SetModel = require('../../models/SetModel');
            const originalFind = SetModel.find;
            SetModel.find = vi.fn().mockReturnValue({
                select: vi.fn().mockReturnValue({
                    lean: vi.fn().mockResolvedValue([
                        {
                            data: {
                                code: 'BLB', name: 'Bloomburrow', releaseDate: '2024-08-02',
                                sealedProduct: [{ uuid: 'uuid-1', name: 'Draft Booster Box' }]
                            }
                        }
                    ])
                })
            });

            const results = await plugin.findSealedProducts({ setCodes: ['blb'] });

            expect(results).toHaveLength(1);
            expect(results[0].setData.code).toBe('BLB');

            SetModel.find = originalFind;
        });
    });

    describe('transformSealedToProduct', () => {
        it('builds a Shopify-ready sealed product with combined SealedProduct fields', () => {
            const product = {
                uuid: 'uuid-1',
                name: 'Bloomburrow Draft Booster Box',
                identifiers: { tcgplayerProductId: 12345 },
                purchaseUrls: { tcgplayer: 'https://tcgplayer.com/x' }
            };
            const setData = { code: 'BLB', name: 'Bloomburrow', releaseDate: '2024-08-02' };

            const result = plugin.transformSealedToProduct(product, setData);

            expect(result.game).toBe('mtg');
            expect(result.uuid).toBe('uuid-1');
            expect(result.category).toBe('booster_box');
            expect(result.setCode).toBe('BLB');
            expect(result.handle).toBe('sealed-blb-bloomburrow-draft-booster-box');
            expect(result.vendor).toBe('Wizards of the Coast');
            expect(result.metafields.uuid).toBe('uuid-1');
            expect(result.imagesrc).toBe('https://product-images.tcgplayer.com/fit-in/400x400/12345.jpg');
        });
    });

    describe('preloadSealedPrices', () => {
        it('returns a Map keyed by uuid using getSealedProductPrice per product', async () => {
            const priceLookupService = require('../../services/priceLookupService');
            const original = priceLookupService.getSealedProductPrice;
            priceLookupService.getSealedProductPrice = vi.fn().mockResolvedValue({
                price: 150, source: 'tcgplayer', timestamp: new Date('2026-07-01')
            });

            const priceMap = await plugin.preloadSealedPrices([
                { uuid: 'uuid-1', setCode: 'BLB', category: 'booster_box' }
            ]);

            expect(priceMap.get('uuid-1')).toEqual({ price: 150, source: 'tcgplayer', timestamp: new Date('2026-07-01') });
            expect(priceLookupService.getSealedProductPrice).toHaveBeenCalledWith('uuid-1', 'BLB', 'booster_box');

            priceLookupService.getSealedProductPrice = original;
        });
    });
});
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run server/plugins/mtg/index.test.js Expected: FAIL โ€” plugin.supportsSealed is undefined/falsy, and findSealedProducts/transformSealedToProduct/preloadSealedPrices throw "must be implemented by subclass".

  • [ ] Step 3: Implement the three methods on MTGPlugin

In server/plugins/mtg/index.js, add immediately after get priceSources() (before getMetafieldDefinitions()):

javascript
    get supportsSealed() {
        return true;
    }

Add immediately before the closing } of the class (after findExistingProductLive):

javascript
    // ===== Sealed interface =====

    async findSealedProducts({ setCodes, uuids } = {}) {
        const SetModel = require('../../models/SetModel');
        const toSetData = (setData) => ({ code: setData.code, name: setData.name, releaseDate: setData.releaseDate || null });

        if (uuids && uuids.length > 0) {
            const uuidSet = new Set(uuids);
            const setsWithSealed = await SetModel.find({
                'data.isOnlineOnly': { $ne: true },
                'data.sealedProduct.0': { $exists: true }
            }).select('data.code data.name data.releaseDate data.sealedProduct').lean();

            const results = [];
            for (const set of setsWithSealed) {
                const setData = set.data;
                for (const product of (setData.sealedProduct || [])) {
                    if (uuidSet.has(product.uuid)) {
                        results.push({ product, setData: toSetData(setData) });
                        uuidSet.delete(product.uuid);
                    }
                }
                if (uuidSet.size === 0) break;
            }
            return results;
        }

        const codes = setCodes || [];
        const normalizedCodes = codes.map(c => c.toUpperCase());
        const sets = await SetModel.find({
            $or: [
                { 'data.code': { $in: normalizedCodes } },
                { 'data.code': { $in: codes } }
            ]
        }).select('data.code data.name data.releaseDate data.sealedProduct').lean();

        const results = [];
        for (const set of sets) {
            const setData = set.data;
            for (const product of (setData.sealedProduct || [])) {
                results.push({ product, setData: toSetData(setData) });
            }
        }
        return results;
    }

    transformSealedToProduct(product, setData) {
        const { categorizeProduct } = require('../../utils/sealedProductUtils');
        const name = product.name || 'Unknown';
        const category = categorizeProduct(name);
        const tcgplayerProductId = product.identifiers?.tcgplayerProductId;
        const imageUrl = tcgplayerProductId
            ? `https://product-images.tcgplayer.com/fit-in/400x400/${tcgplayerProductId}.jpg`
            : null;
        const slugifiedName = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
        const handle = `sealed-${setData.code.toLowerCase()}-${slugifiedName}`;

        const descriptionParts = [
            `<h3>${name}</h3>`,
            `<p><strong>Set:</strong> ${setData.name} (${setData.code})</p>`
        ];
        if (product.subtype) {
            descriptionParts.push(`<p><strong>Subtype:</strong> ${product.subtype}</p>`);
        }
        if (product.cardCount) {
            descriptionParts.push(`<p><strong>Card Count:</strong> ${product.cardCount} cards</p>`);
        }
        if (product.productSize) {
            descriptionParts.push(`<p><strong>Product Size:</strong> ${product.productSize}</p>`);
        }
        if (setData.releaseDate) {
            const releaseDate = new Date(setData.releaseDate);
            descriptionParts.push(`<p><strong>Release Date:</strong> ${releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>`);
        }

        return {
            game: this.gameId,
            uuid: product.uuid,
            name,
            category,
            setCode: setData.code,
            setName: setData.name,
            releaseDate: setData.releaseDate || null,
            identifiers: product.identifiers || {},
            purchaseUrls: product.purchaseUrls || {},
            handle,
            title: name,
            body: descriptionParts.join('\n'),
            vendor: 'Wizards of the Coast',
            type: 'Sealed Product',
            tags: `sealed, ${setData.code}, ${category}`,
            sku: handle,
            imagesrc: imageUrl,
            imagealttext: name,
            metafields: {
                set_code: setData.code,
                category,
                release_date: setData.releaseDate || '',
                uuid: product.uuid,
                managed_by: MANAGED_BY_VALUE
            }
        };
    }

    async preloadSealedPrices(products) {
        const { getSealedProductPrice } = require('../../services/priceLookupService');
        const priceMap = new Map();
        await Promise.all(products.map(async ({ uuid, setCode, category }) => {
            priceMap.set(uuid, await getSealedProductPrice(uuid, setCode, category));
        }));
        return priceMap;
    }
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run server/plugins/mtg/index.test.js Expected: PASS (all tests, including the new sealed-interface ones)

  • [ ] Step 5: Run the full server test suite

Run: npm test Expected: all suites PASS, no regressions.

  • [ ] Step 6: Commit
bash
git add server/plugins/mtg/index.js server/plugins/mtg/index.test.js
git commit -m "$(cat <<'EOF'
Implement the sealed interface on MTGPlugin from existing route logic

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 3: Pokemon plugin โ€” implement SEALED INTERFACE โ€‹

Files:

  • Modify: server/plugins/pokemon/index.js
  • Modify: server/plugins/pokemon/index.test.js

Interfaces:

  • Consumes: Task 1's stubs; server/models/PokemonProduct.js (Task 1 of PR 1), server/models/PokemonSet.js, server/models/PokemonPrice.js, server/utils/sealedProductUtils.js's categorizeProduct.

  • Produces: PokemonPlugin.supportsSealed === true; the three SEALED INTERFACE methods. Consumed by Tasks 6-9.

  • [ ] Step 1: Write the failing tests

Add to server/plugins/pokemon/index.test.js (a new top-level describe block):

javascript
describe('PokemonPlugin sealed interface', () => {
    const plugin = new PokemonPlugin();

    it('supportsSealed is true', () => {
        expect(plugin.supportsSealed).toBe(true);
    });

    describe('findSealedProducts', () => {
        it('finds sealed products by uuid, joined to their set', async () => {
            const PokemonProduct = require('../../models/PokemonProduct');
            const PokemonSet = require('../../models/PokemonSet');
            const originalProductFind = PokemonProduct.find;
            const originalSetFind = PokemonSet.find;

            PokemonProduct.find = vi.fn().mockReturnValue({
                lean: vi.fn().mockResolvedValue([
                    { tcgplayerProductId: 475651, groupId: 22873, name: 'Code Card - Fuecoco', imageUrl: 'https://x/475651.jpg', url: 'https://tcgplayer.com/475651' }
                ])
            });
            PokemonSet.find = vi.fn().mockReturnValue({
                lean: vi.fn().mockResolvedValue([
                    { groupId: 22873, id: 'sv01-scarlet-violet-base-set', abbreviation: 'SV01', name: 'SV01: Scarlet & Violet Base Set', publishedOn: '2023-03-31' }
                ])
            });

            const results = await plugin.findSealedProducts({ uuids: ['475651'] });

            expect(results).toHaveLength(1);
            expect(results[0].product.uuid).toBe('475651');
            expect(results[0].setData).toEqual({ code: 'SV01', name: 'SV01: Scarlet & Violet Base Set', releaseDate: '2023-03-31' });

            PokemonProduct.find = originalProductFind;
            PokemonSet.find = originalSetFind;
        });

        it('returns an empty array when no sets match the given setCodes', async () => {
            const PokemonSet = require('../../models/PokemonSet');
            const originalSetFind = PokemonSet.find;
            PokemonSet.find = vi.fn().mockReturnValue({
                select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) })
            });

            const results = await plugin.findSealedProducts({ setCodes: ['no-such-set'] });
            expect(results).toEqual([]);

            PokemonSet.find = originalSetFind;
        });
    });

    describe('transformSealedToProduct', () => {
        it('builds a Shopify-ready sealed product with combined SealedProduct fields', () => {
            const product = {
                uuid: '475651',
                name: 'Base Set Booster Box',
                imageUrl: 'https://x/475651.jpg',
                identifiers: { tcgplayerProductId: 475651 },
                purchaseUrls: { tcgplayer: 'https://tcgplayer.com/475651' }
            };
            const setData = { code: 'SV01', name: 'SV01: Scarlet & Violet Base Set', releaseDate: '2023-03-31' };

            const result = plugin.transformSealedToProduct(product, setData);

            expect(result.game).toBe('pokemon');
            expect(result.uuid).toBe('475651');
            expect(result.category).toBe('booster_box');
            expect(result.setCode).toBe('SV01');
            expect(result.handle).toBe('sealed-sv01-base-set-booster-box');
            expect(result.vendor).toBe('The Pokemon Company');
            expect(result.metafields.uuid).toBe('475651');
            expect(result.imagesrc).toBe('https://x/475651.jpg');
        });
    });

    describe('preloadSealedPrices', () => {
        it('returns a Map keyed by uuid from the latest pokemon_prices sealed doc', async () => {
            const PokemonPrice = require('../../models/PokemonPrice');
            const original = PokemonPrice.aggregate;
            PokemonPrice.aggregate = vi.fn().mockResolvedValue([
                { _id: '475651', retail: { normal: 45.99 }, timestamp: new Date('2026-07-01') }
            ]);

            const priceMap = await plugin.preloadSealedPrices([
                { uuid: '475651', setCode: 'SV01', category: 'booster_box' }
            ]);

            expect(priceMap.get('475651')).toEqual({ price: 45.99, source: 'tcgplayer', timestamp: new Date('2026-07-01') });

            PokemonPrice.aggregate = original;
        });

        it('fills in a null-price entry for uuids with no price doc', async () => {
            const PokemonPrice = require('../../models/PokemonPrice');
            const original = PokemonPrice.aggregate;
            PokemonPrice.aggregate = vi.fn().mockResolvedValue([]);

            const priceMap = await plugin.preloadSealedPrices([
                { uuid: '999999', setCode: 'SV01', category: 'booster_box' }
            ]);

            expect(priceMap.get('999999')).toEqual({ price: null, source: null, timestamp: null });

            PokemonPrice.aggregate = original;
        });
    });
});
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run server/plugins/pokemon/index.test.js Expected: FAIL โ€” same "must be implemented by subclass" errors as Task 2.

  • [ ] Step 3: Implement the three methods on PokemonPlugin

In server/plugins/pokemon/index.js, add immediately after get priceSource() (before getMetafieldDefinitions()):

javascript
    get supportsSealed() {
        return true;
    }

Add immediately before the closing } of the class (after findExistingProductLive):

javascript
    // ===== Sealed interface =====

    async findSealedProducts({ setCodes, uuids } = {}) {
        const PokemonProduct = require('../../models/PokemonProduct');
        const PokemonSet = require('../../models/PokemonSet');

        let productDocs;
        if (uuids && uuids.length > 0) {
            const productIds = uuids.map(u => parseInt(u, 10)).filter(n => !isNaN(n));
            productDocs = await PokemonProduct.find({
                productType: 'sealed',
                tcgplayerProductId: { $in: productIds }
            }).lean();
        } else {
            const codes = setCodes || [];
            const sets = await PokemonSet.find({
                $or: [
                    { id: { $in: codes } },
                    { abbreviation: { $in: codes.map(c => c.toUpperCase()) } }
                ]
            }).select('groupId').lean();
            const groupIds = sets.map(s => s.groupId);
            if (groupIds.length === 0) return [];
            productDocs = await PokemonProduct.find({
                productType: 'sealed',
                groupId: { $in: groupIds }
            }).lean();
        }

        if (productDocs.length === 0) return [];

        const groupIds = [...new Set(productDocs.map(p => p.groupId))];
        const sets = await PokemonSet.find({ groupId: { $in: groupIds } }).lean();
        const setByGroupId = new Map(sets.map(s => [s.groupId, s]));

        return productDocs.map(doc => {
            const setDoc = setByGroupId.get(doc.groupId);
            return {
                product: {
                    uuid: String(doc.tcgplayerProductId),
                    name: doc.name,
                    subtype: null,
                    imageUrl: doc.imageUrl,
                    identifiers: { tcgplayerProductId: doc.tcgplayerProductId },
                    purchaseUrls: { tcgplayer: doc.url }
                },
                setData: {
                    code: setDoc ? (setDoc.abbreviation || setDoc.id) : String(doc.groupId),
                    name: setDoc ? setDoc.name : `Group ${doc.groupId}`,
                    releaseDate: setDoc ? setDoc.publishedOn : null
                }
            };
        });
    }

    transformSealedToProduct(product, setData) {
        const { categorizeProduct } = require('../../utils/sealedProductUtils');
        const name = product.name || 'Unknown';
        const category = categorizeProduct(name);
        const slugifiedName = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
        const handle = `sealed-${setData.code.toLowerCase()}-${slugifiedName}`;

        const descriptionParts = [
            `<h3>${name}</h3>`,
            `<p><strong>Set:</strong> ${setData.name}</p>`
        ];
        if (setData.releaseDate) {
            const releaseDate = new Date(setData.releaseDate);
            descriptionParts.push(`<p><strong>Release Date:</strong> ${releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>`);
        }

        return {
            game: this.gameId,
            uuid: product.uuid,
            name,
            category,
            setCode: setData.code,
            setName: setData.name,
            releaseDate: setData.releaseDate || null,
            identifiers: { tcgplayerProductId: product.identifiers?.tcgplayerProductId },
            purchaseUrls: product.purchaseUrls || {},
            handle,
            title: name,
            body: descriptionParts.join('\n'),
            vendor: 'The Pokemon Company',
            type: 'Sealed Product',
            tags: `sealed, ${setData.code}, ${category}`,
            sku: handle,
            imagesrc: product.imageUrl || '',
            imagealttext: name,
            metafields: {
                set_code: setData.code,
                category,
                release_date: setData.releaseDate || '',
                uuid: product.uuid,
                managed_by: MANAGED_BY_VALUE
            }
        };
    }

    async preloadSealedPrices(products) {
        const PokemonPrice = require('../../models/PokemonPrice');
        const uuids = products.map(p => p.uuid);

        // $project precedes $sort (CLAUDE.md ยง5.6).
        const priceDocs = await PokemonPrice.aggregate([
            { $match: { slug: { $in: uuids }, productType: 'sealed', game: 'pokemon' } },
            { $project: { slug: 1, timestamp: 1, retail: '$paper.tcgplayer.retail' } },
            { $sort: { slug: 1, timestamp: -1 } },
            { $group: { _id: '$slug', retail: { $first: '$retail' }, timestamp: { $first: '$timestamp' } } }
        ]);

        const priceMap = new Map();
        for (const doc of priceDocs) {
            const price = doc.retail?.normal;
            const hasPrice = price != null && price > 0;
            priceMap.set(doc._id, {
                price: hasPrice ? price : null,
                source: hasPrice ? 'tcgplayer' : null,
                timestamp: doc.timestamp || null
            });
        }
        for (const { uuid } of products) {
            if (!priceMap.has(uuid)) priceMap.set(uuid, { price: null, source: null, timestamp: null });
        }
        return priceMap;
    }
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run server/plugins/pokemon/index.test.js Expected: PASS (all tests, including the new sealed-interface ones)

  • [ ] Step 5: Run the full server test suite

Run: npm test Expected: all suites PASS, no regressions.

  • [ ] Step 6: Commit
bash
git add server/plugins/pokemon/index.js server/plugins/pokemon/index.test.js
git commit -m "$(cat <<'EOF'
Implement the sealed interface on PokemonPlugin

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 4: Schemas โ€” requiredGameSchema + thread game through write-side sealed schemas โ€‹

Files:

  • Modify: server/schemas/catalog.js
  • Modify: server/schemas/sealedProducts.js
  • Modify: server/schemas/queries.js
  • Modify: server/schemas/schemas.test.js

Interfaces:

  • Produces: requiredGameSchema (exported from catalog.js) โ€” z.string().regex(/^[a-z0-9]{2,16}$/, 'Invalid game identifier'), no .optional(), no .default(). Added to sealedImportSchema, quickAddSchema, quickAddBulkSchema's per-item shape, sealedPreviewQuerySchema, sealedProductsListQuerySchema. Consumed by Tasks 6-9's route validation.

  • [ ] Step 1: Write the failing tests

Add to server/schemas/schemas.test.js (find the existing describe('sealedImportSchema', ...)-style blocks and add game assertions to each; the exact insertion point is wherever those describes currently live โ€” add these as new it(...) blocks inside each existing describe):

javascript
    // Inside describe('sealedImportSchema', ...):
    it('requires game with no default', () => {
        const result = sealedImportSchema.safeParse({ setCodes: ['BLB'] });
        expect(result.success).toBe(false);
    });
    it('accepts a valid game identifier', () => {
        const result = sealedImportSchema.safeParse({ setCodes: ['BLB'], game: 'mtg' });
        expect(result.success).toBe(true);
    });

    // Inside describe('quickAddSchema', ...) (create this describe if it doesn't already exist near quickAddBulkSchema's tests):
    it('requires game with no default', () => {
        const result = quickAddSchema.safeParse({ uuid: 'a-b-c', quantity: 1, price: 10 });
        expect(result.success).toBe(false);
    });
    it('accepts a valid game identifier', () => {
        const result = quickAddSchema.safeParse({ uuid: 'a-b-c', quantity: 1, price: 10, game: 'pokemon' });
        expect(result.success).toBe(true);
    });

    // Inside describe('sealedPreviewQuerySchema', ...):
    it('requires game with no default', () => {
        const result = sealedPreviewQuerySchema.safeParse({ uuids: 'abc-123' });
        expect(result.success).toBe(false);
    });

    // Inside describe('sealedProductsListQuerySchema', ...):
    it('requires game with no default', () => {
        const result = sealedProductsListQuerySchema.safeParse({ status: 'active' });
        expect(result.success).toBe(false);
    });

If quickAddSchema/quickAddBulkSchema don't yet have a describe block in schemas.test.js, add one modeled on the existing sealedImportSchema block, importing quickAddSchema, quickAddBulkSchema alongside the other sealed-schema imports at the top of the file.

  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run server/schemas/schemas.test.js Expected: FAIL โ€” the "requires game" tests currently pass validation without game (they expect success: false but get true), and "accepts a valid game identifier" tests fail because game isn't a recognized key yet (though Zod by default strips unknown keys rather than erroring, so this one may pass trivially โ€” the meaningful RED signal is the "requires game" tests).

  • [ ] Step 3: Add requiredGameSchema and thread it through

In server/schemas/catalog.js, add immediately after the existing gameSchema declaration:

javascript
/**
 * Required game id schema โ€” same format as gameSchema but with NO default.
 * Used on write-side sealed-product endpoints where silently assuming a
 * game would violate CLAUDE.md ยง5.5 (never default `game`). gameSchema's
 * default('mtg') is fine for read-only catalog browsing; it is never fine
 * for an endpoint that creates or mutates per-store, per-game data.
 */
const requiredGameSchema = z
    .string()
    .regex(/^[a-z0-9]{2,16}$/, 'Invalid game identifier');

Add requiredGameSchema to catalog.js's module.exports object (alongside the existing gameSchema, catalogSealedQuerySchema, etc.).

In server/schemas/sealedProducts.js, add the import at the top:

javascript
const { requiredGameSchema } = require('./catalog');

Add game: requiredGameSchema, as the first field in sealedImportSchema's z.object({...}), in quickAddSchema's z.object({...}), and in quickAddBulkSchema's per-item schema (quickAddSchema is already reused there via z.array(quickAddSchema), so adding it to quickAddSchema covers both โ€” no separate edit needed in quickAddBulkSchema itself).

In server/schemas/queries.js, add the import at the top (alongside its other requires):

javascript
const { requiredGameSchema } = require('./catalog');

Add game: requiredGameSchema, as the first field in both sealedPreviewQuerySchema's and sealedProductsListQuerySchema's z.object({...}).

  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run server/schemas/schemas.test.js Expected: PASS (all tests, including the new game-required ones)

  • [ ] Step 5: Run the full server test suite

Run: npm test Expected: PASS. Watch specifically for queues/processors or other tests that construct a sealed-products request body without game โ€” if any exist, they'll now fail validation; that's an expected, correct RED signal that those call sites also need game added (there should be none, since routes/api.js has no test coverage today, but check test output carefully).

  • [ ] Step 6: Commit
bash
git add server/schemas/catalog.js server/schemas/sealedProducts.js server/schemas/queries.js server/schemas/schemas.test.js
git commit -m "$(cat <<'EOF'
Require an explicit game on every write-side sealed product schema

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 5: SealedProduct model โ€” flip game to required, update the unique index โ€‹

Files:

  • Modify: server/models/SealedProduct.js
  • Modify: server/models/SealedProduct.test.js

Interfaces:

  • Produces: SealedProduct.game is now required: true, no default. Unique index is {shop:1, game:1, uuid:1} (was {shop:1, uuid:1}). Consumed by Tasks 6-8's SealedProduct.create()/.find() calls, which must now always pass game.

  • [ ] Step 1: Write the failing test

In server/models/SealedProduct.test.js, update the existing two game-related tests (added in PR 1's Task 2) โ€” find:

javascript
        it('accepts an optional game field', () => {
            const product = new SealedProduct({ ...validProductData, game: 'pokemon' });
            const error = product.validateSync();
            expect(error).toBeUndefined();
            expect(product.game).toBe('pokemon');
        });

        it('does not require game yet (PR 2 flips this once every write path sets it)', () => {
            const product = new SealedProduct(validProductData);
            const error = product.validateSync();
            expect(error).toBeUndefined();
            expect(product.game).toBeUndefined();
        });

Replace with:

javascript
        it('accepts a valid game field', () => {
            const product = new SealedProduct({ ...validProductData, game: 'pokemon' });
            const error = product.validateSync();
            expect(error).toBeUndefined();
            expect(product.game).toBe('pokemon');
        });

        it('requires game', () => {
            const product = new SealedProduct(validProductData);
            const error = product.validateSync();
            expect(error.errors.game).toBeDefined();
        });
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run server/models/SealedProduct.test.js Expected: FAIL โ€” 'requires game' expects a validation error and gets undefined (field is still optional).

  • [ ] Step 3: Flip the field to required and update the index

In server/models/SealedProduct.js, replace:

javascript
    // Game identifier ('mtg', 'pokemon', ...). NOT required yet โ€” no write
    // path sets it today (sealed products are MTG-only end to end). A
    // follow-up PR threads `game` through every /sealed-products/* route and
    // flips this to required (CLAUDE.md ยง5.5 โ€” no schema default, ever).
    // Existing docs get backfilled to 'mtg' by
    // server/scripts/migrations/backfillSealedProductGame.js.
    game: {
        type: String
    },

with:

javascript
    // Game identifier ('mtg', 'pokemon', ...). Required, no default (CLAUDE.md
    // ยง5.5) โ€” every /sealed-products/* route sets it explicitly via
    // requiredGameSchema (server/schemas/catalog.js). Existing docs were
    // backfilled to 'mtg' by
    // server/scripts/migrations/backfillSealedProductGame.js before this
    // field became required.
    game: {
        type: String,
        required: true
    },

Replace the unique index:

javascript
sealedProductSchema.index({ shop: 1, uuid: 1 }, { unique: true });

with:

javascript
sealedProductSchema.index({ shop: 1, game: 1, uuid: 1 }, { unique: true });

Production deployment note (not a code step โ€” record this in the PR description): MongoDB does not auto-drop a renamed/removed index; the old {shop:1, uuid:1} unique index will persist on the live collection until an operator explicitly drops it (e.g. db.sealedproducts.dropIndex('shop_1_uuid_1')) and lets Mongoose create the new one on next connect. This is safe to do any time after the backfill migration (PR 1, Task 4) has run โ€” every doc already has game set by then, so the old and new indexes are both satisfied simultaneously without conflict during the transition window.

  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run server/models/SealedProduct.test.js Expected: PASS (all tests)

  • [ ] Step 5: Run the full server test suite and coverage

Run: npm test Expected: PASS, no regressions.

Run: npx vitest run server/models/SealedProduct.test.js --coverage --coverage.include="server/models/SealedProduct.js" Expected: 100%/100%/100%/100% (matches PR 1's Task 2 state โ€” this change doesn't add branches, just tightens one field).

  • [ ] Step 6: Commit
bash
git add server/models/SealedProduct.js server/models/SealedProduct.test.js
git commit -m "$(cat <<'EOF'
Require game on SealedProduct and key its unique index on shop+game+uuid

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 6: Route โ€” POST /sealed-products/import dispatches through the plugin โ€‹

Files:

  • Modify: server/routes/api.js

Interfaces:

  • Consumes: game (from Task 4's schema), plugin.supportsSealed, plugin.findSealedProducts, plugin.transformSealedToProduct, plugin.preloadSealedPrices (Tasks 1-3), categorizeProduct (existing import, for building preloadSealedPrices inputs), applySealedPricingRules (existing import, unchanged).

  • No test file for this task โ€” server/routes/api.js has no unit test coverage today (confirmed: no api.test.js exists in this codebase; route-level behavior is verified manually, matching the existing convention for every other route in this file). Task 10 is this route's real verification.

  • [ ] Step 1: Replace the route body

In server/routes/api.js, find the router.post('/sealed-products/import', ...) handler (currently ~90 lines, from const { setCodes, uuids } = req.body; through the closing });). Replace its entire body with:

javascript
router.post('/sealed-products/import', validate(sealedImportSchema), async (req, res) => {
    try {
        const { game, setCodes, uuids } = req.body;
        const shop = req.shop;
        const store = req.store;

        const { getPlugin, isGameSupported } = require('../plugins');
        if (!isGameSupported(game)) {
            return res.status(400).json({ error: `Unsupported game: ${game}` });
        }
        const plugin = getPlugin(game);
        if (!plugin.supportsSealed) {
            return res.status(400).json({ error: `Sealed products are not supported for ${game}` });
        }

        const ShopifyAPI = require('../services/shopifyAPI');
        const shopifyAPI = new ShopifyAPI(shop, req.accessToken);
        const publicationIds = await shopifyAPI.resolveConfiguredPublicationIds(store?.salesChannelConfig);
        let imported = 0;
        let skipped = 0;
        let failed = 0;
        const importedProducts = [];

        const toImport = uuids
            ? await plugin.findSealedProducts({ uuids })
            : await plugin.findSealedProducts({ setCodes });

        if (toImport.length === 0) {
            return res.json({ imported: 0, skipped: 0, failed: 0, products: [] });
        }

        const allUuids = toImport.map(({ product }) => product.uuid);
        const existingProducts = await SealedProduct.find({
            shop, game, uuid: { $in: allUuids }
        }).select('uuid').lean();
        const existingUuids = new Set(existingProducts.map(p => p.uuid));

        const newToImport = toImport.filter(({ product }) => !existingUuids.has(product.uuid));
        skipped = toImport.length - newToImport.length;

        if (newToImport.length === 0) {
            return res.json({ imported: 0, skipped, failed: 0, products: [] });
        }

        const priceInputs = newToImport.map(({ product, setData }) => ({
            uuid: product.uuid,
            setCode: setData.code,
            category: categorizeProduct(product.name || 'Unknown')
        }));
        const priceMap = await plugin.preloadSealedPrices(priceInputs);

        for (const { product, setData } of newToImport) {
            const transformed = plugin.transformSealedToProduct(product, setData);
            const priceResult = priceMap.get(product.uuid) || { price: null, source: null, timestamp: null };

            try {
                const suggestedPrice = priceResult.price
                    ? applySealedPricingRules(priceResult.price, store)
                    : null;

                let shopifyProductId = null;
                let shopifyVariantId = null;
                try {
                    const shopifyProduct = await shopifyAPI.createProduct({
                        title: transformed.title,
                        handle: transformed.handle,
                        body: transformed.body,
                        vendor: transformed.vendor,
                        type: transformed.type,
                        tags: transformed.tags,
                        published: false, // DRAFT status
                        variantprice: suggestedPrice || 0,
                        variantsku: transformed.sku,
                        variantinventorypolicy: 'deny',
                        imagesrc: transformed.imagesrc,
                        imagealttext: transformed.imagealttext,
                        metafields: transformed.metafields
                    }, publicationIds);
                    shopifyProductId = shopifyProduct.id;
                    shopifyVariantId = shopifyProduct.variants?.edges?.[0]?.node?.id || null;
                } catch (shopifyErr) {
                    logger.error('Failed to create sealed product in Shopify', {
                        shop, uuid: transformed.uuid, name: transformed.name, error: shopifyErr.message
                    });
                }

                const sealedProduct = await SealedProduct.create({
                    shop,
                    game,
                    uuid: transformed.uuid,
                    name: transformed.name,
                    category: transformed.category,
                    setCode: transformed.setCode,
                    setName: transformed.setName,
                    releaseDate: transformed.releaseDate ? new Date(transformed.releaseDate) : null,
                    identifiers: transformed.identifiers,
                    purchaseUrls: transformed.purchaseUrls,
                    currentPrice: suggestedPrice,
                    pricingSource: priceResult.source || 'tcgplayer',
                    lastPriceUpdate: priceResult.timestamp || null,
                    msrp: priceResult.source === 'msrp' ? priceResult.price : undefined,
                    status: 'draft',
                    requiresReview: true,
                    shopifyProductId: shopifyProductId || undefined,
                    shopifyVariantId: shopifyVariantId || undefined,
                    syncStatus: shopifyProductId ? 'synced' : 'failed',
                    lastSyncedAt: shopifyProductId ? new Date() : undefined,
                    syncError: shopifyProductId ? undefined : 'Failed to create in Shopify'
                });

                imported++;
                importedProducts.push({
                    id: sealedProduct._id,
                    name: transformed.name,
                    category: transformed.category,
                    setCode: transformed.setCode,
                    currentPrice: suggestedPrice,
                    pricingSource: priceResult.source,
                    shopifyProductId
                });
            } catch (err) {
                if (err.code === 11000) {
                    skipped++;
                } else {
                    failed++;
                    logger.error('Failed to import sealed product', {
                        shop, uuid: transformed.uuid, name: transformed.name, error: err.message
                    });
                }
            }
        }

        logger.info('Sealed product import completed', { shop, game, imported, skipped, failed });

        res.json({ imported, skipped, failed, products: importedProducts });
    } catch (error) {
        logger.error('Failed to import sealed products', { error: error.message, shop: req.shop });
        res.status(500).json({ error: 'Failed to import sealed products' });
    }
});

Note what changed vs. what didn't: the Shopify-creation payload fields, the SealedProduct.create() field list, the DRAFT (published: false) status, the duplicate-key (11000) handling, and the response shape are all identical to before for MTG โ€” only the source of product/setData/pricing/transform moved from inline SetModel/categorizeProduct/getSealedProductPrice calls to plugin.findSealedProducts/plugin.transformSealedToProduct/plugin.preloadSealedPrices. game is now required in the request and threaded into the SealedProduct.find/.create() calls.

  • [ ] Step 2: Run the full server test suite

Run: npm test Expected: PASS, no regressions.

  • [ ] Step 3: Run lint

Run: npm run lint 2>&1 | grep -A5 "routes.api.js$" Expected: no new warnings beyond whatever pre-existing ones this file already had (record the before/after count if any exist).

  • [ ] Step 4: Commit
bash
git add server/routes/api.js
git commit -m "$(cat <<'EOF'
Route sealed product import through the plugin interface for both games

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 7: Route โ€” quick-add + quick-add-bulk dispatch through the plugin โ€‹

Files:

  • Modify: server/routes/api.js

Interfaces:

  • Consumes: same plugin methods as Task 6.

  • [ ] Step 1: Rewrite quickAddSealedProduct

In server/routes/api.js, replace the entire async function quickAddSealedProduct({ shop, store, accessToken, uuid, quantity, price }) function body with:

javascript
async function quickAddSealedProduct({ shop, store, accessToken, game, uuid, quantity, price }) {
    const ShopifyAPI = require('../services/shopifyAPI');
    const shopifyAPI = new ShopifyAPI(shop, accessToken);

    const existing = await SealedProduct.findOne({ shop, game, uuid });

    if (existing) {
        if (!existing.shopifyProductId || !existing.shopifyVariantId) {
            throw new QuickAddError(
                'Sealed product exists but was never successfully synced to Shopify. Re-import it from Browse & Import.',
                400
            );
        }

        let priceUpdated = false;
        if (price !== existing.currentPrice) {
            await shopifyAPI.updateVariantGraphQL(
                existing.shopifyVariantId,
                { price: String(price) },
                existing.shopifyProductId
            );
            existing.currentPrice = price;
            existing.pricingSource = 'manual';
            priceUpdated = true;
        }

        if (priceUpdated) {
            await existing.save();
        }

        let quantityAdded = 0;
        let quantityWarning = null;
        if (quantity > 0) {
            try {
                const inventoryItemId = await shopifyAPI.getVariantInventoryItemId(existing.shopifyVariantId);
                const locationId = await shopifyAPI.getDefaultLocationId();
                if (!locationId || !inventoryItemId) {
                    throw new Error('No active Shopify location found');
                }
                await shopifyAPI.addInventoryQuantity(inventoryItemId, locationId, quantity);
                quantityAdded = quantity;
            } catch (invErr) {
                logger.warn('Sealed product quick-add: price updated but inventory add failed', {
                    shop, uuid, error: invErr.message
                });
                quantityWarning = 'Price was updated, but adding inventory failed โ€” add it manually or retry.';
            }
        }

        return { created: false, product: existing, priceUpdated, quantityAdded, quantityWarning };
    }

    const { getPlugin, isGameSupported } = require('../plugins');
    if (!isGameSupported(game)) {
        throw new QuickAddError(`Unsupported game: ${game}`, 400);
    }
    const plugin = getPlugin(game);
    if (!plugin.supportsSealed) {
        throw new QuickAddError(`Sealed products are not supported for ${game}`, 400);
    }

    const matches = await plugin.findSealedProducts({ uuids: [uuid] });
    if (matches.length === 0) {
        throw new QuickAddError('Sealed product not found in catalog', 404);
    }
    const { product, setData } = matches[0];
    const transformed = plugin.transformSealedToProduct(product, setData);

    const publicationIds = await shopifyAPI.resolveConfiguredPublicationIds(store?.salesChannelConfig);

    let shopifyProduct;
    try {
        shopifyProduct = await shopifyAPI.createProduct({
            title: transformed.title,
            handle: transformed.handle,
            body: transformed.body,
            vendor: transformed.vendor,
            type: transformed.type,
            tags: transformed.tags,
            published: true,
            variantprice: price,
            variantsku: transformed.sku,
            variantinventorypolicy: 'deny',
            imagesrc: transformed.imagesrc,
            imagealttext: transformed.imagealttext,
            metafields: transformed.metafields
        }, publicationIds);
    } catch (shopifyErr) {
        logger.error('Sealed product quick-add: failed to create Shopify product', {
            shop, uuid, name: transformed.name, error: shopifyErr.message
        });
        throw new QuickAddError('Failed to create product in Shopify. Nothing was saved โ€” try again.');
    }

    const shopifyProductId = shopifyProduct.id;
    const shopifyVariantId = shopifyProduct.variants?.edges?.[0]?.node?.id || null;

    const sealedProduct = await SealedProduct.create({
        shop,
        game,
        uuid: transformed.uuid,
        name: transformed.name,
        category: transformed.category,
        setCode: transformed.setCode,
        setName: transformed.setName,
        releaseDate: transformed.releaseDate ? new Date(transformed.releaseDate) : null,
        identifiers: transformed.identifiers,
        purchaseUrls: transformed.purchaseUrls,
        currentPrice: price,
        pricingSource: 'manual',
        lastPriceUpdate: new Date(),
        status: 'active',
        requiresReview: false,
        reviewedBy: shop,
        reviewedAt: new Date(),
        shopifyProductId,
        shopifyVariantId,
        syncStatus: 'synced',
        lastSyncedAt: new Date()
    });

    let quantityAdded = 0;
    let quantityWarning = null;
    if (quantity > 0 && shopifyVariantId) {
        try {
            const inventoryItemId = await shopifyAPI.getVariantInventoryItemId(shopifyVariantId);
            const locationId = await shopifyAPI.getDefaultLocationId();
            if (!locationId || !inventoryItemId) {
                throw new Error('No active Shopify location found');
            }
            await shopifyAPI.addInventoryQuantity(inventoryItemId, locationId, quantity);
            quantityAdded = quantity;
        } catch (invErr) {
            logger.warn('Sealed product quick-add: product created but inventory add failed', {
                shop, uuid, error: invErr.message
            });
            quantityWarning = 'Product was created and published, but adding inventory failed โ€” add it manually or retry.';
        }
    }

    return { created: true, product: sealedProduct, priceUpdated: false, quantityAdded, quantityWarning };
}
  • [ ] Step 2: Thread game through both call sites

In the router.post('/sealed-products/quick-add', ...) handler, change:

javascript
        const { uuid, quantity, price } = req.body;
        const result = await quickAddSealedProduct({
            shop: req.shop, store: req.store, accessToken: req.accessToken, uuid, quantity, price
        });

to:

javascript
        const { game, uuid, quantity, price } = req.body;
        const result = await quickAddSealedProduct({
            shop: req.shop, store: req.store, accessToken: req.accessToken, game, uuid, quantity, price
        });

The router.post('/sealed-products/quick-add-bulk', ...) handler already spreads ...item into the call (quickAddSealedProduct({ shop, store, accessToken, ...item })), and each item now includes game per Task 4's schema change to quickAddSchema โ€” no edit needed there.

  • [ ] Step 3: Run the full server test suite

Run: npm test Expected: PASS, no regressions.

  • [ ] Step 4: Commit
bash
git add server/routes/api.js
git commit -m "$(cat <<'EOF'
Route sealed product quick-add through the plugin interface for both games

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 8: Route โ€” GET /sealed-products/preview dispatches through the plugin โ€‹

Files:

  • Modify: server/routes/api.js

  • [ ] Step 1: Replace the route body

In server/routes/api.js, find router.get('/sealed-products/preview', ...). Replace its body with:

javascript
router.get('/sealed-products/preview', validate(sealedPreviewQuerySchema, { source: 'query' }), async (req, res) => {
    try {
        const { game, uuids } = req.query;

        const uuidList = uuids.split(',').map(u => u.trim()).filter(Boolean);
        if (uuidList.length === 0) {
            return res.status(400).json({ error: 'At least one UUID required' });
        }

        const shop = req.shop;
        const store = req.store;

        const { getPlugin, isGameSupported } = require('../plugins');
        if (!isGameSupported(game)) {
            return res.status(400).json({ error: `Unsupported game: ${game}` });
        }
        const plugin = getPlugin(game);
        if (!plugin.supportsSealed) {
            return res.status(400).json({ error: `Sealed products are not supported for ${game}` });
        }

        const [existingProducts, matches] = await Promise.all([
            SealedProduct.find({ shop, game, uuid: { $in: uuidList } }).select('uuid').lean(),
            plugin.findSealedProducts({ uuids: uuidList })
        ]);

        const existingUuids = new Set(existingProducts.map(p => p.uuid));
        const setNamesSet = new Set(matches.map(({ setData }) => `${setData.name} (${setData.code})`));

        const priceInputs = matches.map(({ product, setData }) => ({
            uuid: product.uuid,
            setCode: setData.code,
            category: categorizeProduct(product.name || 'Unknown')
        }));
        const priceMap = await plugin.preloadSealedPrices(priceInputs);

        const previewProducts = [];
        let withPricing = 0;
        let withMsrpOnly = 0;
        let noPricing = 0;

        for (const { product, setData } of matches) {
            const transformed = plugin.transformSealedToProduct(product, setData);
            const priceResult = priceMap.get(product.uuid) || { price: null, source: null, timestamp: null };

            const suggestedPrice = priceResult.price
                ? applySealedPricingRules(priceResult.price, store)
                : null;

            const hasPricing = suggestedPrice !== null && suggestedPrice > 0;
            const isMsrpOnly = priceResult.source === 'msrp';
            const isPreRelease = setData.releaseDate && new Date(setData.releaseDate) > new Date();

            if (hasPricing && !isMsrpOnly) {
                withPricing++;
            } else if (isMsrpOnly) {
                withMsrpOnly++;
            } else {
                noPricing++;
            }

            previewProducts.push({
                uuid: transformed.uuid,
                name: transformed.name,
                setCode: transformed.setCode,
                setName: transformed.setName,
                category: transformed.category,
                categoryLabel: CATEGORY_LABELS_SERVER[transformed.category] || transformed.category,
                releaseDate: transformed.releaseDate,
                imageUrl: transformed.imagesrc || null,
                suggestedPrice,
                pricingSource: priceResult.source || null,
                msrp: priceResult.source === 'msrp' ? priceResult.price : null,
                tcgplayerUrl: transformed.purchaseUrls?.tcgplayer || null,
                hasPricing,
                isMsrpOnly,
                isPreRelease,
                isAlreadyImported: existingUuids.has(transformed.uuid)
            });
        }

        logger.info('Sealed products preview generated', {
            shop, game, requested: uuidList.length, found: previewProducts.length, alreadyImported: existingUuids.size
        });

        res.json({
            products: previewProducts,
            stats: { total: previewProducts.length, withPricing, withMsrpOnly, noPricing },
            setNames: [...setNamesSet],
            alreadyImported: existingUuids.size
        });
    } catch (error) {
        logger.error('Failed to preview sealed products', { error: error.message, shop: req.shop });
        res.status(500).json({ error: 'Failed to preview sealed products' });
    }
});
  • [ ] Step 2: Run the full server test suite

Run: npm test Expected: PASS, no regressions.

  • [ ] Step 3: Commit
bash
git add server/routes/api.js
git commit -m "$(cat <<'EOF'
Route sealed product preview through the plugin interface for both games

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 9: Route โ€” GET /sealed-products (list) threads game; GET /catalog/sealed gets a Pokemon branch โ€‹

Files:

  • Modify: server/routes/api.js

  • [ ] Step 1: Thread game into the list route's filter

In router.get('/sealed-products', ...), find:

javascript
        const filter = { shop: req.shop };

Replace with:

javascript
        const { game } = req.query;
        const filter = { shop: req.shop, game };
  • [ ] Step 2: Add the Pokemon branch to /catalog/sealed

In router.get('/catalog/sealed', ...), find the end of the Riftbound branch (the return res.json({ sealedProducts, pagination: {...} }); } that closes if (game === 'riftbound') { ... }) and insert a new branch immediately after it, before the MTG-branch code that currently starts with const matchStage = {:

javascript
        if (game === 'pokemon') {
            const { getPlugin } = require('../plugins');
            const pokemonPlugin = getPlugin('pokemon');
            // Pokemon set ids are lowercase TCGCSV slugs (e.g. "tcgcsv-1663") โ€”
            // do NOT reuse the `sets` variable above, which is uppercased for
            // MTG/Riftbound. Re-derive without the case transform.
            const rawSets = req.query.sets ? req.query.sets.split(',').map(s => s.trim()) : null;
            // No category taxonomy exists for Pokemon sealed items yet โ€”
            // silently ignore, matching the Riftbound branch's precedent.
            const allResults = await pokemonPlugin.findSealedProducts({ setCodes: rawSets || undefined });

            const total = allResults.length;
            const paged = allResults.slice(skip, skip + limit);
            const sealedProducts = paged.map(({ product, setData }) => ({
                uuid: product.uuid,
                name: product.name,
                setCode: setData.code,
                setName: setData.name,
                imageUri: product.imageUrl,
                releaseDate: setData.releaseDate,
                purchaseUrls: product.purchaseUrls
            }));

            return res.json({
                sealedProducts,
                pagination: { page, limit, total, pages: Math.ceil(total / limit) }
            });
        }

(This mirrors the existing Riftbound branch's response shape exactly, so client code that already handles sealedProducts[].{uuid,name,setCode,setName,imageUri,releaseDate,purchaseUrls} needs no changes to consume Pokemon's browse results โ€” relevant for the deferred PR 3 client work.)

  • [ ] Step 3: Run the full server test suite

Run: npm test Expected: PASS, no regressions.

  • [ ] Step 4: Run lint

Run: npm run lint 2>&1 | grep -A10 "routes.api.js$" Expected: no new warnings.

  • [ ] Step 5: Commit
bash
git add server/routes/api.js
git commit -m "$(cat <<'EOF'
Thread game into the sealed products list filter and add Pokemon catalog browse

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 10: Exit bar โ€” real Shopify test-mode import (no code changes) โ€‹

This task has no commit โ€” it's the proof that the whole chain (schema โ†’ plugin โ†’ route โ†’ Shopify โ†’ DB) works end to end for a real Pokemon sealed product, mirroring PR 1's Task 7 pattern. Requires docker compose up -d (local Mongo), the server running (npm run dev or npm run dev:no-worker), and a real Shopify dev store connected (per root CLAUDE.md ยง4: ufkes-dev-2.myshopify.com or ufkes-dev-custom-app.myshopify.com if the former's token is stale โ€” check .superpowers/sdd/progress.md from PR 1's session or ask if neither works).

Files: none (verification only)

  • [ ] Step 1: Full suite + lint

Run: npm test โ€” expect exit 0, all suites pass. Run: npm run lint โ€” expect zero new warnings across this branch's diff (baseline pre-existing errors/warnings are a separate, known matter).

  • [ ] Step 2: Confirm a real Pokemon sealed product is queryable through the new interface

Using a set already ingested in PR 1's Task 7 (sv01-scarlet-violet-base-set had 46 sealed products as of that verification โ€” re-run node server/scripts/data-loading/updatePokemonData.js --set sv01-scarlet-violet-base-set and node server/scripts/data-loading/updatePokemonPrices.js --set sv01-scarlet-violet-base-set first if the local DB has been reset since then), start a Node REPL or a one-off script that:

javascript
require('dotenv').config();
const mongoose = require('mongoose');
mongoose.connect(process.env.MONGODB_URI).then(async () => {
    const { getPlugin } = require('./server/plugins');
    const plugin = getPlugin('pokemon');
    const results = await plugin.findSealedProducts({ setCodes: ['sv01-scarlet-violet-base-set'] });
    console.log('found:', results.length);
    console.log('sample:', JSON.stringify(results[0], null, 2));
    const priceMap = await plugin.preloadSealedPrices(
        results.map(({ product, setData }) => ({ uuid: product.uuid, setCode: setData.code, category: 'other' }))
    );
    console.log('price for sample:', priceMap.get(results[0].product.uuid));
    await mongoose.disconnect();
});

Expected: found > 0, the sample product has a real name/uuid, and the price lookup returns either a real price or {price: null, source: null, timestamp: null} (both are valid outcomes depending on whether that specific sealed item has market data).

  • [ ] Step 3: Import one real Pokemon sealed product via the actual route, against a real dev Shopify store

With the server running and authenticated against a real dev store, call:

bash
curl -X POST http://localhost:3000/api/sealed-products/import \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <dev-session-token-or-JWT>" \
  -d '{"game":"pokemon","setCodes":["sv01-scarlet-violet-base-set"]}'

(Use whatever auth mechanism local dev already uses โ€” DEV_BYPASS_AUTH=true DEV_BYPASS_SHOP=<shop> per PR 1's session notes, or a real session token via ngrok.)

Expected: response has imported > 0; at least one products[] entry has a shopifyProductId.

  • [ ] Step 4: Verify in Shopify admin

Open the dev store's admin, find the newly-created product (search by the name from Step 3's response). Expected: status is Draft, price matches what preloadSealedPrices returned (post applySealedPricingRules markup/rounding), title/vendor/tags match the transform's output.

  • [ ] Step 5: Verify the SealedProduct document
mongosh $MONGODB_URI --eval "
  db.sealedproducts.findOne({ shop: '<dev-shop>.myshopify.com', game: 'pokemon' }, { uuid: 1, name: 1, game: 1, setCode: 1, currentPrice: 1, shopifyProductId: 1, status: 1 })
"

Expected: one document with game: 'pokemon', status: 'draft', a shopifyProductId matching Step 4's product, and currentPrice matching what's shown in Shopify.

PR 2 exit bar: MET when Steps 3-5 all confirm against a real Shopify dev store โ€” this is the concrete proof MULTI_GAME_ARCHITECTURE.md's sealed exit bar asks for.


Self-review โ€‹

Spec coverage against MULTI_GAME_ARCHITECTURE.md "Sealed Products" requirements 1-3, 5 (required-flip half), 8, 9 (the subset this PR scopes, per the user's own framing):

  • Req 1 (capability declaration, server-enforced) โ€” Task 1 (supportsSealed) + every route in Tasks 6-9 checks plugin.supportsSealed before doing anything game-specific. Client capability-driven gating is explicitly deferred to PR 3 (see Scope Decision).
  • Req 2 (Plugin SEALED INTERFACE) โ€” Tasks 1-3. Signatures differ from the architecture doc's naive sketch (findSealedProducts(setCode) โ†’ findSealedProducts({setCodes, uuids}), preloadSealedPrices(uuids) โ†’ preloadSealedPrices(products: {uuid,setCode,category}[])) because tracing the actual MTG route code (Tasks 6-8's "before" state) showed the simpler signatures can't carry what MSRP fallback and quick-add-by-single-uuid actually need โ€” documented inline in Task 1.
  • Req 3 (sealed identity invariant) โ€” already proven in PR 1; Task 3's preloadSealedPrices reuses the exact slug/tcgplayerProductId keys PR 1 established.
  • Req 5, required-flip half โ€” Task 5.
  • Req 8 (game-threaded endpoints, routed through the plugin instead of reading mtg_sets directly) โ€” Tasks 6-9. /catalog/sealed's MTG branch is the one deliberate exception (Global Constraints explains why).
  • Req 9 (verification bar) โ€” Task 10.
  • Reqs 4, 6, 7 (price-source-per-game, MSRP fallback, per-game sealedPricingConfig) โ€” out of scope per the user's explicit framing; req 4 is already satisfied by PR 1, reqs 6-7 are noted as deferred in Global Constraints.

Placeholder scan: no TBD/"handle appropriately" language; every code step has the literal file contents to write.

Type consistency: findSealedProducts/transformSealedToProduct/preloadSealedPrices signatures are identical across BaseGamePlugin (Task 1), MTGPlugin (Task 2), and PokemonPlugin (Task 3) โ€” same parameter shapes, same return shapes โ€” and the route code in Tasks 6-9 calls them with matching argument shapes throughout ({setCodes} or {uuids} for findSealedProducts; {uuid, setCode, category}[] for preloadSealedPrices).