Skip to content

Phase 5: Merchant Ops UI v1 — Kickoff Plan

Status: Complete — architecture locked here, implemented by the Phase 5 PR stack, and closed out in Phase 5 Findings. The dashboard now lives in apps/dashboard/, deploys to Cloudflare Workers via OpenNext, and talks to the Medusa server in apps/server/. Predecessors: Phase 3 Findings (complete), Phase 4 Kickoff (in progress, separate track — D2) Exit criterion (from Project Plan): "Merchant ops staff can process a day of orders in the UI."

This plan turns the Phase 5 objective — give the merchant a usable daily-driver UI — into a concrete frontend architecture, scope, RBAC model, and validation checklist. Phase 5 is the first phase that puts a real human-facing surface on top of the Medusa core; the architectural choices here are durable and shape Phases 7 (POS), 8 (Finance), and beyond.

Note: This plan supersedes the project-plan's earlier "single TypeScript codebase, single deployable" framing for the frontend layer. The Medusa server remains a single TypeScript codebase, but the operator UI ships as a sibling Next.js application in the same monorepo. The rationale is captured in Architectural decision: Path B below. The project-plan should be updated in a follow-up doc PR to reflect this; this plan is the source of truth for the Phase 5 architecture in the meantime.


Goal

Ship apps/dashboard/ — a Next.js 16 operator dashboard (originally scoped as Next.js 15; bumped to 16 at PR C kickoff because create-next-app@latest had moved on — App Router-native, no architectural delta) that the merchant's staff use as their daily driver to process orders, manage inventory, handle exception queues, edit TCG product variants (with the variant-explosion editor), and perform the operational tasks the Medusa admin can't model. Authentication uses the existing Medusa user identity; authorization is driven by a custom user_role table that maps users to a small set of staff roles (admin, ops, finance, event-staff).

When Phase 5 exits, the merchant's ops staff can run an entire shift through the dashboard: triaging connector exceptions from Phase 3, inspecting and editing inventory, working the order list, and managing TCG product variants — without ever opening Medusa admin or a spreadsheet.


Architectural decision: Path B (separate Next.js dashboard)

The Phase 5 design considered two paths:

  • Path A — extend Medusa admin via UI Routes / widgets. Lower setup cost, single deployable, single auth, ships fastest for a small custom surface area.
  • Path B — separate Next.js application that consumes Medusa's Admin/Custom APIs. Higher initial setup (second app, second build, second deploy target) but no customisation ceiling.

Decision: Path B. Locked 2026-05-08 after reviewing the Medusa v2 docs section "What You Can't Customize in the Medusa Admin" (verbatim quote, Medusa docs):

You can't customize the admin dashboard's layout, design, or the content of the existing pages (aside from injecting widgets). You also can't change the Medusa logo used in the admin dashboard. If your use case requires heavy customization of the admin dashboard, you can build a custom admin dashboard using Medusa's Admin API routes.

The merchant's actual constraints — TCG variant explosion (30+ variants per card requiring a custom variant editor), custom branding, RBAC across staff roles, Phase 7 POS that needs a touch-friendly layout, Phase 8 finance dashboards that need bespoke charts — all sit squarely in the "heavy customization" bucket the Medusa docs name. Medusa admin will still be used narrowly for catalog scaffolding (creating products and locations), but the operator UI is a separate app from day one.

This decision is consistent with the Phase 0 observation "Medusa admin is not the operator UI — and it will never be" (see Phase 0 Notes). Path B turns that observation into an architecture.

Trade-off accepted: doubled infra cost (second Next.js build + deploy target), an explicit auth bridge between the two apps, and the loss of "free" Medusa admin pages we'd otherwise inherit. In return, we get unrestricted control over the surface every operator interacts with all day.


Architectural decision: Monorepo layout (apps/dashboard/ sibling to apps/server/)

Decision: monorepo with workspaces. The existing tcg-platform repo restructures from a flat Medusa project to a workspace layout:

tcg-platform/
├── apps/
│   ├── server/              ← existing Medusa app (current `src/`, `medusa-config.ts`, etc.)
│   └── dashboard/           ← new Next.js 16 app (App Router)
├── packages/
│   └── shared-types/        ← TypeScript types shared between server + dashboard
│                              (re-exported domain types from src/modules/, API request/response shapes)
├── package.json             ← workspace root (yarn workspaces or pnpm — TBD with team)
└── turbo.json (or nx.json)  ← optional, only if dev-server orchestration needs it

