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 — 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.

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/bulkDelete are isAccessible: false; list/show use adminRoleAuth; default sort createdAt desc.
  • The virtual poRecName column is injected purely by an after hook (listActionFindPoRecNameAfter) that batch-queries the page's poIds and writes record.params.poRecName — same shape as shipment.ts's listActionFindRelationsAfter, but with no matching listActionHandler doing an initial include. The poId column stays as the raw FK id (no include from @adminjs/prisma).
  • ⚠️ WorkOrderPdfExportJobScalarFieldEnum is not exported by the generated Prisma client (the <Model>ScalarFieldEnum types only surface through each model's own types.d.ts, which this model lacks). So its listProperties is a plain string[] — don't reach for a Fields = keyof typeof …ScalarFieldEnum union 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, so poRecName renders empty when the admin's selectedFactoryId differs 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]. (enigmaUserEmail and factoryEmails were 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). (adminBgcFactoryOpsAuth was removed in INFRA-666 — the Canceled Shipment and Work Order Shipment Latency resources now gate on the role-only adminEditorViewerRoleAuth. adminBgcAuth / bgcAuth / adminEditorOrBgcBgusAuth were removed in INFRA-672 — Color was their last caller; see the Color field-filtering section below.)
  • 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).

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:

  1. Server-side params stripping (authoritative) — src/routers/admin/resources/color/colorFields.ts exports EDITOR_VISIBLE_FIELDS + stripToEditorVisibleFields(params) (the admin-only list ADMIN_ONLY_FIELDS lives in color.ts, not colorFields.ts). The custom listActionHandler (src/routers/admin/resources/color/listActionHandler.ts) and the show action's after hook both delete admin-only keys from record.params for non-admin roles. This is required because BaseRecord.toJSON() ignores property-level isVisible — the same lesson as stripSensitiveParamsAfterHook (src/routers/admin/resources/user/utils.ts). The edit before hook also sanitizes the payload down to ['colorNameCn'] for editors, and the after hook injects __canEditProperties = ['colorNameCn'] (consumed by the overridden DefaultEditAction, which disables every property not in that list).
  2. UI column hiding (presentational) — the admin-only fields carry custom.showByRoleEmails: [{ type: 'role', val: 'admin' }] and custom.editByRoleEmails: [{ type: 'role', val: 'admin' }] (set by adminOnlyProperty() in color.ts — not the legacy custom.role === 'admin' flag). With judgePropertiesAccessByRoleEmails: true on the list/show/edit actions, filterListPropertiesByRole (in RecordsTableHeader / RecordInList) hides the list columns and filterShowEditPropertiesByRoleAccess (in DefaultShowAction/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 a listAction first arg so it can also honor showByRoleEmails when the list action sets judgePropertiesAccessByRoleEmails: true (previously it only understood the legacy custom.role === 'admin' flag, which it still honors first).
  • filterShowEditPropertiesByRoleAccess({ properties, action, currentAdmin }) — same logic as the old filterPropertiesByRoleAccess, 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_category migration also dropped GarmentMeasurement.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.languageavailableLanguages
English['en']
Chinese['zh-CN']
EnglishChinese['en', 'zh-CN']
(none)['en'] — fallback for pre-change sessions
  • The Language enum gained EnglishChinese (prisma/schema.prisma:21-25). Migration 20260909110132_simplify_user_model backfills 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:45 stores language: user.language on adminUser (typed AdminUser.language?: Language at src/types/extendAdminJS.d.ts:156). admin.router.ts:211 feeds it to AdminJS's locale.availableLanguages. Sessions minted before this change carry no language and fall back to ['en'] until next login.
  • getRole/judgeRolesHasAuth and every email predicate above are independent of this — the effective role still comes from UserFactoryAccess.accessLevel, not the removed User.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 AdminJS useRecords. Adaptations:
    • Stale-record cleanup (useListRecords.ts:114-119): when resourceId changes, records/page/total are 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.
    • error state (useListRecords.ts:94-98 for rejections, and :90-92 for HTTP-200 responses whose notice.type === 'error' — the custom listActionHandlers report failures that way, so a 200 does not imply success).
    • Upstream bug fix: setLoading(false) in the catch branch (upstream leaves loading true forever on failure → permanent mask).
    • hasForceRefresh/removeForceRefresh/REFRESH_KEY are re-implemented locally — they live in adminjs/src/frontend/components/actions/utils/append-force-refresh.ts, which is not part of the public adminjs package exports.
  • src/components/common/RecordInList/buildActionClickHandler.ts — adapted copy of the AdminJS click handler. The only change: the in-place callApi trigger (actions with component: false) toggles the shared listLoadingStore for 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's confirmAction fires. Uses promise.then(clear, clear) rather than .finally() — finally would create a second rejected-promise chain (unhandled rejection) since the caller keeps the original promise.
  • src/components/hooks/useListLoading.ts — module-level store + useSyncExternalStore hook, mirroring mesRecordsStore (useMesRecords.ts). It decouples RecordInList (producer — a record action is in flight) from DefaultListAction (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.tsx files carry // @vitest-environment jsdom on line 1 — environmentMatchGlobs was removed in Vitest 3 (config still had it, silently doing nothing), so per-file pragmas are how tsx specs get jsdom.
  • RecordInList.spec.tsx mocks ./buildActionClickHandler (the local module), not buildActionClickHandler from 'adminjs' — the component no longer imports the package one, so asserting on the package mock silently never fires.
  • vite-tsconfig-paths does not resolve baseUrl: "src" aliases (e.g. utils/dbErrorUtils in the src/models/mock/index.ts setup file) under the jsdom/web pipeline — if tsx specs fail with "Failed to resolve import", that's the cause, not a missing file.