Skip to main content

Admin Order Lists & Auth Predicates

PO/Shipment/WorkOrder list columns & filters, AdminJS auth predicates, PO total-cost hiding.

Deep-dive doc split out of .claude/rules/architecture.md (which is now an index). Append new findings about this area here, not to the index.

PO Total Cost Hiding (hide-po-total-cost)

INFRA-544 added the ability to hide PurchaseOrder.totalCost from the Purchase Order list view (not show/edit — those handlers never touch it). The mechanism:

  • Backend (only place cost is hidden): src/routers/admin/resources/purchaseOrder/handlers/listActionHandler.ts_queryPurchaseOrders resolves the session-selected factory (getSelectedFactoryId()FactoryService.findUnique) and reads FactoryConfigurationService.isHidePOTotalCostEnabled(factory.factoryCode). When true it nulls r.totalCost and stamps a per-record flag r.isHidePOTotalCost = true (type PurchaseOrderWithIsHidePOTotalCost). ⚠️ if (!factory) return { dbRecords: [] } — a missing/unresolved selected factory returns zero POs (INFRA-544 behavior).
  • Frontend reads the per-record flag, not the config: src/components/common/RecordInList/RecordInList.tsx:144 filters the totalCost column out of visibleProperties when record.params.isHidePOTotalCost; src/components/common/RecordsTableHeader/RecordsTableHeader.tsx:103 hides the totalCost header off records[0].params.isHidePOTotalCost. So the backend flag is the single switch — no config lookup on the client.
  • INFRA-552 gate: hiding must additionally require the logged-in user be the dedicated VN1 factory user (vn1@birdygrey.com) — the factory-only check hid cost for all users in VN1 (incl. admins). The user email is read from context.currentAdmin?.email and threaded into _queryPurchaseOrders, which checks it via the exported isVn1User(email) predicate in src/routers/admin/utils.ts. Following the module convention, the underlying vn1UserEmail literal stays module-private (like bgusUserEmail/opsEmail) and is consumed only through the predicate (and reused inside the factoryEmails array) — callers never import the raw string.
  • The config key is registered in FACTORY_CONFIG_KEYS.HIDE_PO_TOTAL_COST + BOOLEAN_FACTORY_CONFIG_KEYS (so it renders as a boolean dropdown via ConfigValueInput). Model reader isHidePOTotalCostEnabled(factoryCode, tx?) returns false for factoryCode = null and parses via parseBooleanConfigValue. Spec: factoryConfiguration.model.spec.ts:344.

Order List Resources (PurchaseOrder / CustomerShipment / WorkOrder) — columns + filters

