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—_queryPurchaseOrdersresolves the session-selected factory (getSelectedFactoryId()→FactoryService.findUnique) and readsFactoryConfigurationService.isHidePOTotalCostEnabled(factory.factoryCode). When true it nullsr.totalCostand stamps a per-record flagr.isHidePOTotalCost = true(typePurchaseOrderWithIsHidePOTotalCost). ⚠️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:144filters thetotalCostcolumn out ofvisiblePropertieswhenrecord.params.isHidePOTotalCost;src/components/common/RecordsTableHeader/RecordsTableHeader.tsx:103hides thetotalCostheader offrecords[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 fromcontext.currentAdmin?.emailand threaded into_queryPurchaseOrders, which checks it via the exportedisVn1User(email)predicate insrc/routers/admin/utils.ts. Following the module convention, the underlyingvn1UserEmailliteral stays module-private (likebgusUserEmail/opsEmail) and is consumed only through the predicate (and reused inside thefactoryEmailsarray) — 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 viaConfigValueInput). Model readerisHidePOTotalCostEnabled(factoryCode, tx?)returnsfalseforfactoryCode = nulland parses viaparseBooleanConfigValue. 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}declarelistProperties,filterProperties, and apropertiesmap. TheFieldstype is a Prisma<Model>ScalarFieldEnumunion-extended with virtual field names (e.g. shipment:CustomerShipmentScalarFieldEnum | 'poRecName' | 'poCreateDate' | 'estimatedShipDate'). - Virtual (relation-derived) columns:
poRecName/poCreateDateare not columns onCustomerShipment— they're declared as virtual properties (with explicittype+isVisible, no DB backing) and populated from the relatedPurchaseOrderat request time. WorkOrder haspoRecNameas a real column but pullspoCreateDatefrom 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
listActionHandlermaps fields offinclude: { purchaseOrder: true }(e.g.poRecName: dbRecord.purchaseOrder?.poRecName), and a separateafterhooklistActionFindRelationsAfterre-queries POs by id and setsrecord.params.poRecName/poCreateDate(runs last, authoritative). WorkOrder's handler usesinclude: { purchaseOrder: { select: { poRecName, poCreateDate } } }+ its ownafterrelations hook. When adding a new PO-derived column, add it to theselect/includeand the param-mapping in whichever path sets the sibling fields. - Filtering is a manual
switchin each handler'smakeWhereCondition(filter)(PO's lives inline inhandlers/listActionHandler.ts). Each filter key needs an explicitcase; unmatched filter keys are silently ignored (no pass-through default). Relation filters translate to a nestedwhere: e.g.case 'poCreateDate': where.purchaseOrder ??= {}; where.purchaseOrder.poCreateDate = …. A column filter on the model itself is a directwhere[key] = …. ⚠️ WorkOrder caveat:WorkOrder.poIdis nullable, so anywhere.purchaseOrder.<field>filter excludes WOs withpoId 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 (seelabelStatus,workOrderStatus) and AdminJS renders a native<select>; the empty/cleared state is the implicit "All". Existing enums use raw Englishlabel: statusvalues (option labels are not localized). Date filters useComponents.DatetimeFilter; bespoke selects useComponents.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 bothlabelsandproperties. Column headers live underproperties.<propertyName>(e.g.properties.category= "Category" / "类别", already added by INFRA-524). Enum option-value translation maps live underlabels.<Name>as nested objects (e.g.labels.ShipmentLabelStatus.{Acknowledged,…},labels.agingDayType.*). Because they're different namespaces,properties.category(header) and alabels.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 usesuseTranslation().translateLabel('<Name>.<value>')(resolves underlabels.*) for option text anduseCurrentAdmin()?.locale?.startsWith('zh')to branch behavior per Chinese vs English user, rendering an@adminjs/design-system<Select isClearable>(the cleared state = "All"). Mount viaproperties.<field>.components.filter. A property component also receives awhereprop ('list' | 'filter' | 'show' | 'edit'), so one component registered on bothcomponents.listandcomponents.filtercan render a localized cell in list mode and the Select in filter mode. This is the pattern to use instead of staticavailableValueswhenever 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:
emailsNotAuthreturns!blockedDeleteEmails.includes(email)— used as a delete-blocker for BGC. Note: BGC is also blocked from delete implicitly because delete actions commonly useadminRoleAuth/adminEditorRoleAuthand BGC is neither admin nor editor. getRolefalls back toenv.DEV_ROLEwhenNODE_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).