Appearance
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 asx.test.js. Plugin tests useconst XPlugin = require('./index');directly (seeserver/plugins/mtg/index.test.js) โ no dynamic import needed, unlike Mongoose model tests. - Never default
game(CLAUDE.md ยง5.5): every schema this PR addsgameto uses a schema with NO.optional()/.default()โ a newrequiredGameSchema(Task 4), distinct from the pre-existing, deliberately-defaultinggameSchemaused 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
supportsSealedin this PR (staysfalse, theBaseGamePlugindefault). Its existing sealed browse still works through its own hardcoded/catalog/sealedbranch (unchanged, added before this PR). Full Riftbound sealed enablement (import/quick-add) is its own future effort perMULTI_GAME_ARCHITECTURE.md. - Pokemon sealed pricing is market-price-only in this PR โ no MSRP fallback (
SealedProductMSRPper-game threading is explicitly out of scope; MTG keeps using it exactly as today). A Pokemon sealed product with no market price inpokemon_pricessimply getsprice: 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 existingsetsquery 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-uppercasedrawSetsfor the Pokemon branch instead of reusing the shared uppercased variable. Verified againstserver/plugins/pokemon/index.js'sfindSet(checks bothidand uppercasedabbreviation). - Coverage โฅ70% on all four metrics for every modified/created file except anything under
server/scripts/**(already-confirmed project-widevitest.config.jsexclusion, not this PR's concern).npm testandnpm run lintexit 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:
$projectbefore$sort(ยง5.6) โ applies to Task 3'spreloadSealedPricesfor 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 mainand merge if this branch (claude/pokemon-sealed-sync-enablement, based on the still-unmergedclaude/sealed-products-pokemon-0d42a1) is behind its base (ยง5.10) โ note this branch intentionally stacks on PR 1's branch, notmain, until PR 1 merges. - The
game-parityskill'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.jsis 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 onlythrow-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, defaultfalse);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'scategorizeProduct(name);server/services/priceLookupService.js'sgetSealedProductPrice(uuid, setCode, category, options).Produces:
MTGPlugin.supportsSealed === true; the three SEALED INTERFACE methods, extracted verbatim from the current inline logic inserver/routes/api.js's/sealed-products/import(lines ~3430-3472 for the lookup, ~3502-3563 for the transform) andgetSealedProductPricecalls. 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'scategorizeProduct.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 fromcatalog.js) โz.string().regex(/^[a-z0-9]{2,16}$/, 'Invalid game identifier'), no.optional(), no.default(). Added tosealedImportSchema,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
requiredGameSchemaand 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.gameis nowrequired: true, no default. Unique index is{shop:1, game:1, uuid:1}(was{shop:1, uuid:1}). Consumed by Tasks 6-8'sSealedProduct.create()/.find()calls, which must now always passgame.[ ] 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(fromTask 4's schema),plugin.supportsSealed,plugin.findSealedProducts,plugin.transformSealedToProduct,plugin.preloadSealedPrices(Tasks 1-3),categorizeProduct(existing import, for buildingpreloadSealedPricesinputs),applySealedPricingRules(existing import, unchanged).No test file for this task โ
server/routes/api.jshas no unit test coverage today (confirmed: noapi.test.jsexists 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
gamethrough 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
gameinto 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
SealedProductdocument
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 checksplugin.supportsSealedbefore 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
preloadSealedPricesreuses the exactslug/tcgplayerProductIdkeys PR 1 established. - Req 5, required-flip half โ Task 5.
- Req 8 (game-threaded endpoints, routed through the plugin instead of reading
mtg_setsdirectly) โ 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).
