Appearance
Sealed Buylist Support Implementation Plan β
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let merchants and customers quote factory-sealed products (booster boxes, bundles, decks) on buy orders, alongside singles, across MTG and Pokemon.
Architecture: Add a lineType: 'single' | 'sealed' discriminator to the existing BuylistOrder line schema so one order holds both. Sealed lines are priced by a new selector-only path in the quote engine that resolves identity from the global sealed source catalog (plugin.findSealedProducts) and basis price from plugin.preloadSealedPrices (market retail + MSRP fallback), then applies a per-game sealed rate config (flat defaultRate + per-category overrides). No condition/rarity/finish for sealed. Both the merchant tab and the public portal share the same engine.
Tech Stack: Node.js/Express (CommonJS), Mongoose, Zod, Vitest (ESM tests). React 18 + Vite + Tailwind + retroui. Per-game plugin layer in server/plugins/.
Global Constraints β
- Never default
game(CLAUDE.md Β§5.5) β no|| 'mtg'anywhere.lineTypeMAY default to'single'(a type tag, not an identity param; every legacy line is a single). - New sub-document fields declared field-by-field + round-trip test (CLAUDE.md Β§9, Β§5.2).
- Data-matching literals cite their source (CLAUDE.md Β§5.4). The sealed category vocabulary is
Object.keys(CATEGORY_LABELS_SERVER)inserver/utils/sealedProductUtils.js(the valuescategorizeProduct(name)emits). Do not invent category keys. - Per-game parity (CLAUDE.md Β§5.1): run the
game-parityskill before commit. Every task touching per-game behavior names mtg / pokemon / riftbound as mirrored or exempt.plugin.supportsSealedis true for all three games (sealed buylist itself is not gated per-game); pokemon and riftbound are exempt only from per-category rates, gated byplugin.hasSealedCategoryTaxonomy(false for both β noSEALED_CATEGORIESvocabulary for their sealed products), so their sealed lines price atsealed.defaultRate. - Server enforces; client mirrors (CLAUDE.md rule 7): sealed enablement and line validation live in Zod + route; the client copies are UX only.
- Never trust client-echoed identity (rule 7): for a sealed line only
sealedUuid,game,quantityare trusted;category/name/setCodeare re-derived server-side. - Aggregations
$projectbefore$sort(CLAUDE.md Β§5.6); never rely onallowDiskUse. - All Shopify calls via
shopifyAPI.js(rule 6) β not relevant to this plan (no Shopify writes), but do not add any. - Sealed category taxonomy exists for MTG only. Pokemon and Riftbound sealed have no category; their sealed lines carry
category: nulland fall through tosealed.defaultRate. This is expected, not a bug. - Spec:
docs/superpowers/specs/2026-07-23-sealed-buylist-support-design.md.
File Structure β
Server (create/modify):
server/models/BuylistOrder.jsβ addlineType,sealedUuid,categorytobuylistLineSchema.server/utils/sealedProductUtils.jsβ exportSEALED_CATEGORIES(ordered key list).server/services/buylistConfigService.jsβsealeddefaults block; sealed vocabulary in the config response; sealed-enablement gating inresolveLineGameConfigs.server/schemas/buylistConfig.jsβsealedobject in the update schema.server/schemas/buylistOrder.jsβselectedCardLineSchemabecomes a single|sealed union.server/services/buylistQuoteService.jsβfindSealedByUuid,resolveSealedBasisPrice,applySealedBuylistRate,priceIdentifiedSealedLine;priceSelectedLinesbranches onlineType.server/plugins/index.jsβgetGamesInfo()includessupportsSealed.server/schemas/catalog.jsβcatalogSealedQuerySchemagains optionalsearch.server/routes/api.jsβ/catalog/sealedname-search filtering in all three branches;POST /buylist/ordersalready routes through the updated service.server/services/publicBuylistService.jsβ inherits sealed via the shared engine +resolveLineGameConfigs;toPublicOrdersurfaceslineType/category.
Client (create/modify):
client/src/pages/buylist/SealedSearchPicker.jsxβ new sealed name-search picker.client/src/pages/buylist/NewBuyInTab.jsxβ Singles/Sealed toggle,addSealed, sealed cart rows.client/src/pages/portal/BuylistPortalPage.jsxβ same treatment.client/src/pages/buylist/BuylistReviewPage.jsx,client/src/pages/buylist/BuylistOrdersTab.jsxβ sealed chip.client/src/pages/buylist/BuylistSettingsTab.jsxβ sealed rate section.
Task 1: Line schema β lineType, sealedUuid, category β
Files:
- Modify:
server/models/BuylistOrder.js:12-30(buylistLineSchema) - Test:
server/models/BuylistOrder.test.js
Interfaces:
Produces:
buylistLineSchemaacceptslineType: 'single'|'sealed'(default'single'),sealedUuid: String,category: String. Existing single fields unchanged.[ ] Step 1: Write the failing round-trip test
Add to server/models/BuylistOrder.test.js (follow the file's existing import/connect pattern):
js
it('persists sealed line fields (lineType, sealedUuid, category)', async () => {
const order = await BuylistOrder.create({
shop: 'test.myshopify.com',
source: 'merchant',
customer: { email: 'seller@example.com' },
lines: [{
rawLine: '1 Foundations Play Booster Box [FDN]',
matchStatus: 'matched',
game: 'mtg',
lineType: 'sealed',
sealedUuid: 'abc-uuid-123',
category: 'booster_box',
cardName: 'Foundations Play Booster Box',
setCode: 'FDN',
quantity: 1,
basisPrice: 100,
offerPrice: 60,
included: true
}]
});
const read = await BuylistOrder.findById(order._id).lean();
expect(read.lines[0].lineType).toBe('sealed');
expect(read.lines[0].sealedUuid).toBe('abc-uuid-123');
expect(read.lines[0].category).toBe('booster_box');
});
it('defaults lineType to single for a line that omits it', async () => {
const order = await BuylistOrder.create({
shop: 'test.myshopify.com',
source: 'merchant',
customer: { email: 'seller@example.com' },
lines: [{ rawLine: '1 Sol Ring', matchStatus: 'matched', game: 'mtg', quantity: 1 }]
});
const read = await BuylistOrder.findById(order._id).lean();
expect(read.lines[0].lineType).toBe('single');
});- [ ] Step 2: Run the test to verify it fails
Run: npm test -- BuylistOrder Expected: FAIL β lineType/sealedUuid/category are dropped by strict mode (undefined), first assertion fails.
- [ ] Step 3: Add the fields to the schema
In server/models/BuylistOrder.js, inside buylistLineSchema, add after the game field (line 16):
js
// Discriminates a single-card line from a sealed-product line. Defaults
// to 'single' β every line written before sealed support existed is a
// single (this is a type tag, not a Β§5.5 identity default).
lineType: { type: String, enum: ['single', 'sealed'], default: 'single' },
// Sealed identity key (MTGJSON sealed uuid / String(tcgplayerProductId)).
// Set on sealed lines only; singles use cardUuid.
sealedUuid: String,
// Sealed product category (Object.keys(CATEGORY_LABELS_SERVER) in
// server/utils/sealedProductUtils.js). Re-derived server-side. MTG only;
// Pokemon/Riftbound sealed lines leave this unset.
category: String,- [ ] Step 4: Run the test to verify it passes
Run: npm test -- BuylistOrder Expected: PASS (both new tests + existing tests).
- [ ] Step 5: Commit
bash
git add server/models/BuylistOrder.js server/models/BuylistOrder.test.js
git commit -m "Add sealed line fields to buylist order schema"Task 2: SEALED_CATEGORIES vocabulary export β
Files:
- Modify:
server/utils/sealedProductUtils.js:110(module.exports) - Test:
server/utils/sealedProductUtils.test.js(create if absent)
Interfaces:
Produces:
SEALED_CATEGORIES: string[]β ordered category keys, the single source consumed by the config schema (Task 4) and config response (Task 3).[ ] Step 1: Write the failing test
Create/append server/utils/sealedProductUtils.test.js:
js
import { describe, it, expect } from 'vitest';
import pkg from './sealedProductUtils.js';
const { SEALED_CATEGORIES, CATEGORY_LABELS_SERVER, categorizeProduct } = pkg;
describe('SEALED_CATEGORIES', () => {
it('is exactly the CATEGORY_LABELS_SERVER keys', () => {
expect(SEALED_CATEGORIES).toEqual(Object.keys(CATEGORY_LABELS_SERVER));
});
it('contains every category categorizeProduct can emit', () => {
const emitted = ['booster_box', 'collector_booster_box', 'bundle', 'commander_deck', 'other'];
for (const c of emitted) expect(SEALED_CATEGORIES).toContain(c);
// categorizeProduct output is always a valid key
expect(SEALED_CATEGORIES).toContain(categorizeProduct('Foundations Play Booster Box'));
});
});- [ ] Step 2: Run the test to verify it fails
Run: npm test -- sealedProductUtils Expected: FAIL β SEALED_CATEGORIES is undefined.
- [ ] Step 3: Add the export
In server/utils/sealedProductUtils.js, before module.exports:
js
// Ordered category vocabulary β the keys categorizeProduct() emits and the
// SealedProduct.category enum's labeled subset. Single source of truth for
// the buylist sealed categoryRates schema (server/schemas/buylistConfig.js)
// and the settings UI's per-category rows.
const SEALED_CATEGORIES = Object.keys(CATEGORY_LABELS_SERVER);Update module.exports to include it:
js
module.exports = { categorizeProduct, CATEGORY_LABELS_SERVER, CATEGORY_TO_MSRP_TYPE, mapCategoryToMsrpType, SEALED_CATEGORIES };- [ ] Step 4: Run the test to verify it passes
Run: npm test -- sealedProductUtils Expected: PASS.
- [ ] Step 5: Commit
bash
git add server/utils/sealedProductUtils.js server/utils/sealedProductUtils.test.js
git commit -m "Export sealed category vocabulary for buylist config"Task 3: Sealed defaults + config response β
Files:
- Modify:
server/services/buylistConfigService.js:18-32(BASE_BUYLIST_DEFAULTS),:74-83(buildBuylistConfigResponse) - Test:
server/services/buylistConfigService.test.js
Interfaces:
Consumes:
SEALED_CATEGORIES(Task 2),plugin.supportsSealed.Produces: resolved config has a
sealedblock{ enabled, useMsrpFallback, defaultRate, categoryRates }.buildBuylistConfigResponsereturnssealedCategories: [{ id, name }]forsupportsSealedgames (else[]).[ ] Step 1: Write the failing tests
Append to server/services/buylistConfigService.test.js:
js
it('includes a sealed defaults block per game', () => {
const cfg = svc.getGameBuylistConfig({ buylistConfig: {} }, 'mtg');
expect(cfg.sealed).toEqual({
enabled: false,
useMsrpFallback: true,
defaultRate: 0.60,
categoryRates: {}
});
});
it('response includes sealed categories for a supportsSealed game', () => {
const resp = svc.buildBuylistConfigResponse({ buylistConfig: {} }, 'mtg');
expect(resp.sealedCategories.length).toBeGreaterThan(0);
expect(resp.sealedCategories[0]).toHaveProperty('id');
expect(resp.sealedCategories[0]).toHaveProperty('name');
});(Use the file's existing import alias for the service β shown here as svc.)
- [ ] Step 2: Run to verify it fails
Run: npm test -- buylistConfigService Expected: FAIL β cfg.sealed is undefined.
- [ ] Step 3: Implement
In server/services/buylistConfigService.js, add to BASE_BUYLIST_DEFAULTS (after instantQuote):
js
// Sealed-product buylist. Separate from the singles rates above: sealed
// has no rarity/finish/condition. defaultRate + categoryRates is the whole
// model; store-credit bonus is reused from storeCreditBonus above.
sealed: {
enabled: false,
useMsrpFallback: true, // pass-through to the sealed price basis (MSRP fallback)
defaultRate: 0.60, // pay 60% of market basis
categoryRates: {} // per-category overrides, keys = SEALED_CATEGORIES
}At the top of the file add:
js
const { SEALED_CATEGORIES, CATEGORY_LABELS_SERVER } = require('../utils/sealedProductUtils');In buildBuylistConfigResponse, add sealedCategories to the returned object:
js
function buildBuylistConfigResponse(store, gameId) {
const plugin = getPlugin(gameId);
return {
game: gameId,
rarities: Object.entries(plugin.rarities)
.sort(([, a], [, b]) => (a.sortOrder || 0) - (b.sortOrder || 0))
.map(([id, def]) => ({ id, name: def.name })),
sealedCategories: plugin.supportsSealed
? SEALED_CATEGORIES.map((id) => ({ id, name: CATEGORY_LABELS_SERVER[id] }))
: [],
config: getGameBuylistConfig(store, gameId)
};
}- [ ] Step 4: Run to verify it passes
Run: npm test -- buylistConfigService Expected: PASS.
- [ ] Step 5: Commit
bash
git add server/services/buylistConfigService.js server/services/buylistConfigService.test.js
git commit -m "Add sealed buylist config defaults and category vocabulary"Task 4: Sealed config validation schema β
Files:
- Modify:
server/schemas/buylistConfig.js:16-53(buildBuylistConfigUpdateSchema) - Test:
server/schemas/schemas.test.js(co-located schema tests) orserver/schemas/buylistConfig.test.js(create)
Interfaces:
Consumes:
SEALED_CATEGORIES(Task 2),plugin(for existing rarity keys).Produces: the update schema accepts a strict
sealedobject;categoryRateskeys are limited toSEALED_CATEGORIES, each arateSchema.nullable().optional().[ ] Step 1: Write the failing tests
Create server/schemas/buylistConfig.test.js:
js
import { describe, it, expect } from 'vitest';
import pkg from './buylistConfig.js';
const { buildBuylistConfigUpdateSchema } = pkg;
import plugins from '../plugins/index.js';
const mtg = plugins.getPlugin('mtg');
describe('sealed config validation', () => {
it('accepts a valid sealed block', () => {
const r = buildBuylistConfigUpdateSchema(mtg).safeParse({
sealed: { enabled: true, defaultRate: 0.6, categoryRates: { booster_box: 0.7 } }
});
expect(r.success).toBe(true);
});
it('rejects an unknown category key', () => {
const r = buildBuylistConfigUpdateSchema(mtg).safeParse({
sealed: { categoryRates: { not_a_category: 0.5 } }
});
expect(r.success).toBe(false);
});
it('rejects a rate above 1', () => {
const r = buildBuylistConfigUpdateSchema(mtg).safeParse({ sealed: { defaultRate: 1.5 } });
expect(r.success).toBe(false);
});
it('allows null to clear a category override', () => {
const r = buildBuylistConfigUpdateSchema(mtg).safeParse({
sealed: { categoryRates: { booster_box: null } }
});
expect(r.success).toBe(true);
});
});- [ ] Step 2: Run to verify it fails
Run: npm test -- buylistConfig Expected: FAIL β sealed key rejected by the outer .strict().
- [ ] Step 3: Implement
In server/schemas/buylistConfig.js, add the import at top:
js
const { SEALED_CATEGORIES } = require('../utils/sealedProductUtils');Inside buildBuylistConfigUpdateSchema, before the closing .strict() of the returned object, add a sealed property:
js
sealed: z.object({
enabled: z.boolean().optional(),
useMsrpFallback: z.boolean().optional(),
defaultRate: rateSchema.optional(),
// null clears a stored category override (see applyGameBuylistConfigUpdate).
categoryRates: z.object(Object.fromEntries(
SEALED_CATEGORIES.map((c) => [c, rateSchema.nullable().optional()])
)).strict().optional()
}).strict().optional(),- [ ] Step 4: Run to verify it passes
Run: npm test -- buylistConfig Expected: PASS.
- [ ] Step 5: Extend the null-clear stripping to sealed categoryRates
applyGameBuylistConfigUpdate (buylistConfigService.js) currently strips null from rarityRates only. Add the same for sealed.categoryRates. After the existing rarityRates strip loop:
js
if (merged.sealed?.categoryRates) {
for (const [cat, rate] of Object.entries(merged.sealed.categoryRates)) {
if (rate === null) delete merged.sealed.categoryRates[cat];
}
}Add a test to buylistConfigService.test.js:
js
it('strips a null sealed categoryRate on update', () => {
const store = { buylistConfig: { mtg: { sealed: { categoryRates: { booster_box: 0.7 } } } }, markModified() {} };
const merged = svc.applyGameBuylistConfigUpdate(store, 'mtg', { sealed: { categoryRates: { booster_box: null } } });
expect(merged.sealed.categoryRates.booster_box).toBeUndefined();
});Run: npm test -- buylistConfig buylistConfigService Expected: PASS.
- [ ] Step 6: Commit
bash
git add server/schemas/buylistConfig.js server/schemas/buylistConfig.test.js server/services/buylistConfigService.js server/services/buylistConfigService.test.js
git commit -m "Validate sealed buylist config updates against category vocabulary"Task 5: Quote engine β sealed pricing β
Files:
- Modify:
server/services/buylistQuoteService.js(add functions; branchpriceSelectedLines; extendgetDeps) - Test:
server/services/buylistQuoteService.test.js
Interfaces:
Consumes:
plugin.findSealedProducts({ uuids }),plugin.preloadSealedPrices([{ uuid, setCode, category }])βMap<uuid,{price,source}>,categorizeProduct(name), per-game configconfig.sealed.Produces:
applySealedBuylistRate({ basisPrice, category, quantity }, sealedConfig)β{ offerPrice, ruleApplied, lineTotal }.findSealedByUuid(sealedUuid, game, deps)β{ sealedUuid, name, setCode, category } | null.resolveSealedBasisPrice({ game, sealedUuid, setCode, category, useMsrpFallback }, deps)βnumber | null.priceIdentifiedSealedLine(selected, game, buylistConfig, deps)β BuylistOrder line shape withlineType:'sealed'.priceSelectedLinesrouteslineType === 'sealed'lines through the sealed path.
[ ] Step 1: Write the failing unit tests
Append to server/services/buylistQuoteService.test.js. Use the file's existing _setDeps/_resetDeps pattern:
js
describe('sealed pricing', () => {
const sealedConfig = { enabled: true, useMsrpFallback: true, defaultRate: 0.6, categoryRates: { booster_box: 0.7 } };
afterEach(() => svc._resetDeps());
it('applySealedBuylistRate uses categoryRates when set', () => {
const r = svc.applySealedBuylistRate({ basisPrice: 100, category: 'booster_box', quantity: 2 }, sealedConfig);
expect(r.offerPrice).toBe(70);
expect(r.ruleApplied).toBe('sealed:category:booster_box');
expect(r.lineTotal).toBe(140);
});
it('applySealedBuylistRate falls back to defaultRate for null/unknown category', () => {
const r = svc.applySealedBuylistRate({ basisPrice: 100, category: null, quantity: 1 }, sealedConfig);
expect(r.offerPrice).toBe(60);
expect(r.ruleApplied).toBe('sealed:default');
});
it('applySealedBuylistRate returns 0 when basis is null', () => {
const r = svc.applySealedBuylistRate({ basisPrice: null, category: 'bundle', quantity: 3 }, sealedConfig);
expect(r).toEqual({ offerPrice: 0, ruleApplied: 'no-price', lineTotal: 0 });
});
it('priceIdentifiedSealedLine composes a matched sealed line', async () => {
svc._setDeps({
getPlugin: () => ({}),
findSealedByUuid: async () => ({ sealedUuid: 'u1', name: 'FDN Booster Box', setCode: 'FDN', category: 'booster_box' }),
resolveSealedBasisPrice: async () => 100
});
// deps default to getDeps(), which returns the _setDeps-injected object above.
const line = await svc.priceIdentifiedSealedLine({ sealedUuid: 'u1', quantity: 2 }, 'mtg', { sealed: sealedConfig });
expect(line.matchStatus).toBe('matched');
expect(line.lineType).toBe('sealed');
expect(line.game).toBe('mtg');
expect(line.sealedUuid).toBe('u1');
expect(line.category).toBe('booster_box');
expect(line.offerPrice).toBe(70);
expect(line.included).toBe(true);
});
it('priceIdentifiedSealedLine returns unmatched when uuid not found', async () => {
svc._setDeps({ findSealedByUuid: async () => null });
const line = await svc.priceIdentifiedSealedLine({ sealedUuid: 'missing', quantity: 1 }, 'mtg', { sealed: sealedConfig });
expect(line.matchStatus).toBe('unmatched');
expect(line.included).toBe(false);
});
it('priceSelectedLines routes sealed lines to the sealed path', async () => {
svc._setDeps({
getGameBuylistConfig: () => ({ sealed: sealedConfig, priceBasis: 'market' }),
priceIdentifiedLine: async () => ({ matchStatus: 'matched', lineType: 'single', game: 'mtg', offerPrice: 1, quantity: 1, included: true }),
priceIdentifiedSealedLine: async (sel) => ({ matchStatus: 'matched', lineType: 'sealed', game: sel.game, sealedUuid: sel.sealedUuid, offerPrice: 70, quantity: sel.quantity, included: true })
});
const { lines } = await svc.priceSelectedLines({
store: {},
lines: [
{ lineType: 'single', game: 'mtg', cardUuid: 'c1', finish: '', condition: 'nm', quantity: 1 },
{ lineType: 'sealed', game: 'mtg', sealedUuid: 'u1', quantity: 2 }
]
});
expect(lines.find((l) => l.lineType === 'sealed').offerPrice).toBe(70);
expect(lines.find((l) => l.lineType === 'single')).toBeTruthy();
});
});Note:
priceIdentifiedSealedLineandpriceSelectedLinesread collaborators fromgetDeps(); the tests above inject them via_setDeps. Match the exact deps-access style already used bypriceIdentifiedLinein this file (it callsdeps.findCardByUuidetc.).
- [ ] Step 2: Run to verify it fails
Run: npm test -- buylistQuoteService Expected: FAIL β applySealedBuylistRate and the sealed functions are undefined.
- [ ] Step 3: Implement the sealed functions
In server/services/buylistQuoteService.js, add near applyBuylistRate:
js
/**
* Sealed buylist rate math. Sealed has no rarity/finish/condition β the whole
* model is categoryRates[category] ?? defaultRate, times the market basis.
* category is null for games with no sealed taxonomy (Pokemon/Riftbound),
* which falls through to defaultRate.
*/
function applySealedBuylistRate({ basisPrice, category, quantity }, sealedConfig) {
if (basisPrice == null) {
return { offerPrice: 0, ruleApplied: 'no-price', lineTotal: 0 };
}
const override = category != null ? sealedConfig.categoryRates?.[category] : undefined;
const rate = override ?? sealedConfig.defaultRate;
const ruleApplied = override != null ? `sealed:category:${category}` : 'sealed:default';
const offerPrice = round2(basisPrice * rate);
return { offerPrice, ruleApplied, lineTotal: round2(offerPrice * quantity) };
}
sealedConfig.categoryRates?.[category]indexes a plain config object by a server-derived category string. Ifsecurity/detect-object-injectionflags it, add the same targetedeslint-disable-next-lineused elsewhere in this file and note the value is a re-derived category from a fixed vocabulary.
Add the identity + basis resolvers near findCardByUuid / resolveBasisPrice:
js
/**
* Reverse lookup for a sealed line: identity comes ONLY from the source
* catalog, never from client-echoed fields (rule 7). category is derived the
* same way sealed pricing already derives it β categorizeProduct(name) β so
* the rate lookup and the MSRP fallback agree. Pokemon/Riftbound have no
* category taxonomy, so category stays null there.
*/
async function findSealedByUuid(sealedUuid, game, deps = getDeps()) {
const plugin = deps.getPlugin(game);
const results = await plugin.findSealedProducts({ uuids: [sealedUuid] });
if (!results || results.length === 0) return null;
const { product, setData } = results[0];
const category = plugin.supportsSealed && game === 'mtg'
? deps.categorizeProduct(product.name)
: null;
return {
sealedUuid,
name: product.name,
setCode: setData.code,
category
};
}
/**
* Sealed market basis via the plugin's own price data (MTG: Price collection +
* MSRP fallback; Pokemon/Riftbound: their price collections). Plugin-driven so
* this never hard-codes an MTG price path.
*/
async function resolveSealedBasisPrice({ game, sealedUuid, setCode, category, useMsrpFallback }, deps = getDeps()) {
const plugin = deps.getPlugin(game);
const priceMap = await plugin.preloadSealedPrices(
[{ uuid: sealedUuid, setCode, category }],
{ useMsrpFallback }
);
const result = priceMap.get(sealedUuid);
const price = result?.price;
return typeof price === 'number' && price > 0 ? price : null;
}
/**
* Sealed twin of priceIdentifiedLine. Selector-only; sealedUuid is the sole
* trusted input, everything else is re-derived.
*/
async function priceIdentifiedSealedLine(selected, game, buylistConfig, deps = getDeps()) {
const sealed = await deps.findSealedByUuid(selected.sealedUuid, game, deps);
if (!sealed) {
return {
rawLine: `${selected.quantity} [unavailable sealed ${selected.sealedUuid}]`,
matchStatus: 'unmatched',
lineType: 'sealed',
game,
sealedUuid: selected.sealedUuid,
quantity: selected.quantity,
included: false
};
}
const basisPrice = await deps.resolveSealedBasisPrice(
{ game, sealedUuid: sealed.sealedUuid, setCode: sealed.setCode, category: sealed.category, useMsrpFallback: buylistConfig.sealed.useMsrpFallback },
deps
);
const { offerPrice, ruleApplied } = deps.applySealedBuylistRate(
{ basisPrice, category: sealed.category, quantity: selected.quantity },
buylistConfig.sealed
);
return {
rawLine: `${selected.quantity} ${sealed.name}${sealed.setCode ? ` [${sealed.setCode}]` : ''}`,
matchStatus: 'matched',
lineType: 'sealed',
game,
sealedUuid: sealed.sealedUuid,
cardName: sealed.name,
setCode: sealed.setCode,
category: sealed.category,
quantity: selected.quantity,
basisPrice,
ruleApplied,
offerPrice,
included: true
};
}Branch priceSelectedLines β change the batch mapper to route by lineType:
js
const batchResults = await Promise.all(
batch.map((selected) => selected.lineType === 'sealed'
? deps.priceIdentifiedSealedLine(selected, selected.game, getConfig(selected.game), deps)
: deps.priceIdentifiedLine(selected, selected.game, getConfig(selected.game), deps))
);Add the new functions and categorizeProduct to getDeps() defaults and to module.exports:
js
// in getDeps() return object:
categorizeProduct: require('../utils/sealedProductUtils').categorizeProduct,
findSealedByUuid,
resolveSealedBasisPrice,
applySealedBuylistRate,
priceIdentifiedSealedLine,js
// in module.exports:
applySealedBuylistRate,
findSealedByUuid,
resolveSealedBasisPrice,
priceIdentifiedSealedLine,- [ ] Step 4: Run to verify it passes
Run: npm test -- buylistQuoteService Expected: PASS.
- [ ] Step 5: Verify
preloadSealedPricesaccepts the options arg (parity check)
The MTG plugin's preloadSealedPrices(products) (server/plugins/mtg/index.js:644) ignores a second arg today and always calls getSealedProductPrice(uuid, setCode, category) β whose default useMsrpFallback is true. Confirm Pokemon (server/plugins/pokemon/index.js:580) and Riftbound implementations also tolerate a second arg (extra args are harmless in JS). If you want useMsrpFallback:false to actually flow through, thread options.useMsrpFallback into each plugin's getSealedProductPrice/equivalent call. Minimum for this task: leave the plugins as-is (MSRP fallback stays on) and record in the commit body that useMsrpFallback:false is not yet honored end-to-end β a documented, not silent, limitation. Run the game-parity skill and name mtg/pokemon/riftbound.
- [ ] Step 6: Commit
bash
git add server/services/buylistQuoteService.js server/services/buylistQuoteService.test.js
git commit -m "Price sealed products in the buylist quote engine"Task 6: selectedCardLineSchema becomes single|sealed union β
Files:
- Modify:
server/schemas/buylistOrder.js:12-20 - Test:
server/schemas/buylistOrder.test.js(create) β covers both merchant and public reuse.
Interfaces:
Produces:
selectedCardLineSchemaaccepts either a single line (cardUuidrequired;lineTypeabsent or'single') or a sealed line (lineType:'sealed',sealedUuidrequired, no finish/condition). Both requiregame+quantity.[ ] Step 1: Write the failing tests
Create server/schemas/buylistOrder.test.js:
js
import { describe, it, expect } from 'vitest';
import pkg from './buylistOrder.js';
const { selectedCardLineSchema } = pkg;
describe('selectedCardLineSchema union', () => {
it('accepts a single line without lineType (back-compat)', () => {
expect(selectedCardLineSchema.safeParse({ cardUuid: 'c1', game: 'mtg', finish: '', condition: 'nm', quantity: 1 }).success).toBe(true);
});
it('accepts a sealed line', () => {
expect(selectedCardLineSchema.safeParse({ lineType: 'sealed', sealedUuid: 'u1', game: 'mtg', quantity: 2 }).success).toBe(true);
});
it('rejects a sealed line missing sealedUuid', () => {
expect(selectedCardLineSchema.safeParse({ lineType: 'sealed', game: 'mtg', quantity: 1 }).success).toBe(false);
});
it('rejects a sealed line carrying card fields (strict)', () => {
expect(selectedCardLineSchema.safeParse({ lineType: 'sealed', sealedUuid: 'u1', cardUuid: 'c1', game: 'mtg', quantity: 1 }).success).toBe(false);
});
it('rejects a single line missing cardUuid', () => {
expect(selectedCardLineSchema.safeParse({ game: 'mtg', finish: '', condition: 'nm', quantity: 1 }).success).toBe(false);
});
});- [ ] Step 2: Run to verify it fails
Run: npm test -- buylistOrder Expected: FAIL β sealed line rejected (no sealedUuid/lineType support yet).
- [ ] Step 3: Implement the union
Replace the selectedCardLineSchema definition in server/schemas/buylistOrder.js:
js
// A search-and-select single line. lineType absent => 'single' (back-compat
// with clients that predate sealed support).
const singleSelectedLineSchema = z.object({
lineType: z.literal('single').optional(),
cardUuid: z.string().min(1, 'cardUuid is required'),
game: z.string().min(1, 'game is required'),
// '' means "no explicit finish choice" β the server re-derives it.
finish: z.string(),
condition: z.enum(VALID_CONDITIONS),
quantity: z.number().int().min(1).max(9999)
}).strict();
// A sealed-product line. Only sealedUuid/game/quantity are trusted; identity,
// category, and price are re-derived server-side (rule 7). No finish/condition.
const sealedSelectedLineSchema = z.object({
lineType: z.literal('sealed'),
sealedUuid: z.string().min(1, 'sealedUuid is required'),
game: z.string().min(1, 'game is required'),
quantity: z.number().int().min(1).max(9999)
}).strict();
// Sealed tried first (it requires the discriminator); anything else is a
// single. z.union rather than discriminatedUnion because a legacy single line
// may omit lineType entirely.
const selectedCardLineSchema = z.union([sealedSelectedLineSchema, singleSelectedLineSchema]);Keep the existing module.exports list (still exports selectedCardLineSchema).
- [ ] Step 4: Run to verify it passes
Run: npm test -- buylistOrder Expected: PASS. Also run npm test -- publicBuylist (the public schema imports this) to confirm no regression.
- [ ] Step 5: Commit
bash
git add server/schemas/buylistOrder.js server/schemas/buylistOrder.test.js
git commit -m "Accept sealed lines in the buylist order line schema"Task 7: Sealed-enablement gate in resolveLineGameConfigs β
Files:
- Modify:
server/services/buylistConfigService.js:103-123(resolveLineGameConfigs) - Test:
server/services/buylistConfigService.test.js
Interfaces:
Consumes: lines each with
gameand optionallineType; per-game config withsealed.enabled.Produces:
resolveLineGameConfigsreturns{ error, status }naming any game that has a sealed line while itssealed.enabledis false; otherwise{ configsByGame }unchanged.[ ] Step 1: Write the failing tests
Append to server/services/buylistConfigService.test.js:
js
it('rejects a sealed line whose game has sealed disabled', () => {
const store = { buylistConfig: { mtg: { enabled: true, sealed: { enabled: false } } } };
const r = svc.resolveLineGameConfigs(store, [{ game: 'mtg', lineType: 'sealed' }]);
expect(r.error).toMatch(/sealed buylist is not enabled/i);
expect(r.error).toMatch(/mtg/);
expect(r.status).toBe(400);
});
it('allows a sealed line when sealed is enabled', () => {
const store = { buylistConfig: { mtg: { enabled: true, sealed: { enabled: true } } } };
const r = svc.resolveLineGameConfigs(store, [{ game: 'mtg', lineType: 'sealed' }]);
expect(r.error).toBeUndefined();
expect(r.configsByGame.mtg).toBeTruthy();
});
it('ignores sealed.enabled for single-only orders', () => {
const store = { buylistConfig: { mtg: { enabled: true, sealed: { enabled: false } } } };
const r = svc.resolveLineGameConfigs(store, [{ game: 'mtg', lineType: 'single' }]);
expect(r.error).toBeUndefined();
});- [ ] Step 2: Run to verify it fails
Run: npm test -- buylistConfigService Expected: FAIL β sealed lines currently pass the gate.
- [ ] Step 3: Implement
In resolveLineGameConfigs, after the existing disabled (singles-enabled) check block, before return { configsByGame }, add:
js
const sealedGames = [...new Set(lines.filter((l) => l.lineType === 'sealed').map((l) => l.game))];
const sealedDisabled = sealedGames.filter((g) => !configsByGame[g].sealed?.enabled);
if (sealedDisabled.length) {
return { error: `Sealed buylist is not enabled for: ${sealedDisabled.join(', ')}`, status: 400 };
}- [ ] Step 4: Run to verify it passes
Run: npm test -- buylistConfigService Expected: PASS.
- [ ] Step 5: Commit
bash
git add server/services/buylistConfigService.js server/services/buylistConfigService.test.js
git commit -m "Gate sealed buy lines on per-game sealed enablement"Task 8: Route + public integration test β
Files:
- Test only: co-located buylist route test (follow the existing
POST /buylist/orderstest file/pattern) andserver/services/publicBuylistService.test.js. - Modify (only if needed):
server/services/publicBuylistService.jsβ ensuretoPublicOrdersurfaceslineType/category.
Interfaces:
Consumes: everything from Tasks 1β7. No new production interface unless
toPublicOrderomits the new fields.[ ] Step 1: Write the failing route tests
In the buylist route test file, add (mirror the existing create-order test setup β auth stub, req.store, and any _setDeps used to stub the quote engine):
js
it('creates a mixed single + sealed order', async () => {
// store config: mtg.enabled = true, mtg.sealed.enabled = true
const res = await request(app).post('/api/buylist/orders').send({
customer: { email: 'seller@example.com' },
lines: [
{ cardUuid: 'c1', game: 'mtg', finish: '', condition: 'nm', quantity: 1 },
{ lineType: 'sealed', sealedUuid: 'u1', game: 'mtg', quantity: 2 }
]
});
expect(res.status).toBe(201);
const sealed = res.body.order.lines.find((l) => l.lineType === 'sealed');
expect(sealed.sealedUuid).toBe('u1');
});
it('rejects a sealed line when sealed is disabled for the game', async () => {
// store config: mtg.enabled = true, mtg.sealed.enabled = false
const res = await request(app).post('/api/buylist/orders').send({
customer: { email: 'seller@example.com' },
lines: [{ lineType: 'sealed', sealedUuid: 'u1', game: 'mtg', quantity: 1 }]
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/sealed buylist is not enabled/i);
});Stub the sealed catalog/price deps via buylistQuoteService._setDeps so findSealedByUuid/resolveSealedBasisPrice return deterministic values (no live DB), matching how the existing selector-line route test stubs findCardByUuid.
- [ ] Step 2: Run to verify it fails
Run: npm test -- buylist (route + public) Expected: FAIL if toPublicOrder drops lineType/category, or PASS on the merchant route if Tasks 1β7 are wired (then this task only adds the public fix + coverage).
- [ ] Step 3: Surface sealed fields in the public order shape
In server/services/publicBuylistService.js, find toPublicOrder (the line-mapping helper) and ensure each mapped line includes lineType and category (add them to the picked fields). Add a public-service test:
js
it('public order lines expose lineType and category', async () => {
// build an order with a sealed line, call toPublicOrder / createPublicOrder
// assert the returned line has lineType 'sealed' and the category string
});- [ ] Step 4: Run to verify it passes
Run: npm test -- buylist Expected: PASS.
- [ ] Step 5: Commit
bash
git add server/routes/api.test.js server/services/publicBuylistService.js server/services/publicBuylistService.test.js
git commit -m "Cover mixed single and sealed buy orders end to end"Task 9: getGamesInfo exposes supportsSealed β
Files:
- Modify:
server/plugins/index.js:72-80(getGamesInfo) - Test:
server/plugins/index.test.js(create if absent) or the existing plugins registry test.
Interfaces:
Produces: each entry from
getGamesInfo()and/api/gamesincludessupportsSealed: boolean. Consumed by the client (Tasks 11β12).[ ] Step 1: Write the failing test
js
import { describe, it, expect } from 'vitest';
import plugins from './index.js';
describe('getGamesInfo', () => {
it('includes supportsSealed for each game', () => {
const games = plugins.getGamesInfo();
const mtg = games.find((g) => g.id === 'mtg');
expect(mtg).toHaveProperty('supportsSealed');
expect(typeof mtg.supportsSealed).toBe('boolean');
});
});- [ ] Step 2: Run to verify it fails
Run: npm test -- plugins Expected: FAIL β supportsSealed absent.
- [ ] Step 3: Implement
In getGamesInfo(), add to the mapped object:
js
supportsSealed: plugin.supportsSealed,- [ ] Step 4: Run to verify it passes
Run: npm test -- plugins Expected: PASS.
- [ ] Step 5: Commit
bash
git add server/plugins/index.js server/plugins/index.test.js
git commit -m "Expose supportsSealed in games info for the client"Task 10: Sealed search on /catalog/sealed β
Files:
- Modify:
server/schemas/catalog.js(catalogSealedQuerySchema) β add optionalsearch. - Modify:
server/routes/api.js:1246-1450(three branches of/catalog/sealed). - Test: co-located catalog route test.
Interfaces:
Produces:
GET /api/catalog/sealed?game=<id>&search=<name>returns products whose name matchessearch(case-insensitive substring), each with{ uuid, name, setCode, setName, category?, suggestedPrice, pricingSource }. Empty/rejected for!plugin.supportsSealed.[ ] Step 1: Write the failing test
In the catalog route test file:
js
it('filters sealed products by name search', async () => {
const res = await request(app).get('/api/catalog/sealed').query({ game: 'mtg', search: 'foundations', limit: 5 });
expect(res.status).toBe(200);
for (const p of res.body.sealedProducts) {
expect(p.name.toLowerCase()).toContain('foundations');
}
});(If the test DB has no MTG sealed rows, assert instead that a search with no matches returns an empty sealedProducts array and status 200 β the point is the param is accepted and applied.)
- [ ] Step 2: Run to verify it fails
Run: npm test -- catalog Expected: FAIL β search rejected by the query schema (unknown key) or ignored.
- [ ] Step 3: Add
searchto the schema
In server/schemas/catalog.js, catalogSealedQuerySchema, add:
js
search: trimmedSearchSchema, // optional name filter (same schema the cards/sets searches use)- [ ] Step 4: Apply the filter in all three branches
In server/routes/api.js, /catalog/sealed handler:
Derive once near the top of the handler:
js
const search = req.query.search ? String(req.query.search).trim().toLowerCase() : null;- Riftbound branch (aggregation): add a
nameregex tofilterbefore the count/aggregate (escape the input):
js
if (search) filter.name = { $regex: escapeRegex(search), $options: 'i' };- Pokemon branch (post-
findSealedProductsfilter, in-memory): filterallResultsbefore paging:
js
const filtered = search
? allResults.filter(({ product }) => product.name.toLowerCase().includes(search))
: allResults;
const total = filtered.length;
const paged = filtered.slice(skip, skip + limit);- MTG branch (SetModel aggregation): add a
$matchon the unwound sealed product name after$unwindand reflect it in both the count pipeline and the main pipeline (keep$projectbefore$sort, Β§5.6):
js
if (search) {
sealedMatchStage['data.sealedProduct.name'] = { $regex: escapeRegex(search), $options: 'i' };
}(The handler already pushes sealedMatchStage into both pipelines when non-empty β adding the name key reuses that path. Confirm escapeRegex is imported/available in api.js; if not, inline a small escape or reuse the existing helper.)
- [ ] Step 5: Run to verify it passes
Run: npm test -- catalog Expected: PASS.
- [ ] Step 6: Parity + commit
Run the game-parity skill (all three branches touched). Then:
bash
git add server/schemas/catalog.js server/routes/api.js server/routes/api.test.js
git commit -m "Add name search to the sealed catalog endpoint"Task 11: SealedSearchPicker component β
Files:
- Create:
client/src/pages/buylist/SealedSearchPicker.jsx - Test:
client/src/pages/buylist/SealedSearchPicker.test.jsx
Interfaces:
Produces:
<SealedSearchPicker game={game} onSelect={fn} apiClient={api} />. Debounced name search against/catalog/sealed?search=&game=&limit=8; on pick callsonSelect(product)where product has{ uuid, name, setCode, setName, category, suggestedPrice }.[ ] Step 1: Write the failing test
jsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import SealedSearchPicker from './SealedSearchPicker';
it('searches sealed products and selects one', async () => {
const apiClient = { get: vi.fn().mockResolvedValue({ data: { sealedProducts: [
{ uuid: 'u1', name: 'Foundations Play Booster Box', setCode: 'FDN', setName: 'Foundations', category: 'booster_box', suggestedPrice: 100 }
] } }) };
const onSelect = vi.fn();
render(<SealedSearchPicker game="mtg" onSelect={onSelect} apiClient={apiClient} />);
fireEvent.change(screen.getByLabelText(/search sealed/i), { target: { value: 'foundations' } });
await waitFor(() => screen.getByText(/Foundations Play Booster Box/));
fireEvent.click(screen.getByText(/Foundations Play Booster Box/));
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ uuid: 'u1', category: 'booster_box' }));
});- [ ] Step 2: Run to verify it fails
Run: npm run test:client -- SealedSearchPicker Expected: FAIL β component does not exist.
- [ ] Step 3: Implement (mirror
CardSearchPicker.jsx)
jsx
import { useEffect, useRef, useState } from 'react';
import api from '../../utils/api';
import { Input, Text } from '../../components/retroui';
const SEARCH_DEBOUNCE_MS = 300;
export default function SealedSearchPicker({ game, onSelect, apiClient = api }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false);
const debounceRef = useRef(null);
useEffect(() => {
if (!query.trim()) { setResults([]); return undefined; }
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
setSearching(true);
try {
const { data } = await apiClient.get('/catalog/sealed', { params: { search: query.trim(), game, limit: 8 } });
setResults(data.sealedProducts || []);
} catch {
setResults([]);
} finally {
setSearching(false);
}
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(debounceRef.current);
}, [query, game, apiClient]);
const pick = (product) => { onSelect(product); setQuery(''); setResults([]); };
return (
<div className="relative">
<label htmlFor="sealed-search" className="font-semibold text-sm block mb-1">Search sealed products to add</label>
<Input
id="sealed-search"
aria-label="Search sealed products"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Start typing a product nameβ¦"
/>
{searching && <Text className="text-xs text-muted-foreground mt-1">Searchingβ¦</Text>}
{results.length > 0 && (
<ul className="absolute z-10 mt-1 w-full max-w-md bg-card border-2 border-black rounded shadow-md">
{results.map((p) => (
<li key={p.uuid}>
<button
type="button"
className="w-full text-left px-3 py-2 hover:bg-muted text-sm flex justify-between items-baseline gap-2"
onClick={() => pick(p)}
>
<span>
<span className="font-semibold">{p.name}</span>
<span className="text-muted-foreground"> β {p.setName || p.setCode}</span>
</span>
{typeof p.suggestedPrice === 'number' && (
<span className="font-semibold whitespace-nowrap">${p.suggestedPrice.toFixed(2)}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
);
}- [ ] Step 4: Run to verify it passes
Run: npm run test:client -- SealedSearchPicker Expected: PASS.
- [ ] Step 5: Commit
bash
git add client/src/pages/buylist/SealedSearchPicker.jsx client/src/pages/buylist/SealedSearchPicker.test.jsx
git commit -m "Add sealed product search picker for buy orders"Task 12: NewBuyInTab β Singles/Sealed toggle + sealed rows β
Files:
- Modify:
client/src/pages/buylist/NewBuyInTab.jsx - Test:
client/src/pages/buylist/NewBuyInTab.test.jsx
Interfaces:
Consumes:
SealedSearchPicker(Task 11);gamesentries carrysupportsSealed(Task 9).Produces: sealed cart rows submit
{ lineType: 'sealed', sealedUuid, game, quantity }.[ ] Step 1: Write the failing test
Add to NewBuyInTab.test.jsx (mock api so /games returns [{ id:'mtg', name:'MTG', supportsSealed:true }] and /catalog/sealed returns one product):
jsx
it('adds a sealed product and submits it as a sealed line', async () => {
// ... render, wait for games, click the "Sealed" picker toggle
// type in the sealed search, pick the product
// assert a cart row shows a "Sealed" chip and NO finish/condition <select>
// click Generate Offer
// assert api.post was called with lines containing
// { lineType: 'sealed', sealedUuid: 'u1', game: 'mtg', quantity: 1 }
});(Flesh out using the file's existing render/mock helpers β assert on api.post.mock.calls for the sealed line shape and that the sealed row has no Finish for / Condition for labeled control.)
- [ ] Step 2: Run to verify it fails
Run: npm run test:client -- NewBuyInTab Expected: FAIL β no sealed picker/rows.
- [ ] Step 3: Implement
Add a picker-type toggle and sealed handling:
jsx
// new state near the existing useState declarations:
const [pickerType, setPickerType] = useState('single'); // 'single' | 'sealed'
const activeGameSupportsSealed = games.find((g) => g.id === game)?.supportsSealed;
// reset to singles when switching to a game without sealed support:
useEffect(() => {
if (!activeGameSupportsSealed && pickerType === 'sealed') setPickerType('single');
}, [activeGameSupportsSealed, pickerType]);
const addSealed = (product) => {
setSelectedCards((prev) => {
if (prev.some((c) => c.lineType === 'sealed' && c.sealedUuid === product.uuid)) return prev;
return [...prev, {
key: `sealed:${product.uuid}`,
lineType: 'sealed',
sealedUuid: product.uuid,
game,
name: product.name,
setCode: product.setCode,
category: product.category,
quantity: 1
}];
});
};Give single rows a stable key too (they currently key on c.uuid): when adding a single, set key: \single:${card.uuid}`, and change the table key={c.uuid}tokey={c.key}and dedup/remove logic to usec.key`. (This namespaces single vs sealed ids so a single and a sealed product with colliding ids can't clash β spec Β§Client.)
In mode === 'select', render the picker toggle + conditional picker:
jsx
{activeGameSupportsSealed && (
<div className="flex gap-2">
<Button variant={pickerType === 'single' ? 'default' : 'outline'} size="sm" onClick={() => setPickerType('single')}>Singles</Button>
<Button variant={pickerType === 'sealed' ? 'default' : 'outline'} size="sm" onClick={() => setPickerType('sealed')}>Sealed</Button>
</div>
)}
{pickerType === 'sealed'
? <SealedSearchPicker game={game} onSelect={addSealed} />
: <CardSearchPicker game={game} onSelect={addCard} />}In the cart row, branch sealed vs single. For a sealed row show a chip and dashes for finish/condition:
jsx
<td className="px-4 py-2">
{c.name}
{c.lineType === 'sealed' && (
<span className="ml-2 inline-block px-1.5 py-0.5 border-2 border-black bg-primary text-xs font-medium rounded">Sealed</span>
)}
</td>
{/* ... Game, Set columns unchanged; # column: c.number for singles, 'β' for sealed ... */}
{/* Finish + Condition cells: render the existing <select>s only for singles, 'β' for sealed */}Update submit to send the right line shape per type:
jsx
lines: selectedCards.map((c) => c.lineType === 'sealed'
? { lineType: 'sealed', sealedUuid: c.sealedUuid, game: c.game, quantity: Number(c.quantity) }
: { cardUuid: c.uuid, game: c.game, finish: c.finish, condition: c.condition, quantity: Number(c.quantity) })Import the new picker at the top:
jsx
import SealedSearchPicker from './SealedSearchPicker';- [ ] Step 4: Run to verify it passes
Run: npm run test:client -- NewBuyInTab Expected: PASS.
- [ ] Step 5: Commit
bash
git add client/src/pages/buylist/NewBuyInTab.jsx client/src/pages/buylist/NewBuyInTab.test.jsx
git commit -m "Let merchants add sealed products to a buy order"Task 13: BuylistPortalPage β sealed in the customer portal β
Files:
- Modify:
client/src/pages/portal/BuylistPortalPage.jsx - Test:
client/src/pages/portal/BuylistPortalPage.test.jsx
Interfaces:
Same picker/toggle/row/submit changes as Task 12, applied to the portal's own cart state. Portal posts to
/public/buylist/:shop/orders; the sealed line shape is identical.[ ] Step 1: Write the failing test
Mirror Task 12's test against the portal component: adding a sealed product produces a { lineType:'sealed', sealedUuid, game, quantity } line in the submitted payload, and the sealed row hides finish/condition.
- [ ] Step 2: Run to verify it fails
Run: npm run test:client -- BuylistPortalPage Expected: FAIL.
- [ ] Step 3: Implement
Apply the same changes as Task 12 (picker-type toggle gated on the active game's supportsSealed, addSealed, namespaced row keys, sealed chip, finish/condition hidden for sealed, per-type submit mapping). Reuse SealedSearchPicker. The portal fetches games from the same /games (public) endpoint, which now carries supportsSealed (Task 9). If the portal uses a different price/label rendering, keep it β only the line-type plumbing is shared.
- [ ] Step 4: Run to verify it passes
Run: npm run test:client -- BuylistPortalPage Expected: PASS.
- [ ] Step 5: Commit
bash
git add client/src/pages/portal/BuylistPortalPage.jsx client/src/pages/portal/BuylistPortalPage.test.jsx
git commit -m "Let customers add sealed products in the buylist portal"Task 14: Sealed chip in review + orders list β
Files:
- Modify:
client/src/pages/buylist/BuylistReviewPage.jsx,client/src/pages/buylist/BuylistOrdersTab.jsx - Test: their co-located test files.
Interfaces:
Consumes: order lines carry
lineType; the list/review render a "Sealed" chip whenline.lineType === 'sealed'.[ ] Step 1: Write the failing tests
For each component, render an order containing one single and one sealed line; assert a "Sealed" chip appears for the sealed line only (and, in the review page, that finish/condition are not shown for the sealed line).
- [ ] Step 2: Run to verify it fails
Run: npm run test:client -- BuylistReviewPage BuylistOrdersTab Expected: FAIL.
- [ ] Step 3: Implement
In each, where a line's card name is rendered, append the same chip used in Task 12 when line.lineType === 'sealed':
jsx
{line.lineType === 'sealed' && (
<span className="ml-2 inline-block px-1.5 py-0.5 border-2 border-black bg-primary text-xs font-medium rounded">Sealed</span>
)}In the review page, guard any finish/condition display with line.lineType !== 'sealed'.
- [ ] Step 4: Run to verify it passes
Run: npm run test:client -- BuylistReviewPage BuylistOrdersTab Expected: PASS.
- [ ] Step 5: Commit
bash
git add client/src/pages/buylist/BuylistReviewPage.jsx client/src/pages/buylist/BuylistOrdersTab.jsx client/src/pages/buylist/BuylistReviewPage.test.jsx client/src/pages/buylist/BuylistOrdersTab.test.jsx
git commit -m "Show a sealed chip on buy order review and list"Task 15: BuylistSettingsTab β sealed rate section β
Files:
- Modify:
client/src/pages/buylist/BuylistSettingsTab.jsx - Test:
client/src/pages/buylist/BuylistSettingsTab.test.jsx
Interfaces:
Consumes:
GET /api/buylist/config?game=now returnssealedCategories: [{id,name}]andconfig.sealed(Task 3). Saves viaPUT /api/buylist/config?game=with asealedpartial (Task 4).Produces: a sealed section (only when
sealedCategories.length > 0) with an enable toggle, a default-rate input, and one rate input per category.[ ] Step 1: Write the failing test
jsx
it('renders sealed rate rows and saves a sealed config update', async () => {
// mock GET /buylist/config -> { rarities:[...], sealedCategories:[{id:'booster_box',name:'Booster Box'}], config:{ ..., sealed:{ enabled:false, defaultRate:0.6, categoryRates:{} } } }
// render, toggle "Enable sealed buylist", set default rate to 0.65, set booster_box to 0.7, click Save
// assert api.put called with a body whose sealed = { enabled:true, defaultRate:0.65, categoryRates:{ booster_box:0.7 } }
});- [ ] Step 2: Run to verify it fails
Run: npm run test:client -- BuylistSettingsTab Expected: FAIL β no sealed section.
- [ ] Step 3: Implement
Mirror the existing rarity-rate section in BuylistSettingsTab.jsx (which already renders one input per rarities[] entry bound to config.rarityRates). Add a parallel sealed block bound to config.sealed:
- A
Switch(retroui) forsealed.enabled. - A number input for
sealed.defaultRate. - One number input per
sealedCategoriesentry, bound tosealed.categoryRates[id](empty input β omit / sendnullto clear, matching the rarity rows' clear behavior). - Render the whole block only when
sealedCategories.length > 0. - On save, include
sealedin thePUTbody (rates as fractions 0β1, same convention as rarity rates).
Follow the file's existing state-management and save-handler patterns exactly β do not introduce a new pattern.
- [ ] Step 4: Run to verify it passes
Run: npm run test:client -- BuylistSettingsTab Expected: PASS.
- [ ] Step 5: Commit
bash
git add client/src/pages/buylist/BuylistSettingsTab.jsx client/src/pages/buylist/BuylistSettingsTab.test.jsx
git commit -m "Let merchants configure sealed buylist rates"Task 16: Full verification pass β
Files: none (verification only).
- [ ] Step 1: Server tests + coverage
Run: npm test Expected: exit 0.
Run: npm run test:coverage Expected: every modified server file β₯70% on all four metrics. Add targeted tests if any modified file dips below.
- [ ] Step 2: Client tests + build
Run: npm run test:client Expected: exit 0.
Run: npm run build Expected: exit 0.
- [ ] Step 3: Lint
Run: npm run lint Expected: exit 0, no new warnings.
[ ] Step 4: Parity + literal audit
Run the
game-parityskill; in the summary name mtg (mirrored), pokemon (exempt from per-category rates β nohasSealedCategoryTaxonomy), riftbound (same exemption as pokemon; sealed buylist itself is supported for all three games).Confirm
categoryRateskeys trace toSEALED_CATEGORIES/CATEGORY_LABELS_SERVER(Β§5.4).grep -n "|| 'mtg'\|?? 'mtg'\|= 'mtg'"across the diff β zero identity defaults (Β§5.5).Confirm the MTG
/catalog/sealedsearch$matchon name is placed after$unwindand before$sort(Β§5.6).[ ] Step 5: Sync-affecting check
Not applicable β this feature performs no Shopify sync. No end-to-end sync bar required.
Self-Review Notes (author) β
- Spec coverage: data model β T1; config defaults/response β T3; config validation β T4; quote engine β T5; line schema union β T6; sealed gate β T7; route + public β T8; supportsSealed exposure β T9; search endpoint β T10; picker β T11; merchant UI β T12; portal UI β T13; review/list chips β T14; settings UI β T15; DoD β T16. Every spec section maps to a task.
- MSRP-fallback knob:
useMsrpFallback:falseis validated and stored but not yet threaded into the MTG plugin'sgetSealedProductPricecall (T5 Step 5 documents this as a known, non-silent limitation; fallback stays on). Flagged rather than hidden. - Category on non-MTG: intentionally
nullfor Pokemon/Riftbound (no taxonomy) βdefaultRate. Asserted in T5.
