Supplier & Fulfillment

How supplier users access orders and the fulfillment flow

Overview

The Merch system supports per-item fulfillment routing — each order item can be assigned to a different supplier (e.g. a t-shirt to IMAP Japan, a plaque to a US manufacturer). Suppliers are modeled as merch_fulfillment_provider rows in the database.

Supplier users are people who can log in and see orders assigned to their supplier. They are linked by email via the merch_fulfillment_provider_partner table (a mapping of email → provider id).

Key Concept: No Role Required

Supplier access is not based on a Cognito/DynamoDB role. Instead:

  1. An admin links an email to a supplier (via the Supplier Detail page)
  2. That email is stored in merch_fulfillment_provider_partner
  3. When the user logs in, the system checks if their email is in that table
  4. If yes, they are treated as a supplier user and routed to the orders dashboard

This avoids the complexity of managing roles during signup — an email can be linked before the user even has a Zooly account.

Auth & Routing Flow

User visits / (root page)
  → getVerifiedUserInfo() to check session
  → If admin role → redirect to /admin/merch (full dashboard)
  → If email in merch_fulfillment_provider_partner → redirect to /admin/merch/orders
  → Otherwise → redirect to /merch (consumer storefront)

Once on /admin/merch, the merch-admin SPA calls GET /api/merch/admin/me which returns:

{ "role": "supplier", "supplierId": "supplier-id-here", "user": {...} }

The client stores this in AdminAuthContext and:

  • Shows only the /orders route (other routes bounce back)
  • Hides admin-only UI controls (supplier assignment, image upscale, hold/release)
  • Scopes all order queries to the supplier's provider id

Database Tables

TablePurpose
merch_fulfillment_providerSupplier entity (code, display name, kind, driver, active flag)
merch_fulfillment_provider_partnerEmail → provider mapping (who can access this supplier's orders)
merch_item.supplier_idWhich supplier is assigned to fulfill this item (null means unassigned)

API Endpoints

EndpointAuthPurpose
GET /api/merch/admin/meAny authenticated userReturns viewer role (admin/supplier/none) + provider scope
GET /api/merch/admin/ordersAdmin or supplierLists orders; suppliers see only their items
PATCH /api/merch/admin/orders/[id]Admin or supplierUpdate order; suppliers limited to fulfillmentStatus and adminNotes
POST /fulfillment-providers/[id]/partnersAdmin onlyLink an email to a supplier
DELETE /fulfillment-providers/[id]/partners/[id]Admin onlyUnlink an email from a supplier

Access Control (requireSupplierScope)

The requireSupplierScope function in @zooly/merch-admin-srv gates all order endpoints:

  • Verifies the session cookie
  • If the user has the admin role → returns { user, providerId: null } (no scope filter)
  • If the user's email is in merch_fulfillment_provider_partner → returns { user, providerId } (scoped)
  • Otherwise → throws 403

All order queries filter by providerId when non-null, ensuring suppliers only see their own items.

Supplier User Lifecycle

  1. Admin links email → row created in merch_fulfillment_provider_partner
  2. Invite email sent → user receives a link to sign up / log in
  3. User signs up or logs in → normal Cognito auth flow (no special role needed)
  4. User lands on / → root page detects supplier status, redirects to /admin/merch/orders
  5. User views orders → only sees orders containing items assigned to their supplier
  6. Admin unlinks email → row deleted; user loses access on next page load

Packages Involved

PackageFileResponsibility
@zooly/dbaccess/merch/merch-fulfillment-provider-partner.tsDB access: link/unlink/list/lookup supplier users
@zooly/merch-admin-srvrequire-admin.tsrequireSupplierScope() auth gate
@zooly/merch-admin-clientcontext/admin-auth-context.tsxClient-side role/scope state
@zooly/merch-admin-clientcomponents/layout/admin-layout.tsxRoute gating (suppliers → /orders only)
@zooly/merch-fulfillment-srvsend-email.tssendSupplierLinkedEmail(), sendOrderAssignedToSupplierEmail()