Phase 5: Merchant Ops UI v1 — Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-developmentto implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Corrigendum (2026-05-10) — Next.js 16, not 15
PR C kicked off on 2026-05-10. By that time, npx create-next-app@latest had shipped Next.js 16 (with React 19.2). Every "Next.js 15" reference in this plan should be read as "Next.js 16+". Three things to know about the bump:
middleware.tswas renamed toproxy.tsin v16.0.0 (the function name changed frommiddlewaretoproxytoo). PR C.5 shipsproxy.ts— see tcg-platform#55 for the actual file. The codemod path:npx @next/codemod@canary middleware-to-proxy ..- The Next 16
AGENTS.mdwarns that "this is NOT the Next.js you know — APIs, conventions, and file structure may all differ from your training data". Readnode_modules/next/dist/docs/before writing App Router code that touches APIs you remember from 14 / 15. searchParamson pages is now a Promise (was a sync object in 14, became Promise in 15+); alwaysawaitit. PR C.4 / C.5 use this idiom.
No architectural delta — Next 16 is App Router-native, server-actions-native, same RSC model. Worth tracking the rename in the plan rather than letting future readers fall into "wait the file is proxy.ts?".
PR C shipped — C.0–C.12 (2026-05-10)
tcg-platform#55 lands the C.0 → C.12 work in 14 commits:
- C.0:
MASTER.md(ui-ux-pro-max design canon, refined for operator-tool context) +PRODUCT.md(impeccable strategic context) +apps/dashboard/CLAUDE.md - C.1: Next.js 16 App Router scaffold (TS + ESLint + Turbopack)
- C.2: Tailwind v4 + theme tokens from MASTER.md, Inter + Fira Code via
next/font - C.3:
lib/medusa.ts(cookie-forwarding wrapper) +lib/auth.ts(requireAuth,requireCapability, two-step login) - C.4:
/loginpage + server action for the two-step Medusa auth flow - C.5:
proxy.tsrequest-level auth gate + return-to-original-path - C.6: route group
(shell)with sidebar + topbar +RoleGatecomponent, role-gated nav - C.7–C.10: Placeholder routes (
/orders,/inventory,/exceptions,/staff) with capability gates - C.10.1: Visual-audit follow-ups (active sidebar state, preserve email on error, focus-ring tweak)
- C.11: 18 Playwright E2E tests via mocked Medusa HTTP server
- C.12: CI workflow extension running build + Playwright on every PR
Plus a chore: gitignore PR-A oversight fix and a fix(server): type-cast Module Link relations follow-up (Medusa's auto-generated ProductVariant type loses link relations under the deduplicated @types/node resolution that landed with the dashboard's own dep tree — affects phase-2-stock-scenarios.ts and the seed-phase-2.spec.ts integration tests; cast applied at three call sites).
Deferred to follow-up PRs (NOT in #55): Lucide icons + shadcn primitives, sidebar collapse + Cmd+\ shortcut, Cmd+K command palette (Phase 6+), real data tables (PR D onward), Cloudflare Workers deploy (PR G).
PRs D / E.1 / E.2 / F / G shipped + Phase 5 closed out (2026-05-10 → 2026-05-12)
All remaining Phase 5 work landed across #56 (orders + exceptions), #57 (inventory), #58 (variant editor), #59 (staff + polish), #60 (Cloudflare Workers deploy), plus three follow-up fixes: #61 (proxy.ts → middleware.ts for Edge-runtime compat), #62 (CF Access service-token headers for Worker → Medusa), #67 (yarn hoist pin for @medusajs/medusa + sidebar comingSoon affordance).
Staging end-to-end validated via Playwright on 2026-05-12: orders / exceptions / inventory / staff all return real data. See Phase 5 Findings for the gotchas + remaining follow-ups (catalog index pages, Phase 4 DCA, exception-triage hardening).
Goal: Ship apps/dashboard/ on ExzenTCG/tcg-platform — a Next.js 16 (App Router) operator dashboard that the merchant's staff use as a daily driver. Authentication via Medusa's existing user actor; authorisation via a new user_role table queried through a Module Link. First-class screens: order list / detail, inventory + transfers, exception queue (with retry), TCG variant editor (live mode), staff management. Deployed to Cloudflare Workers (via @opennextjs/cloudflare), gated by Cloudflare Access in staging.
Architecture: Monorepo with apps/server/ (existing Medusa) + apps/dashboard/ (new Next.js) + packages/shared-types/ (TypeScript domain types re-exported across both apps). Yarn workspaces. Dashboard talks to Medusa over HTTPS — no in-process bindings, no Module Links into Next.js. Server-side role checks on every authoring API; client UI gating is UX nicety only.
Tech Stack: Medusa 2.14.x (existing), Next.js 16 (App Router, server actions, Turbopack), React 19.2, TypeScript 5, PostgreSQL 16, Yarn 4.12.0 (already in starter; workspaces field added in PR A.3, not pre-existing), Cloudflare Workers via @opennextjs/cloudflare (deploy), Cloudflare Access (gating), Playwright (E2E with mocked Medusa per PR C.11).
Predecessor: Phase 5 Kickoff Plan — read it first; this plan implements that design.
Hard decisions locked (do not relitigate):
- Path B — separate Next.js app, not Medusa admin extensions. (Medusa docs citation)
- Monorepo with
apps/server/+apps/dashboard/siblings. Yarn workspaces. - Medusa
useractor as identity; customuser_roletable for RBAC. Singleuseractor type; no custom actor type. - Roles:
admin,ops,finance,event_staff. Permissions matrix in Phase 5 Plan. - Cloudflare Workers (via
@opennextjs/cloudflareadapter) as deploy target. Cloudflare Access gates staging. Dashboard URLdashboard-staging.exzentcg.com(separate subdomain fromtcg-staging.exzentcg.comadmin path — keeps cookie scope clean and avoids reverse-proxy gymnastics). Note: original Phase 5 plan named Cloudflare Pages; the pre-PR audit (2026-05-09) flipped this — Cloudflare deprecated@cloudflare/next-on-pageson 2025-04-08 in favor of Workers + OpenNext, which also gives us runtime env vars (better for auth-bridge config). - TCG variant editor publishes live immediately, no draft state.
Plan-wide conventions¶
These apply to every PR and task. Don't restate them per-task — the executor knows the rhythm.
Branch + commit + PR cadence (per PR):
# Start of every PR
cd /tmp/tcg-platform-pr<X>
git fetch origin && git checkout main && git pull --ff-only
git checkout -b draft/claude-phase-5-pr-<X>-<short>
# After every task: commit on the PR branch
git add <files>
git commit -m "<conventional message>"
# At end of PR
git push -u origin draft/claude-phase-5-pr-<X>-<short>
gh pr create --base main --head draft/claude-phase-5-pr-<X>-<short> \
--title "<title>" --body "$(cat <<'EOF'
<body>
EOF
)"
Working directories:
- All
tcg-platformPRs (A–F): clone fresh to/tmp/tcg-platform-pr<X>for each PR. Do NOT reuse worktrees across PRs. - PR G (homelab repo) work:
/home/xkenchi/Documents/ExzenTCG-Homelabdirectly.
Test commands (after PR A's monorepo restructure):
- Server unit / module integration:
yarn workspace @tcg/server jest <path> - Server HTTP integration:
yarn workspace @tcg/server jest integration-tests/http/<name>.spec.ts - Dashboard component tests:
yarn workspace @tcg/dashboard test <path>(runner locked in PR C — Vitest preferred for native ESM + Next 16 alignment, fall back to Jest if compat issues surface). PR C.11 actually ships Playwright for E2E (no component-test layer yet — defer until there's component logic worth unit-testing.) - Dashboard E2E:
yarn workspace @tcg/dashboard test:e2e(Playwright, runs against staging via env-pinned base URL)
TDD discipline (server work — every task):
- Write the failing test
- Run the test, confirm it fails for the expected reason (not a typo or import error)
- Write the minimum implementation that makes the test pass
- Run the test, confirm it passes
- Commit (test + impl together)
TDD discipline (dashboard work — adapted for UI):
UI work is harder to TDD line-by-line. Per-screen rhythm:
- Write a Playwright spec describing the user-visible outcome (e.g. "operator with
opsrole sees the cancel button on an open order; operator withfinancerole does not"). Confirm it fails (no screen exists yet). - Build the route + minimum component to make the Playwright spec pass.
- Add component-level tests (Vitest) for any non-trivial state machines or input validation only — don't snapshot-test layout.
- Commit (Playwright + component + impl together).
For permission-gating work specifically: every authoring API gets a server-side test that exercises admin / ops / finance / event_staff paths and asserts 403 for the disallowed roles. No exceptions.
Hard constants (use these verbatim — no substitutions):
| Concern | Correct value | Wrong value to avoid |
|---|---|---|
| Workspace tool | Yarn workspaces v4 (yarn 4.12.0 in starter; workspaces field added in PR A.3) |
Pnpm, Turbo, Nx |
| Yarn version | yarn@4.12.0 (pinned in workspace-root packageManager) |
4.x.x placeholder, anything older |
| Server workspace name | @tcg/server |
@medusajs/medusa-app, medusa-server, medusa-starter-default (the starter's current name — replace) |
| Dashboard workspace name | @tcg/dashboard |
dashboard, @tcg/admin |
| Shared-types workspace | @tcg/shared-types |
@tcg/types, shared |
| Server moves to | apps/server/ |
apps/medusa/, server/, backend/ |
| Dashboard lives at | apps/dashboard/ |
apps/admin/, dashboard/, frontend/ |
| Dashboard URL (staging) | dashboard-staging.exzentcg.com |
path on tcg-staging.exzentcg.com |
| RBAC table | user_role (1:1 with user) |
user_roles (plural — Medusa convention is singular for 1:1), polymorphic user_permissions |
| Roles enum | 'admin' \| 'ops' \| 'finance' \| 'event_staff' |
'editor', 'viewer', 'manager' |
| Auth identity | Medusa user actor (built-in) |
Custom actor type, separate auth provider |
| User module import path | import UserModule from "@medusajs/medusa/user" (matches existing @medusajs/medusa/order pattern in src/links/) |
@medusajs/user (works at runtime but inconsistent with rest of codebase) |
Linkable property name for user_role |
StaffModule.linkable.userRole (camelCase of model name) |
linkable.user_role (snake_case — wrong) |
/admin/me response shape |
{ user, role, permissions[] } (server-derived) |
client-trusts a role claim from cookie |
| Auth flow shape | Two-step: POST /auth/user/emailpass with { email, password } → { token } (JWT in body), then POST /auth/session with Authorization: Bearer ${token} → Set-Cookie: connect.sid=... |
POST /admin/auth (does NOT exist in Medusa v2.14 — verified no dist/api/admin/auth/ dir; actual path is POST /auth/{actor_type}/{auth_provider} per dist/api/auth/[actor_type]/[auth_provider]/route.js); single-call that sets the cookie directly |
| Variant publish mode | Live immediately on save | Draft state with publish button |
| Deploy target | Cloudflare Workers via @opennextjs/cloudflare adapter (wrangler deploy) |
Cloudflare Pages with @cloudflare/next-on-pages (deprecated 2025-04-08), Vercel, self-hosted Next |
| Auth gate (staging) | Cloudflare Access on the dashboard subdomain | Custom auth wall, IP allow-list |
| Cookie scope | Domain=.exzentcg.com; Secure; SameSite=Lax; HttpOnly (configured via apps/server/medusa-config.ts projectConfig.cookieOptions — Medusa does NOT default to this; must override. Dev mode strips Domain — see PR C local-dev caveat) |
per-subdomain cookie that the dashboard can't forward |
| Compose project name | name: tcg-platform set explicitly at the top of apps/server/docker-compose.yml (locks volumes / networks regardless of cwd) |
implicit — defaults to cwd basename, would create server_* volumes orphaning pre-monorepo data |
| CI docker build invocation | docker build -t tcg-platform:ci -f apps/server/Dockerfile . (build context = monorepo root) |
docker build apps/server/ (Dockerfile can't see workspace-root yarn files) |
When ANY task touches one of these areas, restate the correct constant inline. Don't reference back to this table from a task.
Pre-PR audit (completed 2026-05-09):
Phase 5's pre-flight audit ran 2026-05-09 against the current Medusa starter, Medusa v2.14 docs, Next.js 15 App Router patterns (the corrigendum at the top of this file flips this to 16+), and Cloudflare's Workers/Pages guidance. Audit doc: /tmp/phase-5-audit-2026-05-09.md. 20 deltas were folded into this plan inline. Material outcomes:
- Cloudflare deploy target flipped from Pages to Workers +
@opennextjs/cloudflare— Cloudflare deprecated@cloudflare/next-on-pageson 2025-04-08; Workers + OpenNext is the official path for Next.js 15+ server actions (still applies under Next 16 — adapter tracks current Next). Hard-constants table reflects this; PR G's task list updated. update-medusa.ymlworkflow disabled preemptively in PR A (Task A.5b) — it relies on a flat-starter layout and would go red post-monorepo. Re-enable as a Phase-5-adjacent follow-up.- Three Next.js 15+ corrections to the auth-bridge tasks (all still apply under Next 16):
cookies()is async (every call site mustawait), outboundfetch()does NOT auto-forward cookies (explicitCookie:header required — PR C.3'smedusaFetchwrapper handles this), and Medusa's auth flow is two-step (POST /auth/user/emailpassreturns JWT in body — post-merge audit 2026-05-09 corrected this from the pre-PR audit's/admin/authwhich doesn't exist in v2.14; thenPOST /auth/sessionwith Bearer JWT returns theconnect.sidcookie). PR C tasks now spell these out verbatim. - Module Link clarifications for PR B.3: import is
@medusajs/medusa/user, linkable is camelCaseStaffModule.linkable.userRole, Module Links create a separate join table (the DML'suser_idcolumn + unique index is what enforces 1:1). - PR B.0 added: Medusa does NOT default to
Domain=.exzentcg.com— must explicitly setprojectConfig.cookieOptionsinapps/server/medusa-config.ts. The cookie-scope invariant is enforced from this single config block. - Local-dev cookie caveat added as PR C.5b — browsers reject
Domain=.exzentcg.comonlocalhost; either/etc/hoststo a*.exzentcg.comsubdomain or strip Domain in dev.
Two open questions from the audit are still flagged: (a) req.scope.resolve() inside Medusa middleware is undocumented (30-min spike at PR B kickoff — resolved post-PR-B by reading framework source), (b) update-medusa.yml re-enable strategy is deferred follow-up.
Off-the-shelf stack (PR C onward)¶
Don't reinvent. Phase 5 consumes ecosystem libraries for everything that isn't genuinely application-specific. The only custom-UX surface in the entire phase is the TCG variant editor in PR E (cartesian-product editor for 30+ variants — no library ships that pattern).
Strong locks (no real alternatives — encode as PR C kickoff defaults)¶
| Concern | Library | Why this one |
|---|---|---|
| Authentication / sessions / JWT | Medusa core (authenticate("user", ["session", "bearer"]), emailpass provider, connect.sid cookie) |
Already locked in Phase 5 plan ("Medusa user as identity"). The dashboard's auth bridge is ~60 LOC of consumer code; do NOT introduce NextAuth.js / Auth.js / Clerk / BetterAuth — they all replace Medusa as the identity source and would invalidate the architectural decision. |
| Forms + validation | React Hook Form + Zod | Next.js de-facto stack; Zod schemas double as TypeScript types — re-export through @tcg/shared-types so server (apps/server) and dashboard (apps/dashboard) share the same validation rules. |
| Tables (orders / inventory / exceptions) | TanStack Table v8 | Headless table library; sorting / filtering / pagination / column resizing built-in. Pairs cleanly with shadcn/ui's table primitives. |
| Client-side data fetching + cache | TanStack Query v5 | Same maintainer as Table; deduplicates fetches, handles stale-while-revalidate. Use for screens with client-side state (filters that don't need full SSR). |
| Server-side data fetching | Next.js server actions + the medusaFetch wrapper from PR C.3 |
Already designed in. Use server actions for mutations and SSR-bound reads; TanStack Query for client-side reactive screens. |
| JSON viewer (exception detail) | react-json-tree | Tiny lib for the collapsible raw-event payload viewer. PR D.7's JsonTree component wraps it with our theme tokens — don't write a viewer from scratch. |
| Toasts / notifications | sonner | Built into shadcn/ui's component set. Don't roll a toast system. |
| Dates | date-fns | Tree-shakeable; we already need it for order timestamps + Shopee push timestamps. |
Lean recommendations (UI/UX Pro Max skill should confirm at PR C kickoff)¶
| Concern | Lean | Confirm via |
|---|---|---|
| Component library | shadcn/ui (Radix primitives + Tailwind) | The uipro-cli design-system generator output for "B2B internal SaaS / commerce-ops dashboard". shadcn/ui is on its supported-stacks list; copy-paste-ownership avoids vendor lock; aligns with our Tailwind v4 base. If the skill recommends something different (e.g. Mantine, MUI), flip — but the burden of proof is on overriding shadcn/ui. |
| Design tokens (colors, spacing, typography) | Output of uipro-cli design-system generator at PR C kickoff |
The skill's reasoning engine + the merchant's industry context (TCG retail / commerce ops). Lock the palette + font pairing here; PR F branding builds on it rather than redoes it. |
| Tailwind version | Tailwind v4 | Already locked in plan PR C.2. CSS-first config, modern. |
Deferred to later phases¶
| Phase | Concern | Likely choice |
|---|---|---|
| Phase 7 (POS) | Touch-first UI primitives | TBD; possibly the same shadcn/ui base with touch-tuned overrides, or a separate library if desktop ↔ touch divergence becomes painful. |
| Phase 8 (Finance) | Charts | Tremor (built on Recharts + Tailwind, dashboard-first) or Recharts directly. Defer until Phase 8 starts; choice depends on whether we want pre-built dashboard layouts or only the chart primitives. |
Anti-patterns (do NOT introduce these)¶
- Auth replacement libraries (NextAuth.js, Auth.js, Clerk, BetterAuth, Lucia) — Medusa is the identity source; using any of these creates a parallel auth system that must be reconciled with Medusa's
useractor. The cost outweighs the convenience. - Custom data-grid implementations beyond what TanStack Table provides — if a screen needs more than TanStack Table can do, the right answer is to add a behavior on top, not to write a grid.
- A bespoke SSR data fetcher — use Next.js server actions + the
medusaFetchwrapper. Don't build a "Medusa SDK for Next.js" layer; that's whatmedusaFetchalready is. - A custom toast / modal / popover system — shadcn/ui ships these. Use them.
- An animation library beyond CSS transitions + Framer Motion (if needed for the variant editor) — start with CSS; introduce Framer Motion only at the specific screen that needs it.
UI/UX skill stack — install + workflow¶
Source: Thomas Yao's UI/UX Pro Max + Impeccable workflow guide drove the integration shape below. The thesis: AI-generated UIs converge on a "statistical-average" aesthetic ("AI slop") because they have no design reference system. UI/UX Pro Max provides one upfront; Impeccable enforces it post-implementation.
The four-layer skill stack¶
| Layer | Skill | When | Role |
|---|---|---|---|
| 1 | UI/UX Pro Max (uipro-cli / marketplace) |
Pre-PR-C kickoff (one-time per project) | Generates the design system: industry-matched style, palette, typography, anti-patterns, pre-delivery checklist. Persisted to apps/dashboard/design-system/MASTER.md as the "north star" every subsequent session reads. |
| 2 | Impeccable (pbakaus/impeccable) |
Per-region audit cycle during PRs C → F | Provides the audit/refinement vocabulary. Bakes in Paul Bakaus's design opinions (rejects bounce animations, neon-on-dark, single-font monotony). Slash commands listed below. |
| 3 (optional) | Anthropic frontend-design skill |
As needed during component implementation | Conceptual direction layer — composes with the above. Only pull in if Pro Max + Impeccable + your judgment feel insufficient on a specific surface. |
| 4 (optional) | Vercel web-design-guidelines skill |
Final compliance pass | Vercel's house design rules for Next.js apps. Optional; useful if the dashboard ever ships outside our staging gate. |
The blog's framing — "This isn't a capability problem; it's a structural one: AI generating interfaces lack a design reference system" — is the unlock. Layers 1+2 give us that reference system; 3+4 are belt-and-braces.
One-time install (before PR C kickoff)¶
Run these locally on the dev box, once. They install globally (so any tcg-platform clone gets them) — global install is the right scope because we'll use these skills across multiple PRs and possibly multiple worktrees.
# Prereq: Python 3.x (uipro-cli's reasoning engine needs it)
python3 --version # should print 3.x — install via brew/apt/winget if missing
# 1. UI/UX Pro Max — global install for Claude Code
npm install -g uipro-cli
uipro init --ai claude --global
# 2. Impeccable — Claude marketplace install (preferred) OR npx skills
/plugin marketplace add pbakaus/impeccable # in Claude Code
# OR if you prefer the cli path:
# npx skills add pbakaus/impeccable
Optional layers 3+4 can be added later — leave alone for now to avoid noise.
Per-project setup (before PR C kickoff, in /tmp/tcg-platform-pr-c)¶
# 1. Teach Impeccable the project context. ONE-TIME per project.
# It writes a .impeccable.md at the project root capturing design preferences.
/teach-impeccable
# 2. Generate the design system with Pro Max. Persist for multi-page consistency.
python3 ~/.claude/skills/ui-ux-pro-max/scripts/search.py \
"B2B internal SaaS, single-tenant TCG merchant ops dashboard, data-dense order and inventory views, custom variant editor as marquee feature, desktop-first, English locale, TCG retail (Pokemon / Yu-Gi-Oh / One Piece), brand: ExzenTCG" \
--design-system --persist -p "tcg-platform-dashboard"
# 3. Output lands at: apps/dashboard/design-system/MASTER.md (commit it).
# Iterate conversationally before committing if the first generation
# feels off — Pro Max can regenerate with refined context.
# 4. (Optional) Add per-project Impeccable overrides if any of Bakaus's
# opinions conflict with TCG branding decisions. Edit .impeccable.md
# at repo root with the specific exceptions.
MASTER.md is checked in (it's the design-token canon). .impeccable.md is checked in only if it has project-specific exceptions; otherwise let Impeccable use its defaults.
Per-region build cadence (PRs C → F)¶
The blog's strongest discipline rule: never generate entire pages at once. Build region-by-region, audit between regions. This is a notable shift from the implementation plan's per-task framing — task breakdowns inside PRs C/D/E/F should now decompose along region boundaries, not "build the whole orders screen → run tests".
For each region (header, side-nav, filter bar, table row, detail card, action button cluster, etc.):
1. Build the region (TDD per existing rules — Playwright spec or component test first)
2. /audit [region] # technical quality, 5 dims, P0-P3 severity
3. /critique [region] # UX heuristics review (Nielsen 10)
4. Targeted fixes:
/typeset # font / hierarchy / sizing / line spacing
/arrange # layout monotony, inconsistent spacing
/colorize # strategic color where mono is dull
/normalize # align inconsistent components to MASTER.md
/distill # remove visual noise / over-complexity
/bolder # raise contrast / impact when needed
/animate # purposeful transitions, motion-preference-aware
/adapt # cross-device responsive checks
/harden # error handling + edge cases
5. /polish before committing the region # final pass
6. Commit (test + impl + audit notes if anything material)
Don't run every command on every region — match the command to the actual issue. /audit + /polish are the bookends; the middle commands are reach-for-when-needed.
Per-PR engagement intensity¶
| PR | UI/UX skill engagement | Why |
|---|---|---|
| C — Dashboard skeleton + auth bridge | Foundation-critical. Run Pro Max design-system generation here; commit MASTER.md. Build login form + AppShell + side-nav as regions; audit each. Locks the visual language for everything that follows. |
|
| D — Orders + exception queue | High. First real operator surfaces. Region-by-region: filter bar → table row → detail card → action cluster → JSON-tree component. /audit + /critique cycle on each. |
|
| E — Inventory + variant editor | Maximum. The variant editor is the marquee novel UX in the whole roadmap. Plan calls it the "highest-risk surface". Heavy use of /critique (Nielsen heuristics), /distill (cartesian-product editor will tend toward complexity), /animate (sparingly — only for state transitions like "row added"). Budget extra time for the audit cycle. |
|
| F — Staff management + branding | High — final polish phase. Full /polish sweep across the dashboard, /distill to remove anything that crept in during D/E, /adapt for any responsive gaps. This is the "make it feel like a product" PR. |
|
| G — CF Workers deploy + findings + runbook | None — infra/docs only. No UI work. |
Caveats (from the source guide)¶
- Pro Max's 161-product DB is mainstream. If the TCG aesthetic wants something niche (e.g. specific Y2K / cyberpunk-card-game vibes for the storefront-adjacent screens), override via per-prompt CLAUDE.md rules or by re-running the generator with explicit style overrides.
- Impeccable encodes Paul Bakaus's preferences — it rejects bounce animations, neon-on-dark, single-font monotony, and a few other patterns. "Impeccable encodes into author aesthetic preferences, and you should know this." If TCG branding deliberately needs any of those, declare exceptions in
.impeccable.mdat repo root. MASTER.mddrift = patchwork dashboard. The whole point of--persistis consistency across PR sessions. TreatMASTER.mdas canonical: change it deliberately, never let a session "freelance" away from it.
Where this lives in the repo¶
apps/dashboard/design-system/
├── MASTER.md # Pro Max output, committed, "north star"
├── tokens/ # extracted token files (colors, typography, spacing)
└── components-inventory.md # running list of regions built + their audit state
.impeccable.md # repo root; Impeccable project context + any overrides
CLAUDE.md # add a section pointing UI/UX work at MASTER.md + Impeccable cycle
Update apps/dashboard/CLAUDE.md (create if missing) at PR C kickoff with a top-level rule: "Before building any new region, read apps/dashboard/design-system/MASTER.md. After implementing, run /audit [region] + /polish before committing."
File structure¶
This is the complete file inventory for the Phase 5 implementation. Each file's responsibility is one line; full code lives in the per-task detail. Files are grouped by introducing PR.
Created in PR A — Monorepo restructure¶
Move every starter file under repo root into apps/server/ EXCEPT the explicit "stay at root" list below. This includes (non-exhaustively, audit-confirmed): package.json, Dockerfile, docker-compose.yml, docker-compose.dev.yml, docker-entrypoint.sh, medusa-config.ts, instrumentation.ts, tsconfig.json, jest.config.js, src/, integration-tests/, __mocks__/, .dockerignore, .env.example, .env.template, .env.test, .env.development.example, .npmrc. Plus anything else git ls-files surfaces at repo root that isn't on the stay-at-root list.
package.json (NEW root — workspace root: "private": true, "workspaces": [...])
.yarnrc.yml (stays at root — keep starter's nodeLinker config)
yarn.lock (regenerate at root — single lockfile covering all workspaces)
apps/server/ (move target — see "move every starter file" rule above)
apps/server/package.json (move — set "name": "@tcg/server")
apps/server/medusa-config.ts (move — modified in PR B for cookieOptions; pure move in A)
apps/server/Dockerfile (move — no content change; build context is now apps/server/)
apps/server/docker-compose.yml (move — `build: .` resolves to apps/server/ when run from there)
apps/server/docker-compose.dev.yml (move — referenced by yarn dev:db scripts, relative paths survive)
apps/server/docker-entrypoint.sh (move — uses absolute /server/ paths inside container, build context apps/server/)
apps/server/instrumentation.ts (move — Medusa CLI loads from project root; lives next to medusa-config.ts)
apps/server/.npmrc (move — pnpm-style hoist patterns; yarn 4 ignores them anyway)
apps/server/src/ (move — entire tree)
apps/server/tsconfig.json (move — paths are tsconfig-relative; survive)
apps/server/integration-tests/ (move — entire tree)
apps/server/__mocks__/ (move — entire tree, jest reads from cwd)
apps/server/.medusa/ (gitignored — regenerated on first build)
apps/dashboard/.gitkeep (placeholder — empty Next.js app comes in PR C)
apps/dashboard/README.md (placeholder pointing at this plan)
packages/shared-types/.gitkeep (placeholder — first real type re-export comes in PR B)
packages/shared-types/package.json (placeholder — "name": "@tcg/shared-types", "main": "./src/index.ts")
packages/shared-types/src/index.ts (placeholder — export {} only)
.github/workflows/ci.yml (modify — every step needs `cd apps/server` or `yarn workspace @tcg/server <cmd>`. Critical: `docker build .` line at the end becomes `docker build apps/server/` — `working-directory:` searches DON'T catch this line.)
.github/workflows/test-cli.yml (modify — repath as needed)
.github/workflows/update-medusa.yml (modify — disable preemptively per locked decision; see Task A.5b)
.github/workflows/update-preview-deps-ci.yml (modify — repath as needed)
.github/workflows/update-preview-deps.yml (modify — repath as needed)
README.md (root — modify to describe monorepo layout, document `cd apps/server && docker compose up` runbook)
Files that do NOT move (stay at repo root):
.git/,.github/,README.md,package.json(becomes workspace root),yarn.lock,.gitignore,LICENSE,.yarn/,.yarnrc.yml
Created in PR B — Staff module + user_role + auth bridge¶
apps/server/src/modules/staff/index.ts module registration (STAFF_MODULE)
apps/server/src/modules/staff/service.ts StaffService extends MedusaService (CRUD + getRoleFor)
apps/server/src/modules/staff/types/enums.ts UserRole enum: 'admin' | 'ops' | 'finance' | 'event_staff'
apps/server/src/modules/staff/models/user-role.ts UserRole DML model (id, user_id unique, role, assigned_at, assigned_by)
apps/server/src/modules/staff/migrations/<auto>.ts create user_role table + UNIQUE(user_id) + indexes
apps/server/src/modules/staff/__tests__/service.spec.ts moduleIntegrationTestRunner: assignRole / getRoleFor / listStaff round-trips
apps/server/src/modules/staff/permissions.ts deriveCapabilities(role): Capability[] — single source of truth for the matrix
apps/server/src/links/staff-user-role-medusa-user.ts 1:1 UserRole ↔ Medusa user (defineLink with cardinality=1:1)
apps/server/src/api/admin/me/route.ts GET /admin/me — returns { user, role, permissions[] }
apps/server/src/api/admin/users/route.ts GET / POST — list staff + create staff (admin only)
apps/server/src/api/admin/users/[id]/role/route.ts PATCH — change role (admin only) + GET — fetch one
apps/server/src/api/middlewares.ts (modify — register requireRole() helpers for /admin/users/* and friends)
apps/server/src/api/__tests__/admin-me.spec.ts integration: each role hits /admin/me, gets the right permissions
apps/server/src/api/__tests__/admin-users.spec.ts integration: ops/finance/event_staff get 403 on /admin/users/*
apps/server/src/scripts/seed-admin-role.ts yarn medusa exec ./src/scripts/seed-admin-role.ts <email> — promotes existing user to admin
apps/server/medusa-config.ts (modify — register staff module)
packages/shared-types/src/index.ts (modify — export UserRoleType, Capability, MeResponse)
packages/shared-types/src/auth.ts (new — type definitions for the auth-bridge surface)
Created in PR C — Dashboard skeleton + auth bridge¶
apps/dashboard/package.json Next.js 16, React 19.2, @tcg/shared-types workspace dep
apps/dashboard/next.config.ts App Router config; rewrites for /api/auth proxy if needed
apps/dashboard/tsconfig.json extends server's base; aliases @tcg/shared-types
apps/dashboard/.env.example NEXT_PUBLIC_MEDUSA_URL, MEDUSA_INTERNAL_URL, NEXT_PUBLIC_DASHBOARD_URL
apps/dashboard/middleware.ts Next middleware — redirect to /login if no session
apps/dashboard/src/app/layout.tsx root layout: ExzenTCG branding placeholder, providers
apps/dashboard/src/app/page.tsx redirect to /orders if authed, /login otherwise
apps/dashboard/src/app/login/page.tsx login form; posts to Medusa /auth/user/emailpass, then /auth/session, sets cookie
apps/dashboard/src/app/login/actions.ts server action: login(email, password) → cookie-set + redirect
apps/dashboard/src/app/(authed)/layout.tsx authed shell: side nav, role-gated nav items
apps/dashboard/src/app/(authed)/orders/page.tsx placeholder — replaced in PR D
apps/dashboard/src/app/(authed)/inventory/page.tsx placeholder — replaced in PR E
apps/dashboard/src/app/(authed)/exceptions/page.tsx placeholder — replaced in PR D
apps/dashboard/src/app/(authed)/staff/page.tsx placeholder — replaced in PR F (admin-only)
apps/dashboard/src/lib/medusa.ts typed fetch wrapper that forwards the session cookie
apps/dashboard/src/lib/auth.ts getMe() server-side helper that calls /admin/me; cached per request
apps/dashboard/src/lib/permissions.ts hasPermission(role, capability) — mirrors the server matrix
apps/dashboard/src/components/RoleGate.tsx React component that hides children if role lacks permission
apps/dashboard/playwright.config.ts E2E config — base URL from env
apps/dashboard/e2e/auth.spec.ts login flow per role + role-gated nav visibility
apps/dashboard/vitest.config.ts (or jest.config.ts — locked in this PR)
apps/dashboard/src/lib/__tests__/permissions.spec.ts unit test of permission matrix
Created in PR D — Order list + detail + exception queue¶
apps/server/src/api/admin/orders/route.ts (existing Medusa endpoint — verify shape; extend with channel filter if needed)
apps/server/src/api/admin/exceptions/route.ts GET — list connector_exception (filter by status, connector)
apps/server/src/api/admin/exceptions/[id]/route.ts GET — fetch one + raw event payload
apps/server/src/api/admin/exceptions/[id]/retry/route.ts POST — re-enqueue raw event (set processed=false; emit event)
apps/server/src/api/admin/exceptions/[id]/resolve/route.ts POST — mark resolved with operator note
apps/dashboard/src/app/(authed)/orders/page.tsx (replace placeholder) — list with filters: status, channel, date
apps/dashboard/src/app/(authed)/orders/[id]/page.tsx detail: line items, customer, channel context (Shopee badge), actions
apps/dashboard/src/app/(authed)/orders/[id]/cancel/action.ts server action — calls Medusa cancelOrder workflow
apps/dashboard/src/app/(authed)/exceptions/page.tsx (replace placeholder) — table with status + connector filters
apps/dashboard/src/app/(authed)/exceptions/[id]/page.tsx detail: error class, raw event payload (JSON tree), retry / resolve actions
apps/dashboard/src/components/ChannelBadge.tsx Shopee / Lazada / Manual visual chip
apps/dashboard/src/components/JsonTree.tsx collapsible JSON viewer for raw event payloads
apps/dashboard/e2e/orders.spec.ts ops cancels an order; finance can view but cannot cancel (403)
apps/dashboard/e2e/exceptions.spec.ts ops retries an exception; raw event flips processed=false; finance role hidden
Created in PR E — Inventory + TCG variant editor¶
apps/server/src/api/admin/inventory/by-location/route.ts GET — stock-by-location grouped view
apps/server/src/api/admin/inventory/transfer/route.ts POST — initiate stock transfer (wraps Medusa workflow)
apps/server/src/api/admin/products/[id]/variants/bulk/route.ts POST — bulk variant create + price set in one transaction
apps/server/src/api/admin/products/sku-generator/route.ts POST — preview SKUs from axes (no DB write)
apps/dashboard/src/app/(authed)/inventory/page.tsx (replace placeholder) — stock-by-location table + low-stock highlight
apps/dashboard/src/app/(authed)/inventory/transfer/page.tsx form: from-location, to-location, items
apps/dashboard/src/app/(authed)/products/[id]/variants/page.tsx variant list (read)
apps/dashboard/src/app/(authed)/products/[id]/variants/edit/page.tsx the BIG editor — see PR E.6 for shape
apps/dashboard/src/components/VariantEditor/ the BIG editor component family
├── AxesPanel.tsx configure axes (condition / language / foil / grading / set / language / ...)
├── VariantTable.tsx cartesian-product table with bulk-edit affordances
├── SKUGenerator.tsx formula-driven SKU preview pane
├── PriceMatrix.tsx bulk price set across customer groups
└── PublishConfirm.tsx confirm dialog explaining "live immediately"
apps/dashboard/e2e/inventory.spec.ts ops sees stock; finance sees stock read-only; event_staff can transfer
apps/dashboard/e2e/variant-editor.spec.ts ops creates 30+ variants for a card; appears in storefront API
Note: PR E may split into E.1 (inventory) + E.2 (variant editor) if the variant editor work alone exceeds ~10 tasks. Decision deferred to executor; document the split in the PR description and update this plan inline.
Created in PR F — Staff management + branding polish¶
apps/dashboard/src/app/(authed)/staff/page.tsx (replace placeholder) — list users with their roles (admin only)
apps/dashboard/src/app/(authed)/staff/[id]/page.tsx user detail with role-change form
apps/dashboard/src/app/(authed)/staff/new/page.tsx invite new user (creates Medusa user + assigns role)
apps/dashboard/src/app/globals.css ExzenTCG theme tokens (colors, fonts, spacing)
apps/dashboard/src/components/AppShell.tsx polished side-nav + topbar with user menu + logout
apps/dashboard/src/components/Logo.tsx ExzenTCG logo SVG component
apps/dashboard/public/logo.svg the actual asset
apps/dashboard/e2e/staff.spec.ts admin assigns role; non-admin blocked at UI + 403 at API
Created in PR G — Cloudflare Workers deploy + findings + runbook¶
apps/dashboard/wrangler.jsonc Cloudflare Workers config (main: ".open-next/worker.js", assets, compatibility_flags: ["nodejs_compat"])
apps/dashboard/open-next.config.ts @opennextjs/cloudflare adapter config (defineCloudflareConfig)
.github/workflows/deploy-dashboard-staging.yml GH Action: build dashboard, run opennextjs-cloudflare build, wrangler deploy
# In ExzenTCG-Homelab repo:
tcg-platform/phase-5-findings.md (new — homelab repo)
homelab/05_service_deployments/tcg-staging.md (modify — Phase 5 dashboard deploy notes + Cloudflare Access policy)
homelab/01_planning/ (new diagram if helpful — dashboard ↔ medusa ↔ postgres)
mkdocs.yml (modify — register phase-5-findings in nav)
PR overview¶
| PR | Title | Repo | Tasks | Commits | Focus |
|---|---|---|---|---|---|
| A | Monorepo restructure | tcg-platform |
9 | ~9 | Move Medusa to apps/server/, set up workspaces, disable update-medusa.yml, CI green |
| B | Staff module + user_role + auth bridge APIs | tcg-platform |
12 | ~12 | Backend foundation; cookieOptions configured; /admin/me works; role gating tested |
| C | Dashboard skeleton + auth bridge + design-system foundation | tcg-platform |
12 | ~12 | UX skill setup → MASTER.md → login + AppShell as audited regions → role-gated shell; placeholder screens for D/E/F |
| D | Order list + detail + exception queue | tcg-platform |
10 | ~10 | First real operator screens; Phase 3 exception retry UI |
| E | Inventory + TCG variant editor | tcg-platform |
12–18 | ~12–18 | The big surface; may split into E.1 + E.2 |
| F | Staff management + branding | tcg-platform |
6 | ~6 | Admin-only screens + theme polish |
| G | Cloudflare Workers deploy (OpenNext) + findings + runbook | mixed | 6 | ~6 | Deploy story + operational artefacts |
PRs A → F must merge in order on tcg-platform/main. PR G can ship in parallel with the back end of F since the docs work is in a different repo.
PR A — Monorepo restructure¶
Goal: Convert the flat tcg-platform repo into a yarn-workspaces monorepo. Move every existing file into apps/server/, scaffold empty apps/dashboard/ and packages/shared-types/, update CI, verify a clean build + test green from the new paths. No behavior changes. A reviewer should be able to skim this PR and confirm it's purely structural.
Branch: draft/claude-phase-5-pr-a-monorepo from tcg-platform/main.
Pre-flight: Run the pre-PR audit (see Plan-wide conventions). Confirm Medusa v2.14 is happy living under apps/server/ — it should be (Medusa is path-agnostic), but confirm before mass-moving files.
Task A.1: Branch + dry-run move list¶
- [ ]
git checkout -b draft/claude-phase-5-pr-a-monorepo - [ ] List every file under repo root with
git ls-files. Categorise each as: move to apps/server/, stay at root (.github/,README.md,LICENSE,package.json(becomes workspace root),yarn.lock,.gitignore,.yarnrc.yml,.yarn/), or delete (none expected). - [ ] Write the categorisation to
MIGRATION_PLAN.md(this file is deleted before merge — it's just a working artifact for the executor + reviewer).
Commit: chore(monorepo): scaffold migration plan
Task A.2: Mechanical move into apps/server/¶
- [ ]
mkdir -p apps/server - [ ] For every file in the "move to apps/server/" list,
git mv <file> apps/server/<file>. Usegit mv -kto skip files that don't exist; verify the count matchesMIGRATION_PLAN.md. - [ ] Verify no file was missed:
git status --porcelain | grep -v '^R'should print nothing unexpected. - [ ] Do NOT modify file contents in this commit. Pure moves only.
Commit: chore(monorepo): move Medusa app to apps/server/ (mechanical)
Task A.3: Workspace root package.json + yarn config¶
- [ ] Create new repo-root
package.json:
{
"name": "tcg-platform",
"private": true,
"packageManager": "yarn@4.12.0",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"build": "yarn workspaces foreach -A run build",
"test": "yarn workspaces foreach -A run test",
"dev:server": "yarn workspace @tcg/server dev",
"dev:dashboard": "yarn workspace @tcg/dashboard dev"
}
}
- [ ] Update
apps/server/package.jsonnamefield frommedusa-starter-default(audit-confirmed) to@tcg/server. Keep all other fields untouched. - [ ] Verify
.yarnrc.ymland.yarn/releases/yarn-4.12.0.cjsare at the root (they should be — the move skipped these). - [ ] Run
yarn installfrom the repo root. Should complete cleanly. If yarn complains about workspace integrity, fix per its message before committing.
Commit: chore(monorepo): set up yarn workspaces root + rename server workspace
Task A.4: apps/dashboard/ + packages/shared-types/ placeholders¶
- [ ]
mkdir -p apps/dashboard packages/shared-types/src - [ ]
apps/dashboard/package.json:{"name": "@tcg/dashboard", "private": true, "version": "0.0.0"}. Real Next.js scaffold lands in PR C. - [ ]
apps/dashboard/README.md: one-line pointer attcg-platform/phase-5-implementation.md. - [ ]
packages/shared-types/package.json:{"name": "@tcg/shared-types", "version": "0.0.0", "main": "./src/index.ts", "types": "./src/index.ts"}. - [ ]
packages/shared-types/src/index.ts:export {}placeholder. - [ ] Run
yarn installagain. All three workspaces should be recognised.
Commit: chore(monorepo): scaffold dashboard + shared-types workspaces
Task A.5: Repath every CI workflow¶
- [ ] For every file in
.github/workflows/*.yml, find everyworking-directory:orpaths:reference and update from repo-root paths toapps/server/.... - [ ] Pay specific attention to: build steps that reference
dist/,.medusa/,src/— all should becomeapps/server/dist/,apps/server/.medusa/,apps/server/src/. - [ ] Critical line that
working-directory:searches do NOT catch: in.github/workflows/ci.yml, the final step runsdocker build -t tcg-platform:ci .. Change todocker build -t tcg-platform:ci -f apps/server/Dockerfile .— build context = monorepo root (Dockerfile mustCOPYworkspace-root yarn artifacts), Dockerfile path explicit via-f. (PR-A executor 2026-05-09: the original audit instructiondocker build apps/server/failed because Dockerfile + yarn workspace root files diverged; verified-merged form is the one above.) - [ ] Do NOT touch the build commands themselves yet —
yarn workspace @tcg/server <cmd>substitution comes in A.6 if needed.
Commit: chore(monorepo): repath CI workflows to apps/server/
Task A.5b: Disable update-medusa workflow preemptively¶
- [ ]
.github/workflows/update-medusa.ymlusesmedusajs/medusa-update-action@v1which assumes a flat-starter layout. Post-monorepo it will likely fail. Per locked decision (2026-05-09): disable preemptively rather than accepting red CI. - [ ] Comment out the workflow's
on:triggers (replace withon: workflow_dispatch:only — keeps the file but prevents auto-runs). - [ ] Add a top-of-file comment:
# DISABLED 2026-05-09 by Phase 5 monorepo restructure. The medusajs/medusa-update-action@v1 expects a flat starter layout. Re-enable behind a wrapper (or replace with dependabot for apps/server/package.json) as a Phase-5-adjacent follow-up. - [ ] Track as a follow-up issue with title
chore: re-enable medusa version-bump automation post-monorepo.
Commit: chore(monorepo): disable update-medusa.yml preemptively (post-monorepo restructure)
Task A.6: Repath docker-compose.yml + Dockerfile¶
Audit was wrong here — corrigendum 2026-05-09 from PR-A execution: the audit said "Dockerfile fully self-contained, no edits needed". It needed a substantial rewrite because workspace-root yarn files (
yarn.lock,.yarnrc.yml,.yarn/) don't move intoapps/server/. The Dockerfile must build with the monorepo root as context. Verified-merged shape:
- [ ] Dockerfile rewrite required. Build context becomes the monorepo root. Sequence:
apps/server/docker-compose.yml: setbuild.context: ../..andbuild.dockerfile: apps/server/Dockerfileso compose invokesdocker buildwith the monorepo root as context.apps/server/Dockerfile:COPYworkspace-root yarn artifacts (package.json yarn.lock .yarnrc.yml ./,.yarn ./.yarn), then each workspace'spackage.json(server + dashboard + shared-types), thenapps/server/ ./apps/server/.WORKDIR /server/apps/serverfor build + runtime.apps/server/docker-entrypoint.sh: bump/server/.medusa/server→/server/apps/server/.medusa/server.- Add
.dockerignoreat the monorepo root (Docker reads it from the build-context root, not next to the Dockerfile). - [ ] Lock the compose project name to keep volume / network names stable regardless of cwd. Add to the top of
apps/server/docker-compose.yml:(Caught on tcg-staging during PR A smoke test — without this, the new compose-up creates an empty Postgres volume next to the existing one. Production-deploy day disaster avoided.)# Without this, compose derives the project name from the cwd. Running from # apps/server/ would create `server_postgres-data` instead of reusing the # existing `tcg-platform_postgres-data` on machines initialised pre-monorepo. name: tcg-platform - [ ] Update root
README.mdwith the newcd apps/server && docker compose upcommand, and addyarn dev:server/yarn dev:dashboardaliases at the workspace root.
Commit: chore(monorepo): repath docker-compose + Dockerfile, update root README
Task A.7: End-to-end build + test verification¶
- [ ] From repo root:
yarn installclean. (No errors. Lockfile changes are expected and OK.) - [ ] From repo root:
yarn workspace @tcg/server build— Medusa builds clean. - [ ] From repo root:
yarn workspace @tcg/server test— every existing test passes from the new paths. - [ ] From
apps/server/:docker compose up -d --build, thendocker compose exec medusa yarn medusa migrations run. Container builds; migrations apply against fresh Postgres. Thendocker compose down -v. - [ ] If anything breaks: fix the path that broke; do NOT rewrite test bodies. The fix is always a path repath, never a behavior change.
Commit (only if any fixes are needed): chore(monorepo): fix repath issues surfaced by build
Task A.8: Delete MIGRATION_PLAN.md, push, open PR¶
- [ ] Delete
MIGRATION_PLAN.md(its job is done). - [ ]
git push -u origin draft/claude-phase-5-pr-a-monorepo - [ ] Open PR with title:
chore(monorepo): convert to workspaces, move Medusa to apps/server/ - [ ] PR body sections:
- Summary: monorepo restructure per phase-5-plan + phase-5-implementation. No behavior changes.
- What landed: Medusa moved to
apps/server/, emptyapps/dashboard/+packages/shared-types/scaffolds, yarn workspaces wired, CI repathed. - Validation:
yarn workspace @tcg/server build+test+ docker stack all green from new paths. Manual diff-review confirms zero content changes (only paths). - Test plan:
- [ ] CI green
- [ ]
yarn installfrom a fresh checkout works - [ ]
cd apps/server && docker compose upboots Medusa successfully - [ ] At least one existing integration test runs and passes from the new path
PR B — Staff module + user_role + auth bridge APIs¶
Goal: Backend foundation for the dashboard. Ship a staff module with a user_role table linked 1:1 to Medusa's user, plus the three new admin API routes (/admin/me, /admin/users, /admin/users/:id/role) and the seed script that promotes the founder to admin. After this PR, a curl against staging with a Medusa session cookie returns the right role + permissions, and role-gated routes 403 the wrong role.
Branch: draft/claude-phase-5-pr-b-staff-rbac from tcg-platform/main (after PR A merges).
Task overview (full task expansion deferred to PR-B kickoff session)¶
- [ ] B.0 Edit
apps/server/medusa-config.tsprojectConfigto add acookieOptionsfield — at the top level ofprojectConfig, sibling tohttpandsessionOptions, NOT nested insidehttp. Verified-correct shape (post-merge audit 2026-05-09 against@medusajs/types/dist/common/config-module.d.tsline 488 +@medusajs/framework/dist/http/express-loader.jsline 34):AddprojectConfig: { // ... databaseUrl, http: { ... }, sessionOptions: { ... }, ... cookieOptions: { domain: process.env.COOKIE_DOMAIN || undefined, sameSite: "lax", secure: process.env.NODE_ENV !== "development", httpOnly: true, }, }COOKIE_DOMAIN=.exzentcg.comtoapps/server/.env.example(commented for dev — see PR C local-dev caveat). This is what enables theDomain=.exzentcg.cominvariant — Medusa does NOT default to it. Placing the field underprojectConfig.http.cookieOptions(where the pre-PR audit originally put it) silently no-ops — Medusa ignores the unknown nested key and falls back to express-session defaults. Verify withcurl -v https://tcg-staging.exzentcg.com/auth/session ... | grep Set-Cookieafter deploy. - [ ] B.1 Branch + scaffold
apps/server/src/modules/staff/directory. - [ ] B.2
UserRoleenum + DML model + auto-generated migration + service spec. Model definition:model.define("user_role", { ... }). Add a unique index onuser_id— that's where 1:1 enforcement actually lives (the Module Link does NOT enforce 1:1 by itself; cardinality is metadata forquery.graph()traversal). - [ ] B.3 Module Link
apps/server/src/links/staff-user-role-medusa-user.ts. Use the existingsrc/links/shopee-order-sync-medusa-order.tsas the canonical pattern. Hard rules: - Import:
import UserModule from "@medusajs/medusa/user"(matches existing repo convention). - Linkable property is camelCase:
StaffModule.linkable.userRole— NOTlinkable.user_role. - Default cardinality of
defineLink(a, b)is 1:1 — pass neither sideisList: true. - Set
deleteCascade: trueon the StaffModule side only (deleting a Medusa user removes the role row; deleting the role row does not delete the user). - Module Links create a separate join table (managed by
db:sync-links) — distinct from theuser_role.user_idDML column. Both exist for different purposes: the DML column for direct-table queries, the link forquery.graph()cross-module reads. - [ ] B.4
permissions.ts:deriveCapabilities(role): Capability[]matching the Phase 5 Plan permission matrix. Unit tested. - [ ] B.5
requireRole()middleware helper; integrated intoapps/server/src/api/middlewares.ts. Hard rules: - Register AFTER
authenticate("user", ["session", "bearer"])in the route's middlewares array (execution order is array order). - Route handler signatures use
AuthenticatedMedusaRequesttype to get typedreq.auth_context. - Inside the middleware: read
req.auth_context.actor_id, then resolve the staff module viareq.scope.resolve("staff"). Confirm with a 30-min spike at PR B kickoff thatreq.scope.resolve()works inside middleware (Awilix is the framework DI container; documented for route handlers but undocumented for middleware — fall back to globalcontainer.resolve("staff")if the scope-bound version doesn't work). - [ ] B.6
GET /admin/meroute + integration test (asserts each role gets correct permissions array). - [ ] B.7
GET /admin/users+POST /admin/usersroute + admin-only gating test. - [ ] B.8
PATCH /admin/users/:id/roleroute + admin-only gating test + audit-trail (assigned_bypopulated). - [ ] B.9
seed-admin-role.tsscript that takes<email>, looks up the existing Medusa user, and inserts auser_rolerow withrole='admin'. Idempotent. - [ ] B.10 Re-export shared types from
packages/shared-types/src/auth.ts(UserRoleType,Capability,MeResponse). Bothapps/server/tsconfig.jsonANDapps/dashboard/tsconfig.jsonneedpathsaliases pointing at../../packages/shared-types/src(since@tcg/shared-typesships TS source, not compiled JS, consumers must compile through it). Verify both workspaces resolve the import. - [ ] B.11 Push, open PR. Body: links to phase-5-plan + phase-5-implementation; test plan asserts each role's capabilities at the API level.
Note for the executor: when picking up PR B, expand each B.x into the same level of detail as PR A's tasks (failing test first, minimum impl, commit). Update this section inline with the expanded steps so it matches Phase 3's depth.
PR C — Dashboard skeleton + auth bridge¶
Goal: Stand up apps/dashboard/ as a Next.js 16 App Router app that authenticates against Medusa, fetches /admin/me, and renders a role-gated shell with placeholder routes for orders / inventory / exceptions / staff. After this PR, an operator can log in at the staging dashboard URL, see their role in the UI, and the side-nav hides items they can't access.
Branch: draft/claude-phase-5-pr-c-dashboard-skeleton
Task overview (full task expansion deferred to PR-C kickoff session)¶
Region-by-region build cadence applies from C.7 onward. See the UI/UX skill stack section for the full audit cycle. Earlier tasks (C.0 through C.6) are wiring with little visual surface — audit cadence kicks in once the AppShell layout work begins.
- [ ] C.0 (NEW — pre-task) UI/UX skills installed (
uipro-cli+pbakaus/impeccable);python3 --versionconfirms Python 3.x. Run/teach-impeccableonce for tcg-platform context. Generate the design system:python3 ~/.claude/skills/ui-ux-pro-max/scripts/search.py "B2B internal SaaS, single-tenant TCG merchant ops dashboard, ..." --design-system --persist -p "tcg-platform-dashboard". Iterate conversationally until the output feels right. Commit toapps/dashboard/design-system/MASTER.md. Addapps/dashboard/CLAUDE.mdwith the "always read MASTER.md first" rule. - [ ] C.1 Scaffold Next.js 16 App Router app at
apps/dashboard/viacreate-next-app@latestthen strip the Vercel-branded demo content (the marketing JSX, Geist fonts, Vercel SVGs inpublic/). Test runner deferred — PR C.11 ships Playwright E2E only; component tests added when there's component logic worth unit-testing. - [ ] C.2 Tailwind v4 + theme-token foundation. Tokens come from
MASTER.md— this task converts the persisted design-system output into Tailwind config + CSS custom properties (real branding refinement lands in PR F, but tokens are foundational). - [ ] C.3
lib/medusa.tstyped fetch wrapper that forwards the session cookie via Nextcookies()API. Hard rules: cookies()is async in Next 15+ (still in Next 16) — every call site mustawait cookies(). Don't copy-paste Next 14 examples.- Outbound
fetch()does NOT auto-forward cookies — thecredentials: 'include'flag does nothing server-side (no browser cookie jar). Set theCookie:header explicitly. -
Verbatim pattern:
// apps/dashboard/src/lib/medusa.ts import { cookies } from 'next/headers' export async function medusaFetch(path: string, init?: RequestInit) { const cookieStore = await cookies() const sid = cookieStore.get('connect.sid')?.value return fetch(`${process.env.MEDUSA_INTERNAL_URL}${path}`, { ...init, headers: { ...init?.headers, ...(sid ? { Cookie: `connect.sid=${sid}` } : {}), 'Content-Type': 'application/json', }, cache: 'no-store', // auth-bearing requests must not cache }) } -
[ ] C.4
lib/auth.tsgetMe()server-side helper. Cached with React'scache()so multiple calls per request hit Medusa once. - [ ] C.5 Login page + server action: implements the two-step Medusa auth flow. The plan's pre-PR audit said step 1 was
POST /admin/auth— that path does NOT exist in Medusa v2.14 (verified post-merge audit 2026-05-09: nodist/api/admin/auth/directory; actual auth route family isPOST /auth/{actor_type}/{auth_provider}). Use the corrected path below. Hard sequence: POST /auth/user/emailpasswith{ email, password }→ expect200with{ token: string }(JWT in body, no Set-Cookie).POST /auth/sessionwithAuthorization: Bearer ${token}→ expect200withSet-Cookie: connect.sid=...in the response.- Parse the cookie value from Medusa's response, then call
(await cookies()).set('connect.sid', value, { domain: process.env.COOKIE_DOMAIN, sameSite: 'lax', secure: !isDev, httpOnly: true }). Note:cookies().set()only works inside a server action or route handler — NOT a server component. The login page calls a server action; that's where the cookie write happens. - [ ] C.5b Local-dev cookie caveat: browsers reject
Domain=.exzentcg.comwhen the host islocalhost. Two options for dev: - (a) Add
127.0.0.1 dashboard-local.exzentcg.comto/etc/hosts, runnext devon port 3001, point browser athttp://dashboard-local.exzentcg.com:3001. - (b) (recommended) Conditionally omit the Domain attribute when
process.env.NODE_ENV === 'development'— host-only cookies work on localhost. Apply this both inapps/server/medusa-config.tscookieOptionsAND in the dashboard'scookies().set()call. - [ ] C.6 Next middleware: redirect to
/loginif no session; redirect to/ordersif authed and on/login. - [ ] C.7 Login form region. Build the login form as the first audited region. Per MASTER.md tokens. Audit cycle:
/audit login-form→/critique login-form→ targeted fixes →/polishbefore commit. - [ ] C.8 AppShell region (authed shell layout: side-nav, topbar placeholder, main content slot).
RoleGatecomponent used in nav + on placeholder pages. Audit cycle on each sub-region (side-nav, topbar, content frame). PR F adds branding polish on top of this skeleton. - [ ] C.9 Placeholder routes for
/orders,/inventory,/exceptions,/staff. Each is a stub "coming soon" page with the right RoleGate wrapper. Light audit (/auditonly — they're stubs). - [ ] C.10 Playwright E2E: 4 logins (admin / ops / finance / event_staff), each lands on
/orders, each sees the correct nav items. - [ ] C.11 Push, open PR. Body should include: link to MASTER.md, list of audited regions + their final audit-cycle commands, any
.impeccable.mdoverrides registered.
PR D — Order list + detail + exception queue¶
Goal: First two real operator surfaces. Replace the placeholder /orders and /exceptions screens with working list + detail views. Order list reads Medusa orders with channel filter; detail shows line items, customer, channel context, and a Cancel action (ops+admin only). Exception queue reads connector_exception rows from Phase 3 and exposes Retry (re-enqueue raw event) + Resolve (mark with operator note).
Branch: draft/claude-phase-5-pr-d-orders-exceptions
Task overview (full task expansion deferred)¶
- [ ] D.1 Server: extend
/admin/ordersshape if needed (channel fromsales_channel, Phase 3 Shopee context). - [ ] D.2 Server:
/admin/exceptions/*routes + retry endpoint that re-enqueues by settingprocessed=false+ emitting an event the existing Phase 3 subscriber listens to. - [ ] D.3 Dashboard:
/orderslist page with channel + status + date filters. - [ ] D.4 Dashboard:
/orders/[id]detail page + Cancel server action (gated byRoleGate). - [ ] D.5 Dashboard:
/exceptionslist page. - [ ] D.6 Dashboard:
/exceptions/[id]detail page with raw-event JSON tree, Retry + Resolve actions. - [ ] D.7
ChannelBadge+JsonTreeshared components. - [ ] D.8 Playwright: ops cancels an order; finance can view but not cancel; ops retries an exception; raw event flips
processed=false; subscriber re-processes successfully. - [ ] D.9 Server-side permission tests (cancel + retry + resolve) for every role.
- [ ] D.10 Push, open PR.
PR E — Inventory + TCG variant editor¶
Goal: Stock-by-location views + transfer initiator + the headline variant editor. The variant editor is the marquee feature of the dashboard — it's the screen that justified Path B. Operators select axes (condition × language × foil × grading × set × …), the editor generates the cartesian product (often 30+ rows), they bulk-edit prices and SKUs, click Save, and every variant is created live + visible on the storefront API immediately.
Branch: draft/claude-phase-5-pr-e-inventory-variants (or split — see note below)
Task overview (full task expansion deferred)¶
- [ ] E.1 Server:
/admin/inventory/by-locationGET — group stock by location, flag low-stock thresholds. - [ ] E.2 Server:
/admin/inventory/transferPOST — wraps Medusa stock-transfer workflow. - [ ] E.3 Server:
/admin/products/:id/variants/bulkPOST — atomically create N variants + their prices in one transaction. Idempotent on (product_id, generated_sku) collision. - [ ] E.4 Server:
/admin/products/sku-generatorPOST — preview SKU strings from axes (no DB write). Mirrors the existing Google Sheets formula. - [ ] E.5 Dashboard:
/inventorylist + low-stock banner. - [ ] E.6 Dashboard:
/inventory/transferform. - [ ] E.7 Dashboard: variant editor —
AxesPanelto define which axes apply to this product (e.g. accessories don't have grading). - [ ] E.8 Dashboard: variant editor —
VariantTablecartesian-product grid with bulk price edit + per-row override. - [ ] E.9 Dashboard: variant editor —
SKUGeneratorformula-driven preview pane reading the SKU formula from/admin/products/sku-generator. - [ ] E.10 Dashboard: variant editor —
PriceMatrix(customer-group prices: Standard / Partner / Streamer / Bulk). - [ ] E.11 Dashboard: variant editor —
PublishConfirmdialog. Explains "live immediately" and asks for confirmation. (No draft mode per locked decision.) - [ ] E.12 Playwright: ops creates a 30-variant card from scratch; storefront API confirms variants visible; price tier check confirms Partner sees Partner prices.
- [ ] E.13 Playwright: event_staff can transfer inventory but cannot edit variants (UI hides + API 403s).
Split decision: if E reaches >12 tasks or PR review fatigue, split into PR E.1 (E.1–E.6 — inventory) and PR E.2 (E.7–E.13 — variant editor). The split should be the executor's call based on actual mid-PR scope. Document the split in the PR descriptions and update this plan inline.
PR F — Staff management + branding¶
Goal: Admin-only staff management (list / invite / change role) + the visual polish pass that makes the dashboard feel like an ExzenTCG product instead of a Tailwind starter. Branding is a "do once, do well" task — logo, colors, typography, layout chrome, empty/error states.
Branch: draft/claude-phase-5-pr-f-staff-branding
Task overview (full task expansion deferred)¶
- [ ] F.1 Dashboard:
/stafflist page (admin only —RoleGate+ middleware). - [ ] F.2 Dashboard:
/staff/[id]detail page with role-change form. Calls PR B'sPATCH /admin/users/:id/role. - [ ] F.3 Dashboard:
/staff/newinvite flow. Creates Medusa user + assigns role in one server action. - [ ] F.4 Theme tokens: ExzenTCG palette, font stack, spacing scale. Wired into Tailwind config.
- [ ] F.5 AppShell polish: side-nav, topbar with user menu + logout, breadcrumbs.
- [ ] F.6 Empty states + error pages (
error.tsxper route segment, custom 404). - [ ] F.7 Playwright: admin invites a new ops user; ops user can log in and reach
/orders; admin downgrades them to event_staff and they get redirected on next page load.
PR G — Cloudflare Workers deploy + findings + runbook¶
Goal: Deploy apps/dashboard/ to Cloudflare Workers via @opennextjs/cloudflare, gate it with Cloudflare Access on the staging URL, ship the findings doc + runbook updates, register everything in the homelab docs site. Audit-driven flip from Cloudflare Pages — see deploy-target hard constant.
Branch: draft/claude-phase-5-pr-g-deploy-docs (mixed-repo PR — see Plan-wide conventions)
Task overview (full task expansion deferred)¶
- [ ] G.1
apps/dashboard/wrangler.jsonc+apps/dashboard/open-next.config.ts+ Cloudflare Workers project setup (manual one-time via Cloudflare dashboard, recorded in runbook). Requiredwrangler.jsoncshape:main: ".open-next/worker.js",assets: { directory: ".open-next/assets" },compatibility_flags: ["nodejs_compat"],compatibility_date: "2024-09-23"or later. - [ ] G.2 GitHub Actions workflow
.github/workflows/deploy-dashboard-staging.yml: triggers onmainpush, builds dashboard via three-step sequence:
- run: yarn install --immutable
- run: yarn workspace @tcg/dashboard build # next build
- run: yarn workspace @tcg/dashboard exec opennextjs-cloudflare build # transforms output to .open-next/
- run: yarn workspace @tcg/dashboard exec wrangler deploy # deploys Worker
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- [ ] G.3 Cloudflare Access policy on
dashboard-staging.exzentcg.com: same email allow-list astcg-staging.exzentcg.com/app. Multiple Access apps on the same zone work independently — no conflict with the existingtcg-stagingpolicy. Documented in homelab/00_secrets/ index entry (reference only — no actual policy JSON in repo). - [ ] G.4
tcg-platform/phase-5-findings.md— written during the staging cutover. Verified scenarios checklist (mirrors phase-3-findings.md shape), gaps surfaced, decisions confirmed/changed. - [ ] G.5
homelab/05_service_deployments/tcg-staging.mdupdated with: new dashboard URL, Cloudflare Workers project name (Worker name, not Pages project), Cloudflare Access policy reference, OpenNext build artifacts location, new Proxmox snapshot tagphase-5-dashboard-live(taken on the Medusa CT, not the Workers-deployed dashboard). - [ ] G.6
mkdocs.ymlregisterstcg-platform/phase-5-findings.mdin nav.
Cross-PR concerns¶
Permission gating must be tested twice¶
Every authoring API gets a server-side test that exercises every role. Every dashboard authoring screen gets a Playwright test that exercises every role's UI. Don't trust client-side gating alone — the server is the source of truth, but the UI gating prevents users from seeing buttons they can't use (UX, not security).
Cookie scope is non-negotiable¶
Medusa's session cookie must be scoped Domain=.exzentcg.com so the dashboard subdomain can forward it to the API subdomain. PR B's /admin/me endpoint and PR C's auth bridge both depend on this. Medusa does NOT default to this — set it explicitly via projectConfig.cookieOptions in PR B.0 (the cookieOptions task). In dev, both medusa-config.ts and the dashboard's cookies().set() must conditionally omit Domain (see PR C local-dev caveat) — browsers reject Domain=.exzentcg.com when the host is localhost. Confirm with curl -v https://tcg-staging.exzentcg.com/auth/session ... | grep Set-Cookie after deploy.
TCG variant editor is the highest-risk surface¶
Every other PR builds reasonably well-understood CRUD on top of Medusa's existing primitives. The variant editor is genuinely novel: cartesian product UX, bulk SKU generation, atomic multi-variant creation with prices across customer groups. Budget extra time. If the implementation plan's PR E breakdown turns out to be wrong on contact with the actual UX problem, expand the plan before pushing through. Don't ship a half-baked editor.
Phase 4 (DCA) is in flight on a separate track¶
D2's Phase 4 work runs concurrently. Phase 5's order detail screen needs to display DCA values where available. Coordinate with D2: agree on the read API shape (GET /admin/orders/:id includes a dca field with unit_cost, landed_cost, etc.) before PR D ships. If Phase 4 hasn't merged yet, dashboard renders DCA values as "—" and the integration is a follow-up doc PR.
Exit criteria¶
Mirrors Phase 5 Plan exit criteria — the implementation plan is "done" when every checkbox there is satisfied. Don't restate them here — the plan doc is canonical.
The implementation plan is "done" in the meta sense once PRs A–G land on tcg-platform/main and the findings doc records the validation outcomes against staging.
References¶
- Phase 5 Kickoff Plan — the design this plan implements
- Phase 3 Implementation Plan — task-level template for PR-A-style detail expansion
- Phase 0 Notes — "Medusa admin is not the operator UI" — original observation behind Path B
- Project Plan — Phase 5 row
tcg-stagingrunbook- Medusa v2 docs — Module Links
- Medusa v2 docs — Admin API routes
- Next.js 16 App Router docs (also note the middleware → proxy migration that landed in 16.0.0)
- Cloudflare Workers — Next.js via OpenNext adapter
- @opennextjs/cloudflare docs