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 — 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.
Read-only list resource: WorkOrderPdfExportJob (INFRA-655)
src/routers/admin/resources/workOrderPdfExportJob/workOrderPdfExportJob.ts is a read-only, admin-only resource
under System Administration (not the Order nav). It deliberately differs from the three order resources above:
- It uses AdminJS's default list query — no custom
listActionHandler.new/edit/delete/bulkDeleteareisAccessible: false;list/showuseadminRoleAuth; default sortcreatedAt desc. - The virtual
poRecNamecolumn is injected purely by anafterhook (listActionFindPoRecNameAfter) that batch-queries the page'spoIds and writesrecord.params.poRecName— same shape asshipment.ts'slistActionFindRelationsAfter, but with no matchinglistActionHandlerdoing an initialinclude. ThepoIdcolumn stays as the raw FK id (noincludefrom@adminjs/prisma). - ⚠️
WorkOrderPdfExportJobScalarFieldEnumis not exported by the generated Prisma client (the<Model>ScalarFieldEnumtypes only surface through each model's owntypes.d.ts, which this model lacks). So itslistPropertiesis a plainstring[]— don't reach for aFields = keyof typeof …ScalarFieldEnumunion here, unlike the order resources. - It is not in
selectedFactoryExtension, so the list is cross-factory (acceptable — admin-only). But the after hook's PO lookup does go through that extension, sopoRecNamerenders empty when the admin'sselectedFactoryIddiffers from the job's PO factory (same accepted behaviour as the shipment list).
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',vn1UserEmail = 'vn1@birdygrey.com',blockedDeleteEmails = [bgcUserEmail]. (enigmaUserEmailandfactoryEmailswere removed in INFRA-666 together with their only consumer,adminBgcFactoryOpsAuth.) - 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(admin/ops/bgc — now only used by the Factory resource). (adminBgcFactoryOpsAuthwas removed in INFRA-666 — the Canceled Shipment and Work Order Shipment Latency resources now gate on the role-onlyadminEditorViewerRoleAuth.adminBgcAuth/bgcAuth/adminEditorOrBgcBgusAuthwere removed in INFRA-672 — Color was their last caller; see the Color field-filtering section below.) - 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).
Color resource role-based field filtering (INFRA-672)
The Color resource is the first to filter which fields a non-admin role receives on the list/show views (as opposed to just gating whole actions). It is admin+editor only (list/show/edit = adminEditorRoleAuth, new/delete = adminRoleAuth) — no viewer, no email constants (INFRA-665 direction). Editor sees the 3-column working set (colorCode/colorNameEn/colorNameCn); admin sees every field.
Field filtering is a two-gate mechanism, and both are required:
- Server-side params stripping (authoritative) —
src/routers/admin/resources/color/colorFields.tsexportsEDITOR_VISIBLE_FIELDS+stripToEditorVisibleFields(params)(the admin-only listADMIN_ONLY_FIELDSlives incolor.ts, notcolorFields.ts). The customlistActionHandler(src/routers/admin/resources/color/listActionHandler.ts) and theshowaction'safterhook both delete admin-only keys fromrecord.paramsfor non-admin roles. This is required becauseBaseRecord.toJSON()ignores property-levelisVisible— the same lesson asstripSensitiveParamsAfterHook(src/routers/admin/resources/user/utils.ts). Theeditbeforehook also sanitizes the payload down to['colorNameCn']for editors, and theafterhook injects__canEditProperties = ['colorNameCn'](consumed by the overriddenDefaultEditAction, which disables every property not in that list). - UI column hiding (presentational) — the admin-only fields carry
custom.showByRoleEmails: [{ type: 'role', val: 'admin' }]andcustom.editByRoleEmails: [{ type: 'role', val: 'admin' }](set byadminOnlyProperty()incolor.ts— not the legacycustom.role === 'admin'flag). WithjudgePropertiesAccessByRoleEmails: trueon thelist/show/editactions,filterListPropertiesByRole(inRecordsTableHeader/RecordInList) hides the list columns andfilterShowEditPropertiesByRoleAccess(inDefaultShowAction/DefaultEditAction) hides/disables the show/edit fields. Gate #1 keeps the data off the wire; gate #2 keeps the columns/fields off the screen.
The list query itself is single-sourced in ColorService.queryColorList (shared by the list handler and the Excel export); its pg_trgm search branch threads sortBy/direction into the raw ORDER BY behind a column allowlist (SORT_COLUMN_SQL in src/models/color/color.model.ts) — see style-and-measurements.md § "Color Chinese-Name XLSX Round Trip" for the export/import half.
The two role-filtering helpers were consolidated in INFRA-672. Before it, list-column hiding (filterPropertiesByRole, living in the misspelled src/components/utils/filterPropertiesByRol.ts) and show/edit field hiding (filterPropertiesByRoleAccess, living in src/components/common/utils.ts) were two separate functions in two files. Both were renamed and moved into src/components/utils/filterPropertiesByRole.ts:
filterListPropertiesByRole(listAction, properties, currentAdmin)— gained alistActionfirst arg so it can also honorshowByRoleEmailswhen the list action setsjudgePropertiesAccessByRoleEmails: true(previously it only understood the legacycustom.role === 'admin'flag, which it still honors first).filterShowEditPropertiesByRoleAccess({ properties, action, currentAdmin })— same logic as the oldfilterPropertiesByRoleAccess, just renamed.
src/components/common/utils.ts now only exports addDaysIsoUtc. The old filterPropertiesByRol.ts (typo filename) is deleted.
Style list imagesCount hiding was migrated to the same mechanism (INFRA-672). It used a per-record flag — the list after hook set record.params.isHideImagesCount = true for non-admin, and RecordInList/RecordsTableHeader dropped the imagesCount column off that flag. That flag is gone: the list after hook still nulls imagesCount for non-admin (server side), and the imagesCount property now carries custom.showByRoleEmails: [{ type: 'role', val: 'admin' }] with judgePropertiesAccessByRoleEmails: true on the list action, so filterListPropertiesByRole hides the column for non-admin. This contrasts with the PO isHidePOTotalCost flag (§ above), which stays flag-based because it depends on factory config + the VN1 user, not just role — a pure role split can use showByRoleEmails instead of a per-record flag.
User Category (User.category / UserCategory) — removed in INFRA-663
User.category (UserCategory? @default(Internal), enum UserCategory {Internal, Factory}) was introduced by
INFRA-646 as data-only groundwork and removed in INFRA-663 together with User.role and User.factoryId. A
reader never landed, so dropping the field + enum (migration 20260909110132_simplify_user_model) was safe.
- ⚠️ The older
20260903031452_add_user_categorymigration also droppedGarmentMeasurement.size(ALTER TABLE "public"."GarmentMeasurement" DROP COLUMN "size") — an unrelated, pre-existing schema drift folded into that migration. The "Warnings" header flags the data loss. If that column is ever needed again, it has to be recreated in a fresh migration, not restored from this one.
User language → AdminJS availableLanguages (INFRA-663)
The AdminJS locale-switcher list is now driven by the user's own User.language field, not role/email.
getCurrentAvailableLanguages (src/routers/admin/utils.ts:168) maps the enum to the list:
User.language | availableLanguages |
|---|---|
English | ['en'] |
Chinese | ['zh-CN'] |
EnglishChinese | ['en', 'zh-CN'] |
| (none) | ['en'] — fallback for pre-change sessions |
- The
Languageenum gainedEnglishChinese(prisma/schema.prisma:21-25). Migration20260909110132_simplify_user_modelbackfills existing rows:role = 'admin'OR email in the old bilingual allowlist (bgus/ops/enigma) →EnglishChinese, everyone else →English(the default). - The raw enum value rides the session:
auth.provider.ts:45storeslanguage: user.languageonadminUser(typedAdminUser.language?: Languageatsrc/types/extendAdminJS.d.ts:156).admin.router.ts:211feeds it to AdminJS'slocale.availableLanguages. Sessions minted before this change carry nolanguageand fall back to['en']until next login. getRole/judgeRolesHasAuthand every email predicate above are independent of this — the effective role still comes fromUserFactoryAccess.accessLevel, not the removedUser.role.
List Loading UX Infrastructure (INFRA-543) — local copies of AdminJS internals
The list view's loading mask / stale-record cleanup / error-retry behavior required changing useRecords and buildActionClickHandler, which live in node_modules/adminjs and can't be patched. The pattern is copy the upstream source into our codebase and adapt it (same precedent as the DefaultListAction / RecordInList component overrides). Each copied file has a header comment naming the upstream path + version — keep the copies otherwise faithful so AdminJS upgrades diff cleanly.
Three pieces:
src/components/common/DefaultListAction/useListRecords.ts— adapted copy of AdminJSuseRecords. Adaptations:- Stale-record cleanup (
useListRecords.ts:114-119): whenresourceIdchanges,records/page/totalare cleared before the new fetch, so the old resource's rows never render under the loading mask. Same-resource refetches (pagination/sort/filter) keep rows visible under the mask. errorstate (useListRecords.ts:94-98for rejections, and:90-92for HTTP-200 responses whosenotice.type === 'error'— the customlistActionHandlers report failures that way, so a 200 does not imply success).- Upstream bug fix:
setLoading(false)in the catch branch (upstream leavesloadingtrue forever on failure → permanent mask). hasForceRefresh/removeForceRefresh/REFRESH_KEYare re-implemented locally — they live inadminjs/src/frontend/components/actions/utils/append-force-refresh.ts, which is not part of the publicadminjspackage exports.
- Stale-record cleanup (
src/components/common/RecordInList/buildActionClickHandler.ts— adapted copy of the AdminJS click handler. The only change: the in-placecallApitrigger (actions withcomponent: false) toggles the sharedlistLoadingStorefor the duration of the API call (buildActionClickHandler.ts:58-62). Navigation actions never touch the store; guarded actions are gated naturally because the store is only toggled when the modal'sconfirmActionfires. Usespromise.then(clear, clear)rather than.finally()—finallywould create a second rejected-promise chain (unhandled rejection) since the caller keeps the original promise.src/components/hooks/useListLoading.ts— module-level store +useSyncExternalStorehook, mirroringmesRecordsStore(useMesRecords.ts). It decouplesRecordInList(producer — a record action is in flight) fromDefaultListAction(consumer — show the mask) without prop drilling through AdminJS internals.
DefaultListAction.tsx wires it together: mask = loading || isListLoading (DefaultListAction.tsx:58); wrapper + overlay are plain divs with Tailwind (relative / absolute inset-0 z-10 ... bg-white/60, :63-83) — a deliberate INFRA-543 decision to not use <Box/> here; on error the table is replaced by a tm('failedToLoadRecords') message + retry Button (:85-92, locale keys messages.failedToLoadRecords / buttons.retry); an unmount/resource-change effect resets the store (:54-56) so a mask triggered by a record action can't leak onto another page.
Testing gotchas in this area:
- New
*.spec.tsxfiles carry// @vitest-environment jsdomon line 1 —environmentMatchGlobswas removed in Vitest 3 (config still had it, silently doing nothing), so per-file pragmas are how tsx specs get jsdom. RecordInList.spec.tsxmocks./buildActionClickHandler(the local module), notbuildActionClickHandlerfrom'adminjs'— the component no longer imports the package one, so asserting on the package mock silently never fires.vite-tsconfig-pathsdoes not resolvebaseUrl: "src"aliases (e.g.utils/dbErrorUtilsin thesrc/models/mock/index.tssetup file) under the jsdom/web pipeline — if tsx specs fail with "Failed to resolve import", that's the cause, not a missing file.