The three "order" list views share a layout but each has its own custom listActionHandler that hand-builds the Prisma query (they do not use AdminJS's default list query). The shape is consistent across all three:

  • Resource configs: src/routers/admin/resources/{purchaseOrder/purchaseorder.ts, shipment/shipment.ts, workorder/workorder.ts} declare listProperties, filterProperties, and a properties map. The Fields type is a Prisma <Model>ScalarFieldEnum union-extended with virtual field names (e.g. shipment: CustomerShipmentScalarFieldEnum | 'poRecName' | 'poCreateDate' | 'estimatedShipDate').
  • Virtual (relation-derived) columns: poRecName / poCreateDate are not columns on CustomerShipment — they're declared as virtual properties (with explicit type + isVisible, no DB backing) and populated from the related PurchaseOrder at request time. WorkOrder has poRecName as a real column but pulls poCreateDate from the relation the same way. This is the canonical way to surface a PO field on the shipment/WO lists without denormalizing it.
  • Two places PO data is attached (shipment): the listActionHandler maps fields off include: { purchaseOrder: true } (e.g. poRecName: dbRecord.purchaseOrder?.poRecName), and a separate after hook listActionFindRelationsAfter re-queries POs by id and sets record.params.poRecName/poCreateDate (runs last, authoritative). WorkOrder's handler uses include: { purchaseOrder: { select: { poRecName, poCreateDate } } } + its own after relations hook. When adding a new PO-derived column, add it to the select/include and the param-mapping in whichever path sets the sibling fields.
  • Filtering is a manual switch in each handler's makeWhereCondition(filter) (PO's lives inline in handlers/listActionHandler.ts). Each filter key needs an explicit case; unmatched filter keys are silently ignored (no pass-through default). Relation filters translate to a nested where: e.g. case 'poCreateDate': where.purchaseOrder ??= {}; where.purchaseOrder.poCreateDate = …. A column filter on the model itself is a direct where[key] = …. ⚠️ WorkOrder caveat: WorkOrder.poId is nullable, so any where.purchaseOrder.<field> filter excludes WOs with poId IS NULL (Prisma to-one relation filter requires the relation to exist) — fine for "All" (no filter) but a real exclusion under any value-specific filter.
  • Enum dropdown filters need no custom component: set availableValues: [{ value, label }] on the property (see labelStatus, workOrderStatus) and AdminJS renders a native <select>; the empty/cleared state is the implicit "All". Existing enums use raw English label: status values (option labels are not localized). Date filters use Components.DatetimeFilter; bespoke selects use Components.ShipmentSelect / ShipmentStatusSelect. Relation-field sorting routes through { purchaseOrder: { <field>: dir } } in the handler's order-by builder.
  • Locales (src/locales/{en,zh_CN}.json): top-level namespaces include both labels and properties. Column headers live under properties.<propertyName> (e.g. properties.category = "Category" / "类别", already added by INFRA-524). Enum option-value translation maps live under labels.<Name> as nested objects (e.g. labels.ShipmentLabelStatus.{Acknowledged,…}, labels.agingDayType.*). Because they're different namespaces, properties.category (header) and a labels.category (option map) can coexist without collision.
  • Localized enum dropdown filter (when option labels must differ by locale, not just raw English): build a custom filter component like src/components/properties/ShipmentStatusSelect/ShipmentStatusSelect.tsx — it uses useTranslation().translateLabel('<Name>.<value>') (resolves under labels.*) for option text and useCurrentAdmin()?.locale?.startsWith('zh') to branch behavior per Chinese vs English user, rendering an @adminjs/design-system <Select isClearable> (the cleared state = "All"). Mount via properties.<field>.components.filter. A property component also receives a where prop ('list' | 'filter' | 'show' | 'edit'), so one component registered on both components.list and components.filter can render a localized cell in list mode and the Select in filter mode. This is the pattern to use instead of static availableValues whenever the displayed cell or options need translation.

AdminJS Action Auth Predicates (src/routers/admin/utils.ts)

AdminJS resources gate each action via isVisible / isAccessible functions that take an ActionContext and return a boolean. These predicates live in src/routers/admin/utils.ts and mix role checks (getRole(currentAdmin)'admin' | 'editor' | 'viewer' | 'qc') with named-email checks:

  • Named-email constants (module-private): bgusUserEmail = 'bgus@birdygrey.com', bgcUserEmail = 'birdygreychina@birdygrey.com', opsEmail = 'ops@birdygrey.com', factoryEmails = [aolong|milly|vn1 @birdygrey.com], blockedDeleteEmails = [bgcUserEmail].
  • Role-only predicates: adminRoleAuth (admin), adminEditorRoleAuth (admin+editor), adminEditorViewerRoleAuth.
  • Role+email predicates: adminEditorOrBgusAuth (admin/editor/bgus — used by both the Measurement and Style resources, so don't change its semantics for one without checking the other), adminOrBgxAuth (admin/bgus/bgc), adminOrOpsOrBgcAuth, adminBgcFactoryOpsAuth (admin/bgc/ops/factory — gates the latency report).
  • Blocklist predicate: emailsNotAuth returns !blockedDeleteEmails.includes(email) — used as a delete-blocker for BGC. Note: BGC is also blocked from delete implicitly because delete actions commonly use adminRoleAuth/adminEditorRoleAuth and BGC is neither admin nor editor.
  • getRole falls back to env.DEV_ROLE when NODE_ENV === 'development' and no role is on the admin — keep this in mind in tests.

When a resource needs a new role/email combination, add a new predicate rather than widening an existing shared one (the existing ones are reused across resources).