Phase 2: Medusa Fit Validation — Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development(recommended) orsuperpowers:executing-plansto implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Ship the src/modules/tcg/ domain module on ExzenTCG/tcg-platform, populate staging with realistic TCG seed data, and produce a gap-analysis findings doc — fulfilling Phase 2's exit criterion from the Phase 2 Kickoff Plan.
Architecture: Custom Medusa 2.x module in src/modules/tcg/ with two DML data models (TcgVariantMetadata, TcgSerializedItem), service-factory CRUD plus a custom reservation method, two defineLink() Module Links to productVariant (1:1 and N:1), and an idempotent seed script driven by yarn medusa exec. Same Postgres DB; all relationships queried via Medusa's standard Query API.
Tech Stack: Medusa 2.13.6, TypeScript, Mikro-ORM (via Medusa DML), yarn 4.12.0, Jest + @medusajs/test-utils, Docker (staging CT 105).
Target repo¶
All code changes in this plan are against ExzenTCG/tcg-platform. This plan doc lives in ExzenTCG-Homelab per the project convention (docs vault separate from code repo). Checkpoints reference each repo explicitly.
Scope references¶
- Data model details:
tcg-platform/phase-2-plan.md§Data model - Product type strategy:
tcg-platform/phase-2-plan.md§Product type mapping details - Seed coverage:
tcg-platform/phase-2-plan.md§Seed plan - Exit criteria:
tcg-platform/phase-2-plan.md§Exit criteria
File structure¶
ExzenTCG/tcg-platform
├── src/
│ ├── modules/
│ │ └── tcg/
│ │ ├── index.ts Module() registration
│ │ ├── service.ts TcgModuleService (MedusaService + reservation)
│ │ ├── types/
│ │ │ └── enums.ts ProductType, Game, Language, Condition, SealedFormat, Grader, SerializedStatus
│ │ ├── models/
│ │ │ ├── variant-metadata.ts TcgVariantMetadata (model.define)
│ │ │ └── serialized-item.ts TcgSerializedItem (model.define)
│ │ ├── migrations/
│ │ │ ├── Migration<ts>_<name>.ts auto-generated by `medusa db:generate tcg`
│ │ │ └── Migration<ts+1>_add_check.ts hand-written CHECK constraint
│ │ ├── __tests__/
│ │ │ └── service.spec.ts moduleIntegrationTestRunner tests
│ │ └── README.md overwrite Phase 1 placeholder
│ ├── links/
│ │ ├── product-variant-tcg-metadata.ts defineLink 1:1
│ │ └── product-variant-serialized-item.ts defineLink N:1
│ └── scripts/
│ └── seed-phase-2.ts seed script (idempotent)
├── integration-tests/
│ └── http/
│ └── seed-phase-2.spec.ts medusaIntegrationTestRunner — seed idempotency + query
└── medusa-config.ts register module in modules array
ExzenTCG/ExzenTCG-Homelab
└── tcg-platform/
└── phase-2-findings.md gap analysis output doc
└── homelab/
└── 05_service_deployments/
└── tcg-staging.md append "Phase 2 applied" entry
Each module file has one clear responsibility: models declare schema, service houses business logic, links define cross-module joins, seed script populates fixtures.
Working style¶
- Work on
draft/claude-phase-2-<scope>branches againsttcg-platform/main. - One PR per logical scope (not per task). Rough grouping: PR A (module + links + migration), PR B (reservation service + tests), PR C (seed script), PR D (findings doc + runbook update in homelab).
- Tests land in the same PR as the code they exercise.
- CI (
.github/workflows/ci.yml) must pass before merge.
Task 1: Enum types¶
Files:
- Create: src/modules/tcg/types/enums.ts
Defines shared TypeScript enums used by both models and the service. Kept in one file so both models can import without circular refs.
- [ ] Step 1: Create the enums file
// src/modules/tcg/types/enums.ts
export enum ProductType {
SINGLE = "single",
SEALED = "sealed",
GRADED = "graded",
}
export enum Game {
POKEMON = "pokemon",
YUGIOH = "yugioh",
ONE_PIECE = "one_piece",
}
export enum Language {
EN = "en",
JA = "ja",
KO = "ko",
ZH_S = "zh-s",
ZH_T = "zh-t",
DE = "de",
FR = "fr",
ES = "es",
IT = "it",
PT = "pt",
}
export enum Condition {
NM = "NM",
LP = "LP",
MP = "MP",
HP = "HP",
DMG = "DMG",
SEALED = "SEALED",
}
export enum SealedFormat {
BOOSTER_PACK = "booster_pack",
BOOSTER_BOX = "booster_box",
ETB = "etb",
BUNDLE = "bundle",
TIN = "tin",
PROMO_BOX = "promo_box",
}
export enum Grader {
PSA = "PSA",
BGS = "BGS",
CGC = "CGC",
ACE = "ACE",
}
export enum SerializedStatus {
AVAILABLE = "available",
RESERVED = "reserved",
SOLD = "sold",
ON_HOLD = "on_hold",
}
- [ ] Step 2: Commit
git add src/modules/tcg/types/enums.ts
git commit -m "feat(tcg): add shared enum types for TCG domain
ProductType, Game, Language, Condition, SealedFormat, Grader,
SerializedStatus. Used by both models and service."
Task 2: TcgVariantMetadata data model¶
Files:
- Create: src/modules/tcg/models/variant-metadata.ts
Per-variant metadata for fungible TCG stock (singles, sealed, graded placeholder variants). One row per Medusa productVariant that represents a TCG product.
- [ ] Step 1: Create the model file
// src/modules/tcg/models/variant-metadata.ts
import { model } from "@medusajs/framework/utils"
import {
ProductType,
Game,
Language,
Condition,
SealedFormat,
} from "../types/enums"
const TcgVariantMetadata = model.define("tcg_variant_metadata", {
id: model.id().primaryKey(),
product_type: model.enum(ProductType),
game: model.enum(Game),
// Card-like (singles + graded)
set_code: model.text().nullable(),
set_name: model.text().nullable(),
card_number: model.text().nullable(),
rarity: model.text().nullable(),
language: model.enum(Language).nullable(),
variation: model.text().nullable(),
foil: model.boolean().nullable(),
edition: model.text().nullable(),
// Sealed-specific
sealed_format: model.enum(SealedFormat).nullable(),
pack_count: model.number().nullable(),
// Condition (fungible only; graded per-slab condition on TcgSerializedItem)
condition: model.enum(Condition).nullable(),
// Housekeeping
notes: model.text().nullable(),
})
export default TcgVariantMetadata
- [ ] Step 2: Commit
git add src/modules/tcg/models/variant-metadata.ts
git commit -m "feat(tcg): add TcgVariantMetadata data model
Per-variant metadata for fungible TCG stock. Single table covering
singles, sealed, and graded placeholder variants; nullable fields
make the shape polymorphic by product_type + game.
One row per Medusa productVariant (enforced via 1:1 Module Link
added later)."
Task 3: TcgSerializedItem data model¶
Files:
- Create: src/modules/tcg/models/serialized-item.ts
One row per physical serialized item (graded slabs, premium raws). Carries its own photos, grade, cert, reservation state.
- [ ] Step 1: Create the model file
// src/modules/tcg/models/serialized-item.ts
import { model } from "@medusajs/framework/utils"
import { Grader, SerializedStatus } from "../types/enums"
const TcgSerializedItem = model.define("tcg_serialized_item", {
id: model.id().primaryKey(),
// Grading
is_graded: model.boolean().default(false),
grader: model.enum(Grader).nullable(),
grade: model.number().nullable(), // numeric precision handled at migration layer if needed
cert_number: model.text().nullable(),
// Ungraded premium raws
condition_notes: model.text().nullable(),
// Fulfillment state
status: model.enum(SerializedStatus).default(SerializedStatus.AVAILABLE).index(),
reserved_by_order_id: model.text().nullable().index(),
// Media
photo_urls: model.json().nullable(),
// Sourcing (Phase 4 wires DCA; leave columns as loose types for now)
acquired_at: model.dateTime().nullable(),
acquired_batch_id: model.text().nullable(),
acquisition_cost: model.number().nullable(),
// Housekeeping
sku: model.text().unique().nullable(),
notes: model.text().nullable(),
})
export default TcgSerializedItem
- [ ] Step 2: Commit
git add src/modules/tcg/models/serialized-item.ts
git commit -m "feat(tcg): add TcgSerializedItem data model
Per-physical-item record for graded slabs and premium raws. Carries
grader/grade/cert, condition notes, reservation status + reserving
order id, photos, sourcing fields.
status + reserved_by_order_id both indexed for reservation lookups.
sku is unique (internal shop SKU for physical labeling).
Module Link to productVariant (N:1) added in a later task."
Task 4: Module service with reservation logic — TDD¶
Files:
- Create: src/modules/tcg/service.ts
- Create: src/modules/tcg/__tests__/service.spec.ts
Extends MedusaService({ TcgVariantMetadata, TcgSerializedItem }) for generated CRUD (createTcgVariantMetadatas, listTcgSerializedItems, etc.), plus a custom reserveSerializedItem method that transitions available → reserved atomically.
- [ ] Step 1: Write the failing test (TDD)
// src/modules/tcg/__tests__/service.spec.ts
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { TCG_MODULE } from ".."
import TcgModuleService from "../service"
import TcgVariantMetadata from "../models/variant-metadata"
import TcgSerializedItem from "../models/serialized-item"
import { SerializedStatus } from "../types/enums"
moduleIntegrationTestRunner<TcgModuleService>({
moduleName: TCG_MODULE,
moduleModels: [TcgVariantMetadata, TcgSerializedItem],
resolve: "./src/modules/tcg",
testSuite: ({ service }) => {
describe("TcgModuleService.reserveSerializedItem", () => {
it("transitions available → reserved and records order id", async () => {
const [item] = await service.createTcgSerializedItems([
{ sku: "TEST-SLAB-001", is_graded: false },
])
const result = await service.reserveSerializedItem(item.id, "order_abc")
expect(result.status).toBe(SerializedStatus.RESERVED)
expect(result.reserved_by_order_id).toBe("order_abc")
})
it("rejects reservation if item is already reserved", async () => {
const [item] = await service.createTcgSerializedItems([
{ sku: "TEST-SLAB-002", is_graded: false },
])
await service.reserveSerializedItem(item.id, "order_1")
await expect(
service.reserveSerializedItem(item.id, "order_2")
).rejects.toThrow(/not available/i)
})
it("rejects reservation if item is sold", async () => {
const [item] = await service.createTcgSerializedItems([
{ sku: "TEST-SLAB-003", status: SerializedStatus.SOLD, is_graded: false },
])
await expect(
service.reserveSerializedItem(item.id, "order_1")
).rejects.toThrow(/not available/i)
})
})
},
})
jest.setTimeout(60 * 1000)
- [ ] Step 2: Create the service with minimal stub so test can load, then run to see failure
// src/modules/tcg/service.ts
import { MedusaService } from "@medusajs/framework/utils"
import TcgVariantMetadata from "./models/variant-metadata"
import TcgSerializedItem from "./models/serialized-item"
class TcgModuleService extends MedusaService({
TcgVariantMetadata,
TcgSerializedItem,
}) {
async reserveSerializedItem(
_itemId: string,
_orderId: string
): Promise<any> {
throw new Error("not implemented")
}
}
export default TcgModuleService
Run: yarn test:integration:modules --testPathPattern=tcg
Expected: FAIL — three failures, all throwing "not implemented" or the first assertion mismatching status.
- [ ] Step 3: Implement
reserveSerializedItemproperly
Replace the stub body with:
import { MedusaError } from "@medusajs/framework/utils"
import { SerializedStatus } from "./types/enums"
// inside the class:
async reserveSerializedItem(itemId: string, orderId: string) {
// Re-read with a lock — Mikro-ORM exposes `transactional` via the inherited
// service. We do the check-and-update inside a single transaction so two
// concurrent callers can't both see AVAILABLE.
return this.withTransaction(async (tx) => {
const [item] = await this.listTcgSerializedItems(
{ id: itemId },
{ take: 1 }
)
if (!item) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`TcgSerializedItem ${itemId} not found`
)
}
if (item.status !== SerializedStatus.AVAILABLE) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`TcgSerializedItem ${itemId} is not available (current status: ${item.status})`
)
}
const [updated] = await this.updateTcgSerializedItems([
{
id: itemId,
status: SerializedStatus.RESERVED,
reserved_by_order_id: orderId,
},
])
return updated
})
}
Note on withTransaction: MedusaService subclasses inherit transactional helpers. If the exact method name differs in 2.13.6, substitute whatever the base exposes — check @medusajs/framework/utils types. The CHECK constraint added in Task 6 catches violations of the invariant even if the transaction is bypassed.
Full file after this step:
// src/modules/tcg/service.ts
import { MedusaError, MedusaService } from "@medusajs/framework/utils"
import TcgVariantMetadata from "./models/variant-metadata"
import TcgSerializedItem from "./models/serialized-item"
import { SerializedStatus } from "./types/enums"
class TcgModuleService extends MedusaService({
TcgVariantMetadata,
TcgSerializedItem,
}) {
async reserveSerializedItem(itemId: string, orderId: string) {
return this.withTransaction(async () => {
const [item] = await this.listTcgSerializedItems(
{ id: itemId },
{ take: 1 }
)
if (!item) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`TcgSerializedItem ${itemId} not found`
)
}
if (item.status !== SerializedStatus.AVAILABLE) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`TcgSerializedItem ${itemId} is not available (current status: ${item.status})`
)
}
const [updated] = await this.updateTcgSerializedItems([
{
id: itemId,
status: SerializedStatus.RESERVED,
reserved_by_order_id: orderId,
},
])
return updated
})
}
}
export default TcgModuleService
- [ ] Step 4: Run tests to verify pass
Run: yarn test:integration:modules --testPathPattern=tcg
Expected: PASS — 3/3 tests pass.
- [ ] Step 5: Commit
git add src/modules/tcg/service.ts src/modules/tcg/__tests__/service.spec.ts
git commit -m "feat(tcg): TcgModuleService with reserveSerializedItem
MedusaService extension exposes auto-generated CRUD for both models.
Custom reserveSerializedItem method transitions available → reserved
inside a transaction, rejects reservation if the item is not
available (already reserved, sold, or on hold).
Tests cover the happy path, double-reservation rejection, and
reservation-of-sold rejection. Uses moduleIntegrationTestRunner with
the module's real Postgres models."
Task 5: Module registration¶
Files:
- Create: src/modules/tcg/index.ts
- Modify: medusa-config.ts (add to modules array)
Registers the module with Medusa's container.
- [ ] Step 1: Create
index.ts
// src/modules/tcg/index.ts
import { Module } from "@medusajs/framework/utils"
import TcgModuleService from "./service"
export const TCG_MODULE = "tcgModuleService"
export default Module(TCG_MODULE, {
service: TcgModuleService,
})
- [ ] Step 2: Register in
medusa-config.ts
Locate the existing modules: [...] array and append:
// medusa-config.ts — add to the modules array, after the existing Redis modules:
{
resolve: "./src/modules/tcg",
},
Full reduced diff of medusa-config.ts modules section:
modules: [
{ resolve: "@medusajs/medusa/event-bus-redis", options: { redisUrl: process.env.REDIS_URL } },
{ resolve: "@medusajs/medusa/workflow-engine-redis", options: { redis: { redisUrl: process.env.REDIS_URL } } },
{ resolve: "@medusajs/medusa/caching", options: { providers: [{ resolve: "@medusajs/caching-redis", id: "caching-redis", is_default: true, options: { redisUrl: process.env.REDIS_URL } }] } },
{ resolve: "@medusajs/medusa/locking", options: { providers: [{ resolve: "@medusajs/medusa/locking-redis", id: "locking-redis", is_default: true, options: { redisUrl: process.env.REDIS_URL } }] } },
{ resolve: "./src/modules/tcg" },
],
- [ ] Step 3: Verify module loads without migrations yet
Run: yarn medusa db:migrate (will run existing migrations, no new ones yet for tcg)
Expected: Command succeeds, logs Medusa loading the tcg module. Will warn about missing migrations — that's addressed in Task 6.
- [ ] Step 4: Commit
git add src/modules/tcg/index.ts medusa-config.ts
git commit -m "feat(tcg): register TCG module in Medusa config
Module() wraps TcgModuleService with the TCG_MODULE name. Added to
the medusa-config.ts modules array after the Redis infra modules.
Module is now resolvable via container.resolve(TCG_MODULE)."
Task 6: Generate + augment migration¶
Files:
- Create (auto-generated): src/modules/tcg/migrations/Migration<ts>_<hash>.ts
- Create (hand-written): src/modules/tcg/migrations/Migration<ts+1>_add_serialized_item_check.ts
Medusa's DML generates Mikro-ORM migrations. We generate the initial migration, then add a hand-written follow-up that enforces the status='reserved' ⇒ reserved_by_order_id NOT NULL invariant via a CHECK constraint (DML doesn't express CHECK constraints directly).
- [ ] Step 1: Generate the initial migration
Run: yarn medusa db:generate tcg
Expected output: creates a file like src/modules/tcg/migrations/Migration20260423123456_<hash>.ts with up() / down() creating the tcg_variant_metadata and tcg_serialized_item tables and their indexes.
- [ ] Step 2: Inspect the generated migration
ls src/modules/tcg/migrations/
cat src/modules/tcg/migrations/Migration*.ts | head -80
Sanity-check the generated SQL creates the right columns and enums.
- [ ] Step 3: Run the migration locally (or via staging later)
Run: yarn medusa db:migrate
Expected: migration applies, \dt tcg_* shows the two tables.
- [ ] Step 4: Write the hand-written CHECK constraint migration
Create file src/modules/tcg/migrations/Migration20260423130000_add_serialized_item_check.ts (pick a timestamp strictly later than the generated one so ordering is deterministic):
// src/modules/tcg/migrations/Migration20260423130000_add_serialized_item_check.ts
import { Migration } from "@mikro-orm/migrations"
export class Migration20260423130000 extends Migration {
async up(): Promise<void> {
this.addSql(`
ALTER TABLE tcg_serialized_item
ADD CONSTRAINT tcg_serialized_item_reserved_order_check
CHECK (
status != 'reserved' OR reserved_by_order_id IS NOT NULL
);
`)
}
async down(): Promise<void> {
this.addSql(`
ALTER TABLE tcg_serialized_item
DROP CONSTRAINT IF EXISTS tcg_serialized_item_reserved_order_check;
`)
}
}
Why a separate migration rather than editing the generated one: auto-generated files are fragile if the model is regenerated later. Keeping custom SQL in its own migration means we can re-generate the base model migration without losing custom constraints.
- [ ] Step 5: Apply the CHECK constraint migration
Run: yarn medusa db:migrate
Expected: second migration applies. Verify with:
psql -c "SELECT conname FROM pg_constraint WHERE conname = 'tcg_serialized_item_reserved_order_check';"
# Expected: returns one row
(On staging use docker compose exec postgres psql -U medusa -d medusa -c "...".)
- [ ] Step 6: Commit both migrations
git add src/modules/tcg/migrations/
git commit -m "feat(tcg): migrations for TCG tables + reservation CHECK
Generated migration (Migration<ts>_<hash>) creates tcg_variant_metadata
and tcg_serialized_item tables plus their indexes.
Hand-written follow-up (Migration<ts+1>_add_serialized_item_check) adds
a CHECK constraint enforcing the invariant:
status = 'reserved' ⇒ reserved_by_order_id IS NOT NULL
Kept as a separate migration so the generated one stays regenerable."
Task 7: Module Link — TcgVariantMetadata ↔ productVariant (1:1)¶
Files:
- Create: src/links/product-variant-tcg-metadata.ts
Associates each Medusa productVariant with at most one TcgVariantMetadata row.
- [ ] Step 1: Create the link file
// src/links/product-variant-tcg-metadata.ts
import TcgModule from "../modules/tcg"
import ProductModule from "@medusajs/medusa/product"
import { defineLink } from "@medusajs/framework/utils"
export default defineLink(
ProductModule.linkable.productVariant,
{
linkable: TcgModule.linkable.tcgVariantMetadata,
deleteCascade: true,
}
)
Cardinality note: default behavior without isList is 1:1 per side. deleteCascade: true on the TCG side means deleting a productVariant removes its metadata row.
- [ ] Step 2: Sync the link
Run: yarn medusa db:sync-links
Expected: creates join table product_variant_tcg_metadata (or similar Medusa-chosen name).
- [ ] Step 3: Commit
git add src/links/product-variant-tcg-metadata.ts
git commit -m "feat(tcg): link TcgVariantMetadata to productVariant (1:1)
defineLink with deleteCascade on the TCG side so metadata is cleaned
up when its variant is deleted. Queryable via
fields: ['*', 'tcg_variant_metadata.*'] on variant queries."
Task 8: Module Link — TcgSerializedItem ↔ productVariant (N:1)¶
Files:
- Create: src/links/product-variant-serialized-item.ts
Multiple serialized items can link to one base printing's variant (e.g. three PSA 10 Charizards of the same printing).
- [ ] Step 1: Create the link file
// src/links/product-variant-serialized-item.ts
import TcgModule from "../modules/tcg"
import ProductModule from "@medusajs/medusa/product"
import { defineLink } from "@medusajs/framework/utils"
export default defineLink(
ProductModule.linkable.productVariant,
{
linkable: TcgModule.linkable.tcgSerializedItem,
isList: true,
deleteCascade: true,
}
)
isList: true = N serialized items per variant. deleteCascade: true = deleting the base variant removes all linked slabs (dev-time safety; never delete a variant with live inventory in production, but the cascade prevents orphans if it happens).
- [ ] Step 2: Sync the link
Run: yarn medusa db:sync-links
Expected: creates second join table.
- [ ] Step 3: Commit
git add src/links/product-variant-serialized-item.ts
git commit -m "feat(tcg): link TcgSerializedItem to productVariant (N:1)
isList on the TCG side so many serialized items can share a base
variant printing. deleteCascade removes orphan slabs if a variant is
ever deleted. Queryable via
fields: ['*', 'tcg_serialized_items.*'] on variant queries."
Task 9: Overwrite src/modules/tcg/README.md¶
Files:
- Modify: src/modules/tcg/README.md
Phase 1 shipped a placeholder README pointing at Phase 2. Replace with a module-level overview now that the module exists.
- [ ] Step 1: Overwrite the README
# `tcg/` module
TCG-specific product semantics. Shipped in Phase 2.
## Data models
- **`TcgVariantMetadata`** — per-variant metadata for fungible TCG stock. Covers singles, sealed products, and graded placeholder variants via a nullable field set discriminated by `product_type` (`single` / `sealed` / `graded`). 1:1 link to Medusa `productVariant`.
- **`TcgSerializedItem`** — per-physical-item row for graded slabs and premium raws. Carries its own photos, grade, cert, reservation state. N:1 link to `productVariant` (many slabs can share a base printing).
## Links
- `src/links/product-variant-tcg-metadata.ts` — 1:1
- `src/links/product-variant-serialized-item.ts` — N:1
Query with:
const { data } = await query.graph({
entity: "product_variant",
fields: ["*", "tcg_variant_metadata.*", "tcg_serialized_items.*"],
})
## Service
`TcgModuleService` extends `MedusaService` for auto-generated CRUD on both models, plus:
- `reserveSerializedItem(itemId, orderId)` — atomically transitions a serialized item from `available` → `reserved`. Throws `MedusaError` if the item is not available.
## Out of scope (Phase 2)
- REST endpoints (Phase 5)
- Admin UI (Phase 5)
- Channel listings per platform (Phase 3 — `TCGChannelListing`)
- Per-game attribute extensions (HP, mana cost, etc. — evaluate Phase 5)
- Bulk stock adjustment UX (Phase 5)
- Reservation race stress tests (Phase 5)
See [the Phase 2 kickoff plan](https://docs.exzentcg.com/tcg-platform/phase-2-plan/) for full context.
- [ ] Step 2: Commit
git add src/modules/tcg/README.md
git commit -m "docs(tcg): replace placeholder README with module overview"
Task 10: Open PR A (module + links + migrations)¶
At this point tasks 1–9 are committed on a single branch. Open a PR so CI runs before the seed script lands on top.
- [ ] Step 1: Push and open PR
git push -u origin draft/claude-phase-2-module-scaffold
gh pr create --title "feat(tcg): Phase 2 module scaffold + Module Links" --body-file - <<'EOF'
## Summary
Phase 2 data layer: TCG module with two DML models, their migrations (+ a CHECK constraint), two Module Links to `productVariant`, and tests covering the reservation state machine.
See [`tcg-platform/phase-2-plan.md`](https://docs.exzentcg.com/tcg-platform/phase-2-plan/) for scope + design.
## Changes
- `src/modules/tcg/` — module, service, models, enums, migrations, tests.
- `src/links/` — two `defineLink()` definitions.
- `medusa-config.ts` — register module in `modules` array.
- README replaces Phase 1 placeholder.
## Test plan
- [ ] CI green.
- [ ] `yarn test:integration:modules --testPathPattern=tcg` passes locally.
- [ ] `yarn medusa db:migrate` applies cleanly on staging via entrypoint (Task 13).
- [ ] CHECK constraint exists on staging (verify with `pg_constraint`).
## Follow-up
- PR B: seed script (`src/scripts/seed-phase-2.ts`)
- PR C: findings doc + runbook update (homelab repo)
EOF
- [ ] Step 2: Wait for CI and merge after review
Expected: build green. Merge via gh pr merge <N> --merge --delete-branch.
Task 11: Seed script skeleton¶
Files:
- Create: src/scripts/seed-phase-2.ts
Idempotent entry point. Resolves the container services we'll need, ensures a default sales channel + three stock locations exist.
Branch from latest main: git checkout main && git pull && git checkout -b draft/claude-phase-2-seed.
- [ ] Step 1: Create the seed script scaffold
// src/scripts/seed-phase-2.ts
import { ExecArgs } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
Modules,
} from "@medusajs/framework/utils"
import {
createStockLocationsWorkflow,
linkSalesChannelsToStockLocationWorkflow,
} from "@medusajs/medusa/core-flows"
import { TCG_MODULE } from "../modules/tcg"
import TcgModuleService from "../modules/tcg/service"
const STOCK_LOCATIONS = [
{ name: "Warehouse", address_1: "—", country_code: "sg" },
{ name: "Store", address_1: "—", country_code: "sg" },
{ name: "Event-A", address_1: "—", country_code: "sg" },
]
export default async function seedPhase2({ container }: ExecArgs) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const salesChannelService = container.resolve(Modules.SALES_CHANNEL)
const stockLocationService = container.resolve(Modules.STOCK_LOCATION)
const tcgService: TcgModuleService = container.resolve(TCG_MODULE)
logger.info("[phase-2-seed] starting")
// 1. Default sales channel (idempotent — Medusa creates one on first boot)
const [defaultChannel] = await salesChannelService.listSalesChannels({
name: "Default Sales Channel",
})
if (!defaultChannel) {
throw new Error("Default Sales Channel not found — did Phase 1 bootstrap run?")
}
// 2. Stock locations (upsert by name)
const existingLocations = await stockLocationService.listStockLocations({
name: STOCK_LOCATIONS.map((l) => l.name),
})
const existingNames = new Set(existingLocations.map((l) => l.name))
const toCreate = STOCK_LOCATIONS.filter((l) => !existingNames.has(l.name))
if (toCreate.length > 0) {
const { result: created } = await createStockLocationsWorkflow(container).run({
input: { locations: toCreate },
})
logger.info(`[phase-2-seed] created ${created.length} stock locations`)
// Associate each new location with the default sales channel
for (const loc of created) {
await linkSalesChannelsToStockLocationWorkflow(container).run({
input: {
id: loc.id,
add: [defaultChannel.id],
},
})
}
} else {
logger.info("[phase-2-seed] stock locations already exist, skipping")
}
// 3. Products + variants + TCG metadata (next tasks populate this)
logger.info("[phase-2-seed] products: (empty scaffold — populated by later tasks)")
logger.info("[phase-2-seed] done")
}
- [ ] Step 2: Run the scaffold to verify it loads
Run: yarn medusa exec ./src/scripts/seed-phase-2.ts
Expected output (abbreviated):
[phase-2-seed] starting
[phase-2-seed] created 3 stock locations (or "already exist, skipping" on rerun)
[phase-2-seed] products: (empty scaffold — populated by later tasks)
[phase-2-seed] done
Run once more to verify idempotency:
yarn medusa exec ./src/scripts/seed-phase-2.ts
Expected: the "already exist, skipping" branch fires on second run.
- [ ] Step 3: Commit
git add src/scripts/seed-phase-2.ts
git commit -m "feat(seed): Phase 2 seed script scaffold
Idempotent entry point. Verifies default sales channel, upserts three
stock locations (Warehouse / Store / Event-A), associates them with
the default channel. Product fixtures added in later tasks.
Run via: yarn medusa exec ./src/scripts/seed-phase-2.ts"
Task 12: Seed — Pokémon singles¶
Files:
- Modify: src/scripts/seed-phase-2.ts
Exercises condition × foil × language × variation axes with realistic Pokémon cards.
- [ ] Step 1: Add a
seedPokemonSingleshelper + invoke it
Add at the top of the file, after imports:
import { createProductsWorkflow } from "@medusajs/medusa/core-flows"
import { ProductStatus } from "@medusajs/framework/utils"
import {
ProductType,
Game,
Language,
Condition,
} from "../modules/tcg/types/enums"
Add this helper below the main function (it uses shared logger, query, tcgService, defaultChannel passed in):
type SeedDeps = {
container: ExecArgs["container"]
logger: ReturnType<typeof (c: any) => any>
query: any
tcgService: TcgModuleService
defaultChannelId: string
}
async function seedPokemonSingles(deps: SeedDeps) {
const { container, logger, query, tcgService, defaultChannelId } = deps
// Idempotency: look for products by our stable external key (handle).
const fixtures = [
{
handle: "pokemon-charizard-ex-obsidian-flames-223",
title: "Charizard ex · Obsidian Flames · 223/197",
set_code: "OBF",
set_name: "Obsidian Flames",
card_number: "223/197",
rarity: "Special Illustration Rare",
variants: [
{ language: Language.EN, condition: Condition.NM, foil: false, variation: null },
{ language: Language.EN, condition: Condition.LP, foil: false, variation: null },
{ language: Language.EN, condition: Condition.NM, foil: true, variation: "Reverse Holo" },
{ language: Language.JA, condition: Condition.NM, foil: false, variation: null },
],
},
{
handle: "pokemon-pikachu-paldean-fates-rc01",
title: "Pikachu · Paldean Fates · RC01",
set_code: "PAF",
set_name: "Paldean Fates",
card_number: "RC01",
rarity: "Illustration Rare",
variants: [
{ language: Language.EN, condition: Condition.NM, foil: false, variation: null },
{ language: Language.EN, condition: Condition.NM, foil: true, variation: "Full Art" },
],
},
]
const { data: existing } = await query.graph({
entity: "product",
fields: ["id", "handle"],
filters: { handle: fixtures.map((f) => f.handle) },
})
const existingHandles = new Set(existing.map((p: any) => p.handle))
const toCreate = fixtures.filter((f) => !existingHandles.has(f.handle))
if (toCreate.length === 0) {
logger.info("[phase-2-seed] pokemon singles already seeded, skipping")
return
}
const productsInput = toCreate.map((f) => ({
handle: f.handle,
title: f.title,
status: ProductStatus.PUBLISHED,
options: [
{ title: "Language", values: Array.from(new Set(f.variants.map((v) => v.language))) },
{ title: "Condition", values: Array.from(new Set(f.variants.map((v) => v.condition))) },
{ title: "Finish", values: Array.from(new Set(f.variants.map((v) => (v.foil ? "Foil" : "Non-foil")))) },
{ title: "Variation", values: Array.from(new Set(f.variants.map((v) => v.variation ?? "Normal"))) },
],
variants: f.variants.map((v) => ({
title: `${f.title} — ${v.language} ${v.condition} ${v.foil ? "Foil" : "Non-foil"} ${v.variation ?? "Normal"}`,
sku: `${f.handle}-${v.language}-${v.condition}-${v.foil ? "F" : "NF"}-${(v.variation ?? "N").toUpperCase().slice(0, 3)}`,
manage_inventory: true,
prices: [{ currency_code: "sgd", amount: 1000 }],
options: {
Language: v.language,
Condition: v.condition,
Finish: v.foil ? "Foil" : "Non-foil",
Variation: v.variation ?? "Normal",
},
})),
sales_channels: [{ id: defaultChannelId }],
}))
const { result: createdProducts } = await createProductsWorkflow(container).run({
input: { products: productsInput },
})
logger.info(`[phase-2-seed] created ${createdProducts.length} pokemon single products`)
// Create TcgVariantMetadata + link rows for each new variant
for (let pi = 0; pi < createdProducts.length; pi++) {
const createdProduct = createdProducts[pi]
const fixture = toCreate[pi]
for (let vi = 0; vi < createdProduct.variants.length; vi++) {
const createdVariant = createdProduct.variants[vi]
const vFixture = fixture.variants[vi]
const [metadata] = await tcgService.createTcgVariantMetadatas([
{
product_type: ProductType.SINGLE,
game: Game.POKEMON,
set_code: fixture.set_code,
set_name: fixture.set_name,
card_number: fixture.card_number,
rarity: fixture.rarity,
language: vFixture.language,
variation: vFixture.variation,
foil: vFixture.foil,
condition: vFixture.condition,
},
])
// Link via remoteLink service (container key: ContainerRegistrationKeys.LINK) — this is the pattern for managing Module Link
// rows programmatically. Inject the link service at top-level:
const link = container.resolve(ContainerRegistrationKeys.LINK)
await link.create({
[Modules.PRODUCT]: { product_variant_id: createdVariant.id },
[TCG_MODULE]: { tcg_variant_metadata_id: metadata.id },
})
}
}
}
Then in the main seedPhase2 function, below the stock-locations block:
await seedPokemonSingles({
container,
logger,
query,
tcgService,
defaultChannelId: defaultChannel.id,
})
- [ ] Step 2: Run and verify
yarn medusa exec ./src/scripts/seed-phase-2.ts
Expected: "created 2 pokemon single products" on first run. Second run → "already seeded, skipping".
Verify in Postgres:
docker compose exec postgres psql -U medusa -d medusa \
-c "SELECT p.handle, COUNT(v.id) FROM product p JOIN product_variant v ON v.product_id = p.id WHERE p.handle LIKE 'pokemon-%' GROUP BY p.handle;"
# Expected:
# pokemon-charizard-... 4
# pokemon-pikachu-... 2
docker compose exec postgres psql -U medusa -d medusa \
-c "SELECT product_type, game, condition, foil FROM tcg_variant_metadata WHERE game = 'pokemon' ORDER BY card_number;"
# Expected: 6 rows across both products
- [ ] Step 3: Commit
git add src/scripts/seed-phase-2.ts
git commit -m "feat(seed): Pokémon singles — condition × foil × language × variation
Two products (Charizard ex OBF 223, Pikachu PAF RC01) with a total of
6 variants exercising the condition, foil, language, and variation
axes of TcgVariantMetadata. Idempotent by product handle.
Each variant gets a TcgVariantMetadata row and a Module Link via
link."
Task 13: Seed — Pokémon sealed¶
Files:
- Modify: src/scripts/seed-phase-2.ts
Booster pack, booster box, ETB in EN; booster box in JP (language-as-product rule).
- [ ] Step 1: Add
seedPokemonSealedhelper and invoke it
Add after seedPokemonSingles helper:
import { SealedFormat } from "../modules/tcg/types/enums"
async function seedPokemonSealed(deps: SeedDeps) {
const { container, logger, query, tcgService, defaultChannelId } = deps
const fixtures = [
{
handle: "pokemon-surging-sparks-booster-pack-en",
title: "Surging Sparks Booster Pack (EN)",
set_code: "SSP",
set_name: "Surging Sparks",
language: Language.EN,
sealed_format: SealedFormat.BOOSTER_PACK,
pack_count: null,
},
{
handle: "pokemon-surging-sparks-booster-box-en",
title: "Surging Sparks Booster Box (EN)",
set_code: "SSP",
set_name: "Surging Sparks",
language: Language.EN,
sealed_format: SealedFormat.BOOSTER_BOX,
pack_count: 36,
},
{
handle: "pokemon-surging-sparks-etb-en",
title: "Surging Sparks Elite Trainer Box (EN)",
set_code: "SSP",
set_name: "Surging Sparks",
language: Language.EN,
sealed_format: SealedFormat.ETB,
pack_count: 9,
},
{
handle: "pokemon-paldean-fates-booster-box-jp",
title: "Paldean Fates Booster Box (JP)",
set_code: "PAF",
set_name: "Paldean Fates",
language: Language.JA,
sealed_format: SealedFormat.BOOSTER_BOX,
pack_count: 30,
},
]
const { data: existing } = await query.graph({
entity: "product",
fields: ["id", "handle"],
filters: { handle: fixtures.map((f) => f.handle) },
})
const existingHandles = new Set(existing.map((p: any) => p.handle))
const toCreate = fixtures.filter((f) => !existingHandles.has(f.handle))
if (toCreate.length === 0) {
logger.info("[phase-2-seed] pokemon sealed already seeded, skipping")
return
}
const productsInput = toCreate.map((f) => ({
handle: f.handle,
title: f.title,
status: ProductStatus.PUBLISHED,
options: [{ title: "Pack", values: ["Sealed"] }],
variants: [
{
title: f.title,
sku: `${f.handle}-sealed`,
manage_inventory: true,
prices: [{ currency_code: "sgd", amount: 2000 }],
options: { Pack: "Sealed" },
},
],
sales_channels: [{ id: defaultChannelId }],
}))
const { result: createdProducts } = await createProductsWorkflow(container).run({
input: { products: productsInput },
})
logger.info(`[phase-2-seed] created ${createdProducts.length} pokemon sealed products`)
const link = container.resolve(ContainerRegistrationKeys.LINK)
for (let i = 0; i < createdProducts.length; i++) {
const variant = createdProducts[i].variants[0]
const fixture = toCreate[i]
const [metadata] = await tcgService.createTcgVariantMetadatas([
{
product_type: ProductType.SEALED,
game: Game.POKEMON,
set_code: fixture.set_code,
set_name: fixture.set_name,
language: fixture.language,
sealed_format: fixture.sealed_format,
pack_count: fixture.pack_count,
condition: Condition.SEALED,
},
])
await link.create({
[Modules.PRODUCT]: { product_variant_id: variant.id },
[TCG_MODULE]: { tcg_variant_metadata_id: metadata.id },
})
}
}
Invoke it:
await seedPokemonSealed({ container, logger, query, tcgService, defaultChannelId: defaultChannel.id })
- [ ] Step 2: Run and verify
yarn medusa exec ./src/scripts/seed-phase-2.ts
docker compose exec postgres psql -U medusa -d medusa -c "SELECT product_type, sealed_format, language, pack_count FROM tcg_variant_metadata WHERE product_type = 'sealed';"
# Expected: 4 rows
- [ ] Step 3: Commit
git add src/scripts/seed-phase-2.ts
git commit -m "feat(seed): Pokémon sealed — pack / box / ETB + JP box (language-as-product)"
Task 14: Seed — Pokémon graded slab¶
Files:
- Modify: src/scripts/seed-phase-2.ts
Adds a graded-type Medusa product (base card printing) with a placeholder variant (manage_inventory: false), plus one TcgSerializedItem row representing the actual slab.
- [ ] Step 1: Add
seedPokemonGradedhelper and invoke it
import { Grader, SerializedStatus } from "../modules/tcg/types/enums"
async function seedPokemonGraded(deps: SeedDeps) {
const { container, logger, query, tcgService, defaultChannelId } = deps
const productHandle = "pokemon-charizard-base-set-unlimited-graded"
const { data: existing } = await query.graph({
entity: "product",
fields: ["id", "handle", "variants.id"],
filters: { handle: [productHandle] },
})
let baseVariantId: string
if (existing.length > 0) {
logger.info("[phase-2-seed] pokemon graded base product exists, reusing")
baseVariantId = existing[0].variants[0].id
} else {
const { result: createdProducts } = await createProductsWorkflow(container).run({
input: {
products: [{
handle: productHandle,
title: "Charizard · Base Set · Unlimited (Graded)",
status: ProductStatus.PUBLISHED,
options: [{ title: "Grade", values: ["Per slab"] }],
variants: [{
title: "Charizard Base Unlimited — graded placeholder",
sku: `${productHandle}-placeholder`,
manage_inventory: false, // real inventory lives in TcgSerializedItem
prices: [{ currency_code: "sgd", amount: 0 }], // price per-slab, set on serialized items later
options: { Grade: "Per slab" },
}],
sales_channels: [{ id: defaultChannelId }],
}],
},
})
baseVariantId = createdProducts[0].variants[0].id
// Metadata on the placeholder variant
const link = container.resolve(ContainerRegistrationKeys.LINK)
const [metadata] = await tcgService.createTcgVariantMetadatas([{
product_type: ProductType.GRADED,
game: Game.POKEMON,
set_code: "BS",
set_name: "Base Set",
card_number: "4/102",
rarity: "Holo Rare",
language: Language.EN,
edition: "Unlimited",
}])
await link.create({
[Modules.PRODUCT]: { product_variant_id: baseVariantId },
[TCG_MODULE]: { tcg_variant_metadata_id: metadata.id },
})
}
// Serialized slab instance — idempotent by SKU
const slabSku = "SLAB-PSA-10-CHARIZ-BASE-001"
const existingSlab = await tcgService.listTcgSerializedItems({ sku: slabSku })
if (existingSlab.length > 0) {
logger.info("[phase-2-seed] graded slab fixture already exists, skipping")
return
}
const [slab] = await tcgService.createTcgSerializedItems([{
sku: slabSku,
is_graded: true,
grader: Grader.PSA,
grade: 10,
cert_number: "TEST-12345678",
status: SerializedStatus.AVAILABLE,
photo_urls: [],
notes: "Seed fixture — Phase 2 validation",
}])
const link = container.resolve(ContainerRegistrationKeys.LINK)
await link.create({
[Modules.PRODUCT]: { product_variant_id: baseVariantId },
[TCG_MODULE]: { tcg_serialized_item_id: slab.id },
})
// Ungraded premium raw — same base variant, second slab row
const rawSku = "RAW-PREMIUM-CHARIZ-BASE-001"
const existingRaw = await tcgService.listTcgSerializedItems({ sku: rawSku })
if (existingRaw.length === 0) {
const [raw] = await tcgService.createTcgSerializedItems([{
sku: rawSku,
is_graded: false,
condition_notes: "Pristine corners, centering 55/45, candidate for grading",
status: SerializedStatus.AVAILABLE,
photo_urls: [],
}])
await link.create({
[Modules.PRODUCT]: { product_variant_id: baseVariantId },
[TCG_MODULE]: { tcg_serialized_item_id: raw.id },
})
}
logger.info("[phase-2-seed] graded slab + ungraded premium raw created")
}
Invoke:
await seedPokemonGraded({ container, logger, query, tcgService, defaultChannelId: defaultChannel.id })
- [ ] Step 2: Run and verify
yarn medusa exec ./src/scripts/seed-phase-2.ts
docker compose exec postgres psql -U medusa -d medusa -c "SELECT sku, is_graded, grader, grade, status FROM tcg_serialized_item ORDER BY sku;"
# Expected: 2 rows (SLAB-PSA-10-..., RAW-PREMIUM-...)
- [ ] Step 3: Commit
git add src/scripts/seed-phase-2.ts
git commit -m "feat(seed): Pokémon graded slab + ungraded premium raw
Base card printing (Charizard Base Unlimited) modeled as a Medusa
product with one placeholder variant (manage_inventory=false). Two
TcgSerializedItem rows link to that variant:
- PSA 10 slab with grader/grade/cert
- Ungraded premium raw with condition_notes"
Task 15: Seed — Yu-Gi-Oh! + One Piece¶
Files:
- Modify: src/scripts/seed-phase-2.ts
Shorter — we've exercised most patterns already; these add coverage for edition (YGO) and variation (One Piece).
- [ ] Step 1: Add helpers + invoke
Pattern-match on seedPokemonSingles for structure. Add:
async function seedYugioh(deps: SeedDeps) {
// Blue-Eyes White Dragon LOB-001 in 1st Edition + Unlimited (edition axis)
// Dark Magician LOB-005 in NM + MP (condition axis)
// LOB Booster Pack (sealed)
// (Structured exactly like seedPokemonSingles + seedPokemonSealed.)
// ...
}
async function seedOnePiece(deps: SeedDeps) {
// Luffy OP01-001 Normal + Alt Art (variation axis)
// Luffy OP01-001 JP (language-as-product)
// Zoro OP01-025 Super Rare
// OP07 Booster Box (sealed)
// ...
}
Both helpers follow the same shape as the Pokémon ones:
1. Define fixtures.
2. Idempotency check via query.graph on product handles.
3. createProductsWorkflow for new ones.
4. createTcgVariantMetadatas + link.create per variant.
Full code for seedYugioh:
async function seedYugioh(deps: SeedDeps) {
const { container, logger, query, tcgService, defaultChannelId } = deps
const fixtures = [
{
handle: "yugioh-blue-eyes-lob-001",
title: "Blue-Eyes White Dragon · LOB-001",
set_code: "LOB",
set_name: "Legend of Blue Eyes",
card_number: "LOB-001",
rarity: "Ultra Rare",
product_type: ProductType.SINGLE,
variants: [
{ language: Language.EN, condition: Condition.NM, edition: "1st Edition" },
{ language: Language.EN, condition: Condition.NM, edition: "Unlimited" },
],
},
{
handle: "yugioh-dark-magician-lob-005",
title: "Dark Magician · LOB-005",
set_code: "LOB",
set_name: "Legend of Blue Eyes",
card_number: "LOB-005",
rarity: "Ultra Rare",
product_type: ProductType.SINGLE,
variants: [
{ language: Language.EN, condition: Condition.NM, edition: "Unlimited" },
{ language: Language.EN, condition: Condition.MP, edition: "Unlimited" },
],
},
]
const { data: existing } = await query.graph({
entity: "product",
fields: ["id", "handle"],
filters: { handle: fixtures.map((f) => f.handle) },
})
const existingHandles = new Set(existing.map((p: any) => p.handle))
const toCreate = fixtures.filter((f) => !existingHandles.has(f.handle))
if (toCreate.length > 0) {
const productsInput = toCreate.map((f) => ({
handle: f.handle,
title: f.title,
status: ProductStatus.PUBLISHED,
options: [
{ title: "Condition", values: Array.from(new Set(f.variants.map((v) => v.condition))) },
{ title: "Edition", values: Array.from(new Set(f.variants.map((v) => v.edition))) },
],
variants: f.variants.map((v) => ({
title: `${f.title} — ${v.condition} ${v.edition}`,
sku: `${f.handle}-${v.condition}-${v.edition.replace(/\s+/g, "-").toUpperCase()}`,
manage_inventory: true,
prices: [{ currency_code: "sgd", amount: 500 }],
options: { Condition: v.condition, Edition: v.edition },
})),
sales_channels: [{ id: defaultChannelId }],
}))
const { result: createdProducts } = await createProductsWorkflow(container).run({
input: { products: productsInput },
})
const link = container.resolve(ContainerRegistrationKeys.LINK)
for (let pi = 0; pi < createdProducts.length; pi++) {
const cp = createdProducts[pi]
const f = toCreate[pi]
for (let vi = 0; vi < cp.variants.length; vi++) {
const variant = cp.variants[vi]
const v = f.variants[vi]
const [metadata] = await tcgService.createTcgVariantMetadatas([{
product_type: ProductType.SINGLE,
game: Game.YUGIOH,
set_code: f.set_code,
set_name: f.set_name,
card_number: f.card_number,
rarity: f.rarity,
language: v.language,
condition: v.condition,
edition: v.edition,
}])
await link.create({
[Modules.PRODUCT]: { product_variant_id: variant.id },
[TCG_MODULE]: { tcg_variant_metadata_id: metadata.id },
})
}
}
logger.info(`[phase-2-seed] created ${createdProducts.length} YGO single products`)
} else {
logger.info("[phase-2-seed] YGO singles already seeded, skipping")
}
// Sealed LOB pack — follow seedPokemonSealed pattern
const sealedHandle = "yugioh-lob-booster-pack"
const { data: sealedExists } = await query.graph({
entity: "product",
fields: ["id"],
filters: { handle: [sealedHandle] },
})
if (sealedExists.length === 0) {
const { result: createdProducts } = await createProductsWorkflow(container).run({
input: {
products: [{
handle: sealedHandle,
title: "Legend of Blue Eyes Booster Pack",
status: ProductStatus.PUBLISHED,
options: [{ title: "Pack", values: ["Sealed"] }],
variants: [{
title: "Legend of Blue Eyes Booster Pack",
sku: `${sealedHandle}-sealed`,
manage_inventory: true,
prices: [{ currency_code: "sgd", amount: 800 }],
options: { Pack: "Sealed" },
}],
sales_channels: [{ id: defaultChannelId }],
}],
},
})
const link = container.resolve(ContainerRegistrationKeys.LINK)
const [metadata] = await tcgService.createTcgVariantMetadatas([{
product_type: ProductType.SEALED,
game: Game.YUGIOH,
set_code: "LOB",
set_name: "Legend of Blue Eyes",
language: Language.EN,
sealed_format: SealedFormat.BOOSTER_PACK,
condition: Condition.SEALED,
}])
await link.create({
[Modules.PRODUCT]: { product_variant_id: createdProducts[0].variants[0].id },
[TCG_MODULE]: { tcg_variant_metadata_id: metadata.id },
})
logger.info("[phase-2-seed] created YGO sealed pack")
}
}
Full code for seedOnePiece:
async function seedOnePiece(deps: SeedDeps) {
const { container, logger, query, tcgService, defaultChannelId } = deps
const fixtures = [
{
handle: "onepiece-luffy-op01-001-en",
title: "Monkey D. Luffy · OP01-001 (EN)",
set_code: "OP01",
set_name: "Romance Dawn",
card_number: "OP01-001",
rarity: "Leader",
language: Language.EN,
variants: [
{ condition: Condition.NM, variation: "Normal" },
{ condition: Condition.NM, variation: "Alt Art" },
],
},
{
handle: "onepiece-luffy-op01-001-jp",
title: "モンキー・D・ルフィ · OP01-001 (JP)",
set_code: "OP01",
set_name: "Romance Dawn",
card_number: "OP01-001",
rarity: "Leader",
language: Language.JA,
variants: [
{ condition: Condition.NM, variation: "Normal" },
],
},
{
handle: "onepiece-zoro-op01-025",
title: "Roronoa Zoro · OP01-025",
set_code: "OP01",
set_name: "Romance Dawn",
card_number: "OP01-025",
rarity: "Super Rare",
language: Language.EN,
variants: [
{ condition: Condition.NM, variation: null },
],
},
]
const { data: existing } = await query.graph({
entity: "product",
fields: ["id", "handle"],
filters: { handle: fixtures.map((f) => f.handle) },
})
const existingHandles = new Set(existing.map((p: any) => p.handle))
const toCreate = fixtures.filter((f) => !existingHandles.has(f.handle))
if (toCreate.length === 0) {
logger.info("[phase-2-seed] One Piece singles already seeded, skipping")
} else {
const productsInput = toCreate.map((f) => ({
handle: f.handle,
title: f.title,
status: ProductStatus.PUBLISHED,
options: [
{ title: "Condition", values: Array.from(new Set(f.variants.map((v) => v.condition))) },
{ title: "Variation", values: Array.from(new Set(f.variants.map((v) => v.variation ?? "Normal"))) },
],
variants: f.variants.map((v) => ({
title: `${f.title} — ${v.condition} ${v.variation ?? "Normal"}`,
sku: `${f.handle}-${v.condition}-${(v.variation ?? "N").toUpperCase().slice(0, 3)}`,
manage_inventory: true,
prices: [{ currency_code: "sgd", amount: 500 }],
options: { Condition: v.condition, Variation: v.variation ?? "Normal" },
})),
sales_channels: [{ id: defaultChannelId }],
}))
const { result: createdProducts } = await createProductsWorkflow(container).run({
input: { products: productsInput },
})
const link = container.resolve(ContainerRegistrationKeys.LINK)
for (let pi = 0; pi < createdProducts.length; pi++) {
const cp = createdProducts[pi]
const f = toCreate[pi]
for (let vi = 0; vi < cp.variants.length; vi++) {
const variant = cp.variants[vi]
const v = f.variants[vi]
const [metadata] = await tcgService.createTcgVariantMetadatas([{
product_type: ProductType.SINGLE,
game: Game.ONE_PIECE,
set_code: f.set_code,
set_name: f.set_name,
card_number: f.card_number,
rarity: f.rarity,
language: f.language,
condition: v.condition,
variation: v.variation,
}])
await link.create({
[Modules.PRODUCT]: { product_variant_id: variant.id },
[TCG_MODULE]: { tcg_variant_metadata_id: metadata.id },
})
}
}
logger.info(`[phase-2-seed] created ${createdProducts.length} One Piece single products`)
}
// OP07 Booster Box — sealed
const sealedHandle = "onepiece-op07-booster-box"
const { data: sealedExists } = await query.graph({
entity: "product",
fields: ["id"],
filters: { handle: [sealedHandle] },
})
if (sealedExists.length === 0) {
const { result: createdProducts } = await createProductsWorkflow(container).run({
input: {
products: [{
handle: sealedHandle,
title: "One Piece OP07 Booster Box",
status: ProductStatus.PUBLISHED,
options: [{ title: "Pack", values: ["Sealed"] }],
variants: [{
title: "One Piece OP07 Booster Box",
sku: `${sealedHandle}-sealed`,
manage_inventory: true,
prices: [{ currency_code: "sgd", amount: 10000 }],
options: { Pack: "Sealed" },
}],
sales_channels: [{ id: defaultChannelId }],
}],
},
})
const link = container.resolve(ContainerRegistrationKeys.LINK)
const [metadata] = await tcgService.createTcgVariantMetadatas([{
product_type: ProductType.SEALED,
game: Game.ONE_PIECE,
set_code: "OP07",
set_name: "Romance Dawn",
language: Language.EN,
sealed_format: SealedFormat.BOOSTER_BOX,
pack_count: 24,
condition: Condition.SEALED,
}])
await link.create({
[Modules.PRODUCT]: { product_variant_id: createdProducts[0].variants[0].id },
[TCG_MODULE]: { tcg_variant_metadata_id: metadata.id },
})
logger.info("[phase-2-seed] created One Piece sealed box")
}
}
Invoke:
await seedYugioh({ container, logger, query, tcgService, defaultChannelId: defaultChannel.id })
await seedOnePiece({ container, logger, query, tcgService, defaultChannelId: defaultChannel.id })
- [ ] Step 2: Run and verify
yarn medusa exec ./src/scripts/seed-phase-2.ts
docker compose exec postgres psql -U medusa -d medusa \
-c "SELECT game, COUNT(*) FROM tcg_variant_metadata GROUP BY game;"
# Expected: pokemon: 10, yugioh: 5, one_piece: 4 (adjust as your fixtures settle)
- [ ] Step 3: Commit
git add src/scripts/seed-phase-2.ts
git commit -m "feat(seed): Yu-Gi-Oh! (edition axis) + One Piece (variation + language)
YGO: Blue-Eyes (1st Ed vs Unlimited), Dark Magician (NM vs MP), LOB
sealed pack.
One Piece: Luffy OP01-001 Normal + Alt Art (variation), Luffy JP
(language-as-product), Zoro SR, OP07 booster box."
Task 16: Seed — Accessories (no TCG metadata)¶
Files:
- Modify: src/scripts/seed-phase-2.ts
Plain Medusa products. No TcgVariantMetadata rows. Proves accessories flow through unmodified Medusa.
- [ ] Step 1: Add
seedAccessorieshelper
async function seedAccessories(deps: SeedDeps) {
const { container, logger, query, defaultChannelId } = deps
const fixtures = [
{
handle: "accessory-ultra-pro-sleeves-100ct-black",
title: "Ultra Pro Sleeves 100ct Black",
sku: "ULTRAPRO-SLV-100-BLK",
price: 800,
},
{
handle: "accessory-dragon-shield-deck-box",
title: "Dragon Shield Deck Box",
sku: "DRAGONSHIELD-DECK-BOX",
price: 1500,
},
{
handle: "accessory-playmat",
title: "TCG Playmat",
sku: "GENERIC-PLAYMAT-001",
price: 3500,
},
]
const { data: existing } = await query.graph({
entity: "product",
fields: ["id", "handle"],
filters: { handle: fixtures.map((f) => f.handle) },
})
const existingHandles = new Set(existing.map((p: any) => p.handle))
const toCreate = fixtures.filter((f) => !existingHandles.has(f.handle))
if (toCreate.length === 0) {
logger.info("[phase-2-seed] accessories already seeded, skipping")
return
}
await createProductsWorkflow(container).run({
input: {
products: toCreate.map((f) => ({
handle: f.handle,
title: f.title,
status: ProductStatus.PUBLISHED,
options: [{ title: "Default", values: ["Default"] }],
variants: [{
title: f.title,
sku: f.sku,
manage_inventory: true,
prices: [{ currency_code: "sgd", amount: f.price }],
options: { Default: "Default" },
}],
sales_channels: [{ id: defaultChannelId }],
})),
},
})
logger.info(`[phase-2-seed] created ${toCreate.length} accessory products (no tcg metadata)`)
}
Invoke:
await seedAccessories({ container, logger, query, tcgService, defaultChannelId: defaultChannel.id })
- [ ] Step 2: Run and verify
yarn medusa exec ./src/scripts/seed-phase-2.ts
# Confirm accessories exist as products but have NO tcg metadata:
docker compose exec postgres psql -U medusa -d medusa -c "
SELECT p.handle, COUNT(tm.id) AS tcg_metadata_rows
FROM product p
JOIN product_variant v ON v.product_id = p.id
LEFT JOIN product_variant_tcg_variant_metadata_link l ON l.product_variant_id = v.id
LEFT JOIN tcg_variant_metadata tm ON tm.id = l.tcg_variant_metadata_id
WHERE p.handle LIKE 'accessory-%'
GROUP BY p.handle;
"
# Expected: all 3 accessory rows show tcg_metadata_rows = 0
(Join table name might be product_variant_tcg_variant_metadata or product_variant_tcg_metadata depending on Medusa's naming — run \dt first to confirm exact name.)
- [ ] Step 3: Commit
git add src/scripts/seed-phase-2.ts
git commit -m "feat(seed): accessories — plain Medusa products, no TCG metadata
Three accessory fixtures (sleeves, deck box, playmat). Confirms
accessories pass through standard Medusa flows without any tcg
module involvement."
Task 17: Integration test — seed idempotency + link query¶
Files:
- Create: integration-tests/http/seed-phase-2.spec.ts
Boots the full Medusa app, runs the seed, asserts the second run is a no-op, and validates the Module Link query returns joined data.
- [ ] Step 1: Write the test
// integration-tests/http/seed-phase-2.spec.ts
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import seedPhase2 from "../../src/scripts/seed-phase-2"
medusaIntegrationTestRunner({
testSuite: ({ getContainer }) => {
describe("Phase 2 seed", () => {
it("seeds idempotently (second run is a no-op for existing products)", async () => {
const container = getContainer()
await seedPhase2({ container, args: [] } as any)
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data: firstRun } = await query.graph({
entity: "product",
fields: ["id", "handle"],
})
const firstCount = firstRun.length
await seedPhase2({ container, args: [] } as any)
const { data: secondRun } = await query.graph({
entity: "product",
fields: ["id", "handle"],
})
expect(secondRun.length).toBe(firstCount)
})
it("exposes TcgVariantMetadata via the 1:1 Module Link query", async () => {
const container = getContainer()
await seedPhase2({ container, args: [] } as any)
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data: variants } = await query.graph({
entity: "product_variant",
fields: ["id", "sku", "tcg_variant_metadata.*"],
filters: { sku: ["pokemon-charizard-ex-obsidian-flames-223-en-NM-NF-N"] },
})
expect(variants.length).toBe(1)
expect(variants[0].tcg_variant_metadata).toBeTruthy()
expect(variants[0].tcg_variant_metadata.game).toBe("pokemon")
expect(variants[0].tcg_variant_metadata.condition).toBe("NM")
})
it("exposes TcgSerializedItem via the N:1 Module Link query", async () => {
const container = getContainer()
await seedPhase2({ container, args: [] } as any)
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data: variants } = await query.graph({
entity: "product_variant",
fields: ["id", "sku", "tcg_serialized_items.*"],
filters: { sku: ["pokemon-charizard-base-set-unlimited-graded-placeholder"] },
})
expect(variants.length).toBe(1)
expect(variants[0].tcg_serialized_items.length).toBeGreaterThanOrEqual(1)
expect(variants[0].tcg_serialized_items[0].grader).toBe("PSA")
})
})
},
})
jest.setTimeout(120 * 1000)
- [ ] Step 2: Run
Run: yarn test:integration:http --testPathPattern=seed-phase-2
Expected: 3/3 pass.
- [ ] Step 3: Commit
git add integration-tests/http/seed-phase-2.spec.ts
git commit -m "test(seed): integration test for Phase 2 seed
- Seed is idempotent (second run = no new products).
- Module Link 1:1 query returns TcgVariantMetadata joined to variant.
- Module Link N:1 query returns TcgSerializedItem array joined to
graded placeholder variant."
Task 18: Open PR B (seed script + integration test)¶
- [ ] Step 1: Push and open PR
git push -u origin draft/claude-phase-2-seed
gh pr create --title "feat(seed): Phase 2 fixtures + integration tests" --body-file - <<'EOF'
## Summary
Idempotent seed script (`src/scripts/seed-phase-2.ts`) populating realistic TCG fixtures across Pokémon, Yu-Gi-Oh!, and One Piece, plus accessories that prove non-TCG products pass through Medusa unmodified.
Integration test validates idempotency + Module Link queries return joined data.
See `tcg-platform/phase-2-plan.md` for coverage requirements.
## Coverage
- Pokémon: 2 single products (6 variants exercising condition × foil × language × variation); 4 sealed (pack/box/ETB/JP box); 1 graded slab + 1 ungraded premium raw.
- Yu-Gi-Oh!: Blue-Eyes (edition axis), Dark Magician (condition axis), LOB sealed pack.
- One Piece: Luffy Normal + Alt Art (variation), Luffy JP (language-as-product), Zoro SR, OP07 booster box.
- Accessories: 3 plain Medusa products with no TCG metadata.
- 3 stock locations (Warehouse, Store, Event-A) linked to default sales channel.
## Test plan
- [ ] CI green.
- [ ] `yarn test:integration:http --testPathPattern=seed-phase-2` passes locally.
- [ ] After merge, run `yarn medusa exec ./src/scripts/seed-phase-2.ts` on staging; verify row counts with SQL.
EOF
- [ ] Step 2: Wait for CI and merge
Task 19: Deploy + seed on staging; capture findings¶
On CT 105:
- [ ] Step 1: Pull merged PRs and rebuild
ssh root@192.168.0.55 # via Tailscale
cd /opt/tcg-platform
git pull
docker compose up -d --build
docker compose logs -f medusa # watch for migrations + link sync + startup
Expected: migrations from PR A apply (two new Migration files), link sync creates join tables, server starts.
- [ ] Step 2: Run the seed on staging
docker compose exec medusa yarn medusa exec ./src/scripts/seed-phase-2.ts
# Expected: product counts per Task 15/16 verifications
Run a second time to confirm idempotency:
docker compose exec medusa yarn medusa exec ./src/scripts/seed-phase-2.ts
# Expected: "already seeded, skipping" for each section
- [ ] Step 3: Verify in Postgres
docker compose exec postgres psql -U medusa -d medusa -c "
SELECT game, product_type, COUNT(*)
FROM tcg_variant_metadata
GROUP BY game, product_type
ORDER BY game, product_type;
"
# Expected breakdown (approximate):
# pokemon | single | 6
# pokemon | sealed | 4
# pokemon | graded | 1
# yugioh | single | 4
# yugioh | sealed | 1
# one_piece | single | 4
# one_piece | sealed | 1
docker compose exec postgres psql -U medusa -d medusa -c "
SELECT sku, is_graded, grader, grade, status
FROM tcg_serialized_item
ORDER BY sku;
"
# Expected: 2 rows (PSA slab + premium raw)
- [ ] Step 4: Verify in the admin UI
Open https://tcg-staging.exzentcg.com/app/, navigate to Products. Expected: all seeded products appear. Click into Charizard — variants visible. Click accessories — they render as plain products (no TCG fields anywhere, confirming accessories don't get metadata).
- [ ] Step 5: Take a new Proxmox snapshot
From the Proxmox host:
pct snapshot 105 phase-2-seeded --description "tcg-staging after Phase 2 seed"
Task 20: Stock-location transfer scenarios (manual, captured in findings)¶
One-off scenario script to prove Medusa's inventory module handles transfers between Warehouse ↔ Store ↔ Event-A.
Files:
- Create: src/scripts/phase-2-stock-scenarios.ts
- [ ] Step 1: Create scenario script
// src/scripts/phase-2-stock-scenarios.ts
import { ExecArgs } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
Modules,
} from "@medusajs/framework/utils"
export default async function stockScenarios({ container }: ExecArgs) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const inventoryService = container.resolve(Modules.INVENTORY)
const stockLocationService = container.resolve(Modules.STOCK_LOCATION)
const [warehouse] = await stockLocationService.listStockLocations({ name: "Warehouse" })
const [eventA] = await stockLocationService.listStockLocations({ name: "Event-A" })
if (!warehouse || !eventA) {
throw new Error("Expected Warehouse + Event-A stock locations — run seed first")
}
// Pick an arbitrary seeded variant (first Charizard NM EN Non-foil Normal)
const { data: variants } = await query.graph({
entity: "product_variant",
fields: ["id", "sku", "inventory_items.inventory.*"],
filters: { sku: ["pokemon-charizard-ex-obsidian-flames-223-en-NM-NF-N"] },
})
const variant = variants[0]
if (!variant) throw new Error("Seed variant missing — run seed first")
const inventoryItem = variant.inventory_items[0].inventory
logger.info(`[stock-scenario] using inventory_item ${inventoryItem.id}`)
// Scenario 1: Set 10 at Warehouse, 0 at Event-A
await inventoryService.createInventoryLevels([
{ inventory_item_id: inventoryItem.id, location_id: warehouse.id, stocked_quantity: 10 },
{ inventory_item_id: inventoryItem.id, location_id: eventA.id, stocked_quantity: 0 },
])
logger.info("[stock-scenario] S1: set warehouse=10, event-A=0")
// Scenario 2: Transfer 3 to Event-A (simulate booth setup)
await inventoryService.updateInventoryLevels([
{ inventory_item_id: inventoryItem.id, location_id: warehouse.id, stocked_quantity: 7 },
{ inventory_item_id: inventoryItem.id, location_id: eventA.id, stocked_quantity: 3 },
])
logger.info("[stock-scenario] S2: transferred 3 to Event-A")
// Scenario 3: Return 2 unsold (simulate event tear-down)
await inventoryService.updateInventoryLevels([
{ inventory_item_id: inventoryItem.id, location_id: warehouse.id, stocked_quantity: 9 },
{ inventory_item_id: inventoryItem.id, location_id: eventA.id, stocked_quantity: 1 },
])
logger.info("[stock-scenario] S3: returned 2 to Warehouse")
// Verify totals
const levels = await inventoryService.listInventoryLevels({ inventory_item_id: inventoryItem.id })
const total = levels.reduce((sum, l) => sum + (l.stocked_quantity ?? 0), 0)
logger.info(`[stock-scenario] final total across all locations: ${total} (expected 10)`)
}
- [ ] Step 2: Run on staging
docker compose exec medusa yarn medusa exec ./src/scripts/phase-2-stock-scenarios.ts
# Expected log: warehouse=7 → 9, event-A=3 → 1, final total 10
- [ ] Step 3: Commit
git add src/scripts/phase-2-stock-scenarios.ts
git commit -m "feat(seed): Phase 2 stock-location transfer scenarios
Three sequential scenarios per phase-2-plan stock-location validation:
S1 — allocate initial Warehouse stock, zero at Event-A.
S2 — transfer 3 to Event-A (event setup).
S3 — return 2 to Warehouse (tear-down).
Run via: yarn medusa exec ./src/scripts/phase-2-stock-scenarios.ts
Results captured in tcg-platform/phase-2-findings.md."
- [ ] Step 4: Open and merge PR
Quick PR with just this one file, CI green, merge.
Task 21: Write phase-2-findings.md (gap analysis) — homelab repo¶
Files:
- Create: tcg-platform/phase-2-findings.md (in ExzenTCG-Homelab)
Switch back to the homelab repo on a new branch: git -C <homelab-path> checkout -b draft/claude-phase-2-findings.
- [ ] Step 1: Create the findings doc
# Phase 2: Medusa Fit Validation — Findings
**Status:** Complete ✅
**Delivered:** <DATE>
**Predecessor:** [Phase 2 Kickoff Plan](phase-2-plan.md)
**Exit criterion met:** *"TCG taxonomy maps cleanly onto Medusa primitives with documented gaps."*
## 1. Purpose + method
Prove Medusa can model a real TCG catalogue — singles, sealed, graded, and accessories — using `product`/`productVariant`/`inventory_item`/`stock_location` primitives, extended only where the domain is genuinely custom.
Method:
1. Designed two Mikro-ORM DML models (`TcgVariantMetadata`, `TcgSerializedItem`) covering the full Phase 2 product-type scope.
2. Linked them to `productVariant` with `defineLink()` (1:1 and N:1 respectively).
3. Seeded realistic fixtures across Pokémon / YGO / One Piece / accessories.
4. Ran stock-location transfer scenarios exercising Medusa's native inventory API.
5. Queried all fixtures via the Module Link graph to verify joins work.
## 2. Medusa primitives leaned on
- `product`, `productVariant` — base catalogue structure.
- `inventory_item`, `inventory_level` — per-location stock for fungible variants.
- `stock_location` — Warehouse / Store / Event-A.
- `sales_channel` — default sales channel bound to all three locations.
- `link` (ContainerRegistrationKeys.LINK) — programmatic Module Link creation during seed.
- Query API — `query.graph({ entity, fields: [..., "tcg_variant_metadata.*", "tcg_serialized_items.*"] })`.
## 3. Custom extensions added
- `src/modules/tcg/models/variant-metadata.ts` — `TcgVariantMetadata`, nullable polymorphic fields discriminated by `product_type`.
- `src/modules/tcg/models/serialized-item.ts` — `TcgSerializedItem`, one row per physical slab / premium raw.
- `src/modules/tcg/service.ts` — `TcgModuleService` with `reserveSerializedItem` using transactional `available → reserved`.
- `src/links/product-variant-tcg-metadata.ts` — 1:1 with deleteCascade.
- `src/links/product-variant-serialized-item.ts` — N:1 (isList) with deleteCascade.
- Two migrations: auto-generated (tables) + hand-written CHECK constraint (`status='reserved' ⇒ reserved_by_order_id NOT NULL`).
## 4. Verified scenarios
- ✅ Singles with condition × foil × language × variation axes (Charizard OBF 4 variants, Pikachu PAF 2 variants).
- ✅ Edition axis (YGO Blue-Eyes 1st Ed vs Unlimited).
- ✅ Variation axis (One Piece Luffy Normal vs Alt Art).
- ✅ Language-as-product for sealed (Pokémon Surging Sparks EN box + Paldean Fates JP box as separate products).
- ✅ Sealed formats: booster pack, booster box, ETB.
- ✅ Graded slab via placeholder variant (`manage_inventory=false`) + `TcgSerializedItem` with grader/grade/cert.
- ✅ Ungraded premium raw via `TcgSerializedItem` with `is_graded=false` + `condition_notes`.
- ✅ Accessories as plain Medusa products with zero TCG metadata.
- ✅ Stock location transfers (Warehouse ↔ Event-A) conserve total quantity.
- ✅ Module Link queries return joined data cleanly.
- ✅ Reservation state machine rejects double-reserve and reserve-of-sold.
- ✅ Seed script idempotent (safe to re-run).
## 5. Gaps / concerns surfaced
<Fill in during validation — anticipated candidates from the kickoff plan:>
- **Variant explosion** — how many variants per product before admin UX degrades? Note actual numbers observed.
- **Serialized reservation vs Medusa inventory** — checkout flow for serialized items bypasses Medusa's native inventory reservation. Document what reconciliation Phase 5 needs to add.
- **Bulk stock adjustments** — Medusa's admin expects one-by-one edits; TCG operators think "+20 after opening a box." Call out the Phase 5 tool need.
- **Per-channel pricing** — not addressed in Phase 2. Confirm assumption for Phase 4.
- **Product search** — searching "charizard PSA 10" doesn't hit `TcgSerializedItem` through standard Medusa search. Phase 5 needs a custom endpoint joining product ↔ tcg_variant_metadata ↔ tcg_serialized_item.
- **Join table names** — Medusa generated names different from expected? Document actuals.
- Any other surprises from the run on staging.
## 6. Phase 2 decisions recorded
- **Two tables, not three.** `TCGChannelListing` deferred to Phase 3 (shape driven by Shopee connector needs).
- **Accessories carry no `TcgVariantMetadata`.** Zero tcg module involvement — confirmed in the seed + query.
- **Language-as-product for sealed.** EN and JP boxes are separate products. Cleaner for connectors than language-as-variant.
- **Graded slabs use a placeholder variant.** `manage_inventory=false` on the base variant; real inventory in `TcgSerializedItem`.
- **Reservation concurrency** — `SELECT ... FOR UPDATE` in `TcgModuleService.reserveSerializedItem`, plus a CHECK constraint as a belt-and-braces invariant.
- **Per-game extensions deferred.** Pokémon HP, Magic mana_cost, YGO level, One Piece power — none modelled. Re-evaluate Phase 5.
## 7. Open questions for Phases 3+
- How does the Shopee connector resolve a marketplace listing to a specific `TcgSerializedItem` (vs a fungible variant)?
- Should `TCGChannelListing` carry listing-level overrides (shipping, per-channel title), or should those live in a separate `TCGListingOverride` table?
- What's the Phase 5 UX for the "send to PSA, get slab back" workflow? (Decrement fungible by 1, create serialized row linked to same base variant.)
- Variant naming convention when the user adds 30+ variants — is the auto-generated variant title readable?
## Links
- [Phase 2 Kickoff Plan](phase-2-plan.md)
- [Implementation Plan](phase-2-implementation.md) *(this plan)*
- [tcg-staging runbook](../homelab/05_service_deployments/tcg-staging.md)
- [ ] Step 2: Add to
mkdocs.ymlnav
- Phase 2 Kickoff: tcg-platform/phase-2-plan.md
- Phase 2 Implementation: tcg-platform/phase-2-implementation.md
- Phase 2 Findings: tcg-platform/phase-2-findings.md
- [ ] Step 3: Fill in section 5 (gaps) and section 7 (open questions) with real observations
This is the value-add of the doc — what you actually observed during the run on staging. Populate anywhere marked <Fill in during validation — …> with concrete findings. No placeholders left.
- [ ] Step 4: Commit
git add tcg-platform/phase-2-findings.md mkdocs.yml
git commit -m "docs(tcg-platform): Phase 2 findings — gap analysis
Exit deliverable for Phase 2: Medusa fit validation results, verified
scenarios, gaps + concerns surfaced for later phases, decisions
recorded, open questions tees up for Phase 3+."
Task 22: Update tcg-staging.md with Phase 2 applied¶
Files:
- Modify: homelab/05_service_deployments/tcg-staging.md
Add a "Phase 2 applied" entry near the top so future readers see the current state of the CT at a glance.
- [ ] Step 1: Edit the runbook
After the existing **Deployed:** 2026-04-22 … line, add:
**Phase 2 applied:** <DATE> — TCG module scaffold, seed data, stock-location scenarios. See [Phase 2 Findings](../../tcg-platform/phase-2-findings.md).
**Snapshot:** `phase-2-seeded`
Also update the Deployment Details table's Snapshot row to include both:
| Snapshot | `initial-deploy` *(2026-04-22)*, `phase-2-seeded` *(<DATE>)* |
- [ ] Step 2: Commit
git add homelab/05_service_deployments/tcg-staging.md
git commit -m "docs(tcg-staging): mark Phase 2 applied + new snapshot"
Task 23: Open and merge the homelab docs PR¶
- [ ] Step 1: Push and open PR
git push -u origin draft/claude-phase-2-findings
gh pr create --title "docs(tcg-platform): Phase 2 findings + runbook update" --body-file - <<'EOF'
## Summary
Phase 2 exit deliverables that live in the homelab docs:
- `tcg-platform/phase-2-findings.md` — gap analysis, decisions, open questions.
- `tcg-platform/phase-2-implementation.md` — implementation plan (historical record).
- `mkdocs.yml` — nav entries for the two new pages.
- `homelab/05_service_deployments/tcg-staging.md` — Phase 2 applied + new snapshot.
## Links to the code changes (on `tcg-platform`)
- PR A: Module scaffold + Module Links + tests
- PR B: Seed script + integration test
- PR C: Stock-location scenario script
EOF
- [ ] Step 2: Wait for CI and merge
Task 24: Close-out verification¶
- [ ] Step 1: Exit criteria checklist
Reference phase-2-plan.md §Exit criteria. Tick each item on staging:
- [ ]
src/modules/tcg/merged, module registered, migrations applied on CT 105. - [ ]
tcg_variant_metadata+tcg_serialized_itemtables exist in staging Postgres. - [ ] Both Module Links queryable via
fields: ["*", "tcg_variant_metadata.*", "tcg_serialized_items.*"]. - [ ]
yarn medusa exec ./src/scripts/seed-phase-2.tsruns idempotently on staging. - [ ] Three stock locations exist with transfer scenarios passing.
- [ ]
phase-2-findings.mdcommitted with all sections filled (no placeholders remaining in §5 and §7). - [ ] Unit test (reservation transitions) + integration test (seed + link query) passing in CI.
- [ ] CI green on all Phase 2 PRs; Phase 1 staging regression check (admin login works).
- [ ]
tcg-staging.mdhas the "Phase 2 applied" entry. -
[ ] Owner signed off on
phase-2-findings.mdand seed output in staging. -
[ ] Step 2: Announce Phase 2 complete
Once all boxes ticked, Phase 2 is closed. Phase 3 (Shopee slice) can start against the now-populated data model.
Summary of PRs¶
| # | Repo | Title | Branch |
|---|---|---|---|
| A | tcg-platform | feat(tcg): Phase 2 module scaffold + Module Links | draft/claude-phase-2-module-scaffold |
| B | tcg-platform | feat(seed): Phase 2 fixtures + integration tests | draft/claude-phase-2-seed |
| C | tcg-platform | feat(seed): Phase 2 stock-location transfer scenarios | draft/claude-phase-2-stock-scenarios |
| D | ExzenTCG-Homelab | docs(tcg-platform): Phase 2 findings + runbook update | draft/claude-phase-2-findings |