Skip to content

Phase 2: Medusa Fit Validation — 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: 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

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 against tcg-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 reserveSerializedItem properly

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."

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."

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"

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 seedPokemonSingles helper + 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 seedPokemonSealed helper 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 seedPokemonGraded helper 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 seedAccessories helper
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."

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.yml nav
      - 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_item tables exist in staging Postgres.
  • [ ] Both Module Links queryable via fields: ["*", "tcg_variant_metadata.*", "tcg_serialized_items.*"].
  • [ ] yarn medusa exec ./src/scripts/seed-phase-2.ts runs idempotently on staging.
  • [ ] Three stock locations exist with transfer scenarios passing.
  • [ ] phase-2-findings.md committed 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.md has the "Phase 2 applied" entry.
  • [ ] Owner signed off on phase-2-findings.md and 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