Why monorepo, not separate repos:

  • One PR can change a Medusa API endpoint and the dashboard call site that consumes it — atomic + reviewable.
  • Shared TypeScript types stay in lockstep — dashboard never falls behind the API contract.
  • One CI pipeline, one deploy story, one place to wire shared lint / format / pre-commit.
  • Single source of truth for env vars and secrets references (Credentials Index entries don't fork).

Why not in-process inside Medusa: Medusa v2 is a Node server; Next.js needs its own dev server, its own build step (next build), its own deploy target. Trying to bolt Next.js into Medusa creates conflicting tsconfigs, build pipelines, and runtime needs. Sibling apps in a monorepo is the conventional shape; the project plan's "single deployable" framing predates the Path B decision and is updated above.

Migration path: moving the Medusa app from repo root to apps/server/ is a one-time mechanical change. The first Phase 5 PR will perform that move + add the empty apps/dashboard/ skeleton; no behavior changes ship in that PR.


Architectural decision: Auth — Medusa user as identity, custom user_role for RBAC

Decision: single user actor type for staff; custom user_role table provides RBAC.

Medusa v2 ships three actor concepts: built-in user (admin / staff), built-in customer (end-buyer), and custom actor types (e.g. a vendor actor for marketplaces). For a single-tenant operations platform, operator staff are exactly the use case Medusa's user actor was designed for — there is no need for a custom actor type, and the Medusa AI bot specifically directed us away from one ("using Medusa's existing user model as your auth source-of-truth is the better approach").

RBAC layer. Medusa's built-in user does not carry a role attribute beyond an implicit "admin". Phase 5 ships a custom user_role table that maps user_id to a small enum of staff roles, queried via Module Link to Medusa's user. The dashboard reads the role on login and gates routes / actions accordingly.

user_role
─────────
id              pk
user_id         fk to Medusa user (1:1 — every staff user has exactly one role)
role            enum: 'admin' | 'ops' | 'finance' | 'event_staff'
assigned_at     timestamptz
assigned_by     fk to Medusa user (the admin who set the role)
INDEX (user_id) UNIQUE

The assigned_by self-reference + an audit log of role changes (separate user_role_audit table — Phase 9 hardening) lets us answer "who promoted whom and when" — a real ops requirement for finance + admin role boundaries.

Why a custom table, not metadata: user.metadata is ungated, has no schema, and can't be referenced cleanly from authorization middleware. A first-class table is queryable, indexable, joinable, and lets us add a row-level audit trail later without retrofitting. The cost (one migration) is trivial.

Why not multiple Medusa actor types per role: that pattern is for genuinely different identity surfaces (vendor portal vs admin), not for permission tiers within the same surface. Splitting staff across actor types would force role-specific login pages and break the "every staff user logs into one dashboard" UX.

Auth flow at runtime:

  1. Operator hits apps/dashboard/, redirected to login if no session.
  2. Login posts to Medusa's existing user-auth endpoint, gets a session token.
  3. Dashboard issues a follow-up request to a new apps/server/ route GET /admin/me that returns { user, role, permissions[] } (role looked up from user_role).
  4. Dashboard caches { user, role } in a server-side session and gates UI / API access accordingly.
  5. Every subsequent dashboard request that hits an authoring API on the server passes the session token; server-side middleware re-checks the role from user_role (no client-trusted role claims).

Scope

In scope

  • Next.js 16 (App Router) skeleton at apps/dashboard/, deployed to Cloudflare Workers via @opennextjs/cloudflare (the original "TBD: Pages, Vercel, or homelab" was resolved during the implementation plan's pre-flight audit — Cloudflare deprecated @cloudflare/next-on-pages 2025-04-08, Workers + OpenNext is the official path).
  • Monorepo restructure: move Medusa to apps/server/, create apps/dashboard/, set up workspace + shared-types package.
  • user_role data model, migration, Medusa Module Link to user, seed of an initial admin role for the founder.
  • New Medusa admin API routes:
  • GET /admin/me — return current user + role + derived permissions.
  • GET /admin/users / POST /admin/users / PATCH /admin/users/:id/role — staff management.
  • Dashboard auth flow: login page, session middleware, role-gated route guards.
  • Order list + detail views. Read from Medusa orders; show Shopee / Lazada (from Phase 6, but UI is generic) source channel, status, line items, fulfillment + shipment + cancellation actions.
  • Exception queue UI (the Phase 3 backlog item). Reads connector_exception table from Phase 3, lets operators retry / mark resolved / annotate.
  • Inventory views. Stock by location, low-stock alerts, stock-transfer initiator (consumes the Medusa stock-location movement APIs validated in Phase 2).
  • TCG variant editor. The 30+ variants-per-card editor that's the headline reason we're on Path B — bulk SKU generation, condition / language / foil / grading axes, bulk variant create + price set in one flow. Replaces the Google Sheets formula chain.
  • Staff management screen (admin role only) — list users, assign / change roles.
  • Branding: ExzenTCG logo, custom theme tokens, bespoke layout.
  • Tests: component (per major screen), integration (auth flow + role gating + order action E2E with Playwright).
  • tcg-platform/phase-5-findings.md — validation findings doc, written during the staging run.
  • homelab/05_service_deployments/tcg-staging.md updated with the dashboard's deploy target + reverse-proxy config.

Out of scope

Out of scope Home phase
POS UI (touch-first cashier interface) Phase 7
Telegram Mini App (buyer-facing) Phase 7 (POS) or later
Event-floor mobile UI Phase 7
Finance dashboards (P&L, cashflow, stakeholder views) Phase 8
DCA cost editor (operator-facing) Phase 4 (D2's track) — Phase 5 just exposes read-only DCA values where they appear in order detail
Lazada-specific connector UI Phase 6 (uses the same generic exception queue scaffolded here)
Customer / buyer self-service portal Out of MVP
Cross-merchant admin tooling Out of MVP — see "Deferred: SaaS & Multi-Tenancy"
SSO / OAuth third-party login Out of MVP — Medusa email/password auth only
Mobile-responsive layout for desktop dashboard Out of MVP — the desktop dashboard targets desktop; POS is the touch-first surface
WCAG full accessibility audit Phase 9 (hardening)

Prerequisites

Two things must land before Phase 5 implementation starts:

  1. Phase 4 (DCA / pricing) data model in place so the dashboard can surface DCA-derived values in order detail screens. Phase 5 won't author DCA values; it will display them.
  2. Decision on dashboard deploy target (Cloudflare Pages vs Vercel vs self-hosted). Affects auth-bridge config (cookies / CORS / session storage) and CI pipeline shape. Implementation plan locks this.

Architecture

Repo + deploy shape

                     ┌──────────────────────────────────────┐
                     │  apps/dashboard/   (Next.js 16)      │
   Operator ───────► │  Auth bridge → Medusa /admin/auth     │
                     │  Server actions / fetch → /admin/*    │
                     └──────────────────┬───────────────────┘
                                        │ HTTPS, session cookie
                                        ▼
                     ┌──────────────────────────────────────┐
                     │  apps/server/    (Medusa v2)          │
                     │  /admin/auth/*   (built-in)           │
                     │  /admin/me, /admin/users (new)        │
                     │  /admin/orders/* (built-in + custom)  │
                     │  /connectors/*   (Phase 3+)           │
                     └──────────────────┬───────────────────┘
                                        │
                                        ▼
                     ┌──────────────────────────────────────┐
                     │  Postgres                             │
                     │  Medusa core tables + custom modules  │
                     │  + new user_role table                │
                     └──────────────────────────────────────┘

Both apps are deployed independently. The dashboard talks to Medusa over HTTPS — no in-process bindings, no Module Links into Next.js, no shared runtime.

Link From To Cardinality
user-role UserRole (custom module) user (Medusa) 1:1

Module lives at apps/server/src/modules/staff/ (new). Service exposes getRoleFor(userId), assignRole(userId, role, assignedBy), listStaff().

Permission model (initial cut)

Capability admin ops finance event_staff
View orders
Cancel / fulfill orders
View inventory
Edit inventory / transfers
Edit product variants (TCG editor)
View / triage exception queue
View finance views (Phase 8)
Manage staff / assign roles

Permissions are derived from role server-side (single source of truth — never trust the client). The dashboard's UI gating is a user-experience nicety; the Medusa server enforces every permission boundary.


Open questions (to resolve before / during implementation)

  1. Workspace tool — yarn workspaces, pnpm, or turbo / nx? Yarn Berry (already in the Medusa starter) supports workspaces natively. Pnpm has nicer hoisting semantics. Turbo / Nx adds task orchestration but might be overkill for two apps. Default: yarn workspaces (least new tooling). Implementation plan revisits if dev-server orchestration becomes painful.
  2. Dashboard deploy target. Cloudflare Pages aligns with the docs site + existing Cloudflare account; Vercel is the canonical Next.js host with first-party server-action support; self-hosted on the homelab is cheapest but adds an ops surface. Default lean: Cloudflare Pages (consistent with docs site; Cloudflare Access can gate the dashboard for free during dev). Locked during implementation plan. (Update 2026-05-09: pre-PR audit found Cloudflare deprecated @cloudflare/next-on-pages on 2025-04-08 in favor of @opennextjs/cloudflare on Cloudflare Workers. The implementation plan locks Workers + OpenNext instead — same Cloudflare account, same Access gating, better runtime env vars. See phase-5-implementation.md → Hard constants → Deploy target.)
  3. Session storage for auth bridge. Medusa issues a session cookie; the dashboard needs to forward it on server-side fetches. Cookie domain shape (*.exzentcg.com vs separate subdomains) affects CORS + cookie scope. Locked during implementation plan once deploy target is chosen.
  4. Variant editor: live vs draft mode? When an operator adds 30 variants for a new card, do those persist as drafts (not yet visible to the storefront) until "publish", or are they live immediately? Default: live immediately (matches existing spreadsheet workflow); revisit if accidental publishing becomes a pain point.
  5. Exception queue retry semantics. Phase 3 specifically deferred auto-retry to Phase 5's exception queue UI. Retry mechanics: re-enqueue the raw event for the subscriber to reprocess (UPDATE shopee_raw_event SET processed=false WHERE id=? + emit a "manually retried" event), or call a retry endpoint that takes parameters? Default: re-enqueue model — keeps the connector's processing path the only path. Phase 5 implementation plan finalizes.
  6. Initial admin seeding. The first user must be assigned role=admin somehow — chicken / egg with the staff-management UI. Default: a one-shot script (yarn medusa exec ./apps/server/src/scripts/seed-admin-role.ts <email>) that promotes the founder's existing Medusa user to admin. Implementation plan finalizes.
  7. TCG editor data shape. Variant explosion is multi-axis (condition × language × foil × grading × set × …). UI shape — table-grid vs cartesian-product wizard vs hybrid? Likely needs a small UX spike during D2's track. Out of scope for this plan; flagged so the implementation plan doesn't underestimate it.

Exit criteria checklist

From the Project Plan Phase 5 row ("Merchant ops staff can process a day of orders in the UI"), expanded:

  • [ ] Monorepo restructure shipped — Medusa lives at apps/server/, dashboard at apps/dashboard/, builds + tests + CI all green from new paths.
  • [ ] user_role migration applies cleanly on staging; staff module registered.
  • [ ] GET /admin/me returns the authenticated user with their role + permissions.
  • [ ] Staff-management screen lets the admin assign / change / remove roles; changes persist.
  • [ ] Login → dashboard → role-gated route reaches the correct screen for admin, ops, finance, event_staff roles (4 manual flows).
  • [ ] Order list reads live Medusa orders (Shopee orders from Phase 3 visible end-to-end through the dashboard, not just psql).
  • [ ] Exception queue UI lists connector_exception rows; operator can retry one, see it re-process, observe the resulting Medusa order.
  • [ ] Inventory view shows correct stock-by-location for the Phase 2 seed catalogue + Shopee deduction from Phase 3 cascade orders.
  • [ ] TCG variant editor creates a 30+ variant card end-to-end (one card, all axes set, prices set, visible in storefront API + Medusa admin afterwards).
  • [ ] Permission negative tests: ops user cannot reach /admin/users; finance user cannot edit inventory; event_staff user cannot view exception queue.
  • [ ] Dashboard deployed to staging at a Cloudflare Access-gated URL (separate from tcg-staging.exzentcg.com admin path).
  • [ ] CI green on Phase 5 PRs (dashboard build + lint + component tests + Playwright E2E + Medusa side build).
  • [ ] tcg-platform/phase-5-findings.md committed with verified scenarios + gaps + open questions.
  • [ ] homelab/05_service_deployments/tcg-staging.md updated with dashboard deploy + reverse-proxy notes; new Proxmox snapshot tagged phase-5-dashboard-live.
  • [ ] Project plan updated to reconcile the "single deployable" framing with the monorepo / two-app shape (separate small doc PR).

Task split (D1 / D2 / D3)

D1 — Lead (most)

  • Monorepo restructure + workspace setup.
  • user_role module + migration + Module Link + seed script.
  • Auth bridge (/admin/me, session middleware, role-gated server-side checks).
  • Architectural decisions during implementation.
  • Phase 5 design + implementation plan + findings doc.

D2 — Mid

  • Order list + detail screens.
  • Inventory views.
  • Exception queue UI.
  • TCG variant editor (the headline screen — likely the largest single piece of D2 work).

D3 — Support (low)

  • Staff management screen.
  • Component tests + Playwright E2E suite.
  • Branding + theme tokens + layout chrome.
  • phase-5-findings.md shell + walkthrough docs.

References