From 5188ea5a56ae5fbc1604a5027d14ba7e3bc0a114 Mon Sep 17 00:00:00 2001 From: Carlos Virreira Date: Thu, 23 Jul 2026 14:12:34 +0200 Subject: [PATCH 1/4] perf(webapp): slim core loader payloads to cut route p95s Profiled with a V8 sampling profiler and pg_stat_statements against a seeded local rig (5k assets, 400 bookings). The dominant costs were loader payloads fetching data the routes never render: - calendar: getBookings included the full bookingAssets subtree (~1,775 asset rows plus QR/kit pivots per request) for events that render scalars only. getBookings gains includeAssets (default true); the calendar opts out, skips its unused count, slims custodian/creator selects, and caps the window fetch at 1,000 rows (warn-logged when hit). - home: all four getBookings calls opt out of assets; location distribution is groupBy-first on AssetLocation; the checklist query joins the loader's Promise.all and drops a duplicate custody count. - assets index: the advanced-index CTE paginates before ranking on the default path and materializes only active sort-key columns. Page output verified identical (md5 of page ids, two sorts, two pages). - bookings index: the row-expansion drawer payload (~99% never opened) moves to a new authenticated resource route fetched on open, with an in-drawer error/retry state and shouldRevalidate false so closed drawers skip action revalidation. The /bookings document shrinks from 343KB to 108KB. Measured on the rig at 10 rps sustained per route: calendar p95 1473ms -> 29ms, home 764ms -> 45ms, bookings index 1200ms -> 137ms; every core route now holds p95 under 300ms at 10 and 15 rps sustained load. --- .../booking/booking-assets-sidebar.tsx | 585 ++++++++++-------- apps/webapp/app/modules/asset/query.server.ts | 108 +++- apps/webapp/app/modules/booking/constants.ts | 93 +++ .../app/modules/booking/service.server.ts | 143 ++--- .../app/routes/_layout+/bookings._index.tsx | 287 +++------ apps/webapp/app/routes/_layout+/home.tsx | 72 ++- .../bookings.$bookingId.assets-sidebar.ts | 207 +++++++ apps/webapp/app/utils/dashboard.server.ts | 70 +-- .../booking/booking-assets-sidebar.test.tsx | 364 +++++++++++ 9 files changed, 1308 insertions(+), 621 deletions(-) create mode 100644 apps/webapp/app/routes/api+/bookings.$bookingId.assets-sidebar.ts create mode 100644 apps/webapp/test/components/booking/booking-assets-sidebar.test.tsx diff --git a/apps/webapp/app/components/booking/booking-assets-sidebar.tsx b/apps/webapp/app/components/booking/booking-assets-sidebar.tsx index 0012fd8a58..3818329647 100644 --- a/apps/webapp/app/components/booking/booking-assets-sidebar.tsx +++ b/apps/webapp/app/components/booking/booking-assets-sidebar.tsx @@ -5,6 +5,17 @@ * booking (kits + individual assets) along with qty-progress indicators * for partial check-ins. * + * Data source is dual-mode: + * - **Eager** — callers whose loader ships `booking.bookingAssets` + * inline (the child bookings pages: `assets.$assetId.bookings`, + * `me.bookings`, ...) render it directly, exactly as before. + * - **Lazy** — the bookings INDEX ships only `_count.bookingAssets` + * (perf: the pivots were ~99% dead payload for users who never + * expand a row). On open, the sheet fetches the full payload — same + * `BOOKINGS_LIST_ASSETS_INCLUDE` shape — plus the qty-progress maps + * from `/api/bookings/:bookingId/assets-sidebar` via a fetcher + * (mirrors the on-open fetch in `booking-overview-pdf.tsx`). + * * Renders an "Unassigned model reservations" section (Book-by-Model) * above the asset list whenever the booking has outstanding * `BookingModelRequest` rows (quantity > 0). The `booking.modelRequests` @@ -12,14 +23,15 @@ * Prisma shape (see `_layout+/bookings._index.tsx`) keep working — the * section just renders nothing when the field is absent. * - * @see {@link file://./../../modules/booking/constants.ts} BOOKING_WITH_ASSETS_INCLUDE + * @see {@link file://./../../modules/booking/constants.ts} BOOKINGS_LIST_ASSETS_INCLUDE + * @see {@link file://./../../routes/api+/bookings.$bookingId.assets-sidebar.ts} * @see {@link file://./../../modules/booking-model-request/service.server.ts} */ import React, { useState } from "react"; import type { ReactNode } from "react"; -import type { BookingStatus, Prisma } from "@prisma/client"; +import type { BookingStatus, ConsumptionType, Prisma } from "@prisma/client"; import { ChevronDownIcon, PackageIcon } from "lucide-react"; -import { Link } from "react-router"; +import { Link, useFetcher } from "react-router"; import { Button } from "~/components/shared/button"; import { Sheet, @@ -28,6 +40,7 @@ import { SheetHeader, SheetTitle, } from "~/components/shared/sheet"; +import { Spinner } from "~/components/shared/spinner"; import { Tooltip, TooltipContent, @@ -37,6 +50,7 @@ import { import { useCurrentOrganization } from "~/hooks/use-current-organization"; import { isQuantityTracked } from "~/modules/asset/utils"; import { resolveDisplayCode } from "~/modules/barcode/display"; +import type { loader as assetsSidebarLoader } from "~/routes/api+/bookings.$bookingId.assets-sidebar"; import { BADGE_COLORS } from "~/utils/badge-colors"; import { tw } from "~/utils/tw"; import { InsufficientStockBadge } from "./availability-label"; @@ -62,7 +76,6 @@ type BookingWithAssets = Prisma.BookingGetPayload<{ id: true; title: true; type: true; - consumptionType: true; availableToBook: true; custody: true; status: true; @@ -115,6 +128,14 @@ type BookingWithAssets = Prisma.BookingGetPayload<{ }; }>; +/** + * The `bookingAssets` rows this sidebar renders — the structural + * counterpart of `BOOKINGS_LIST_ASSETS_INCLUDE`. Exported so the + * bookings index route can type its (optional, child-pages-only) eager + * payload without re-declaring the field list. + */ +export type SidebarBookingAssets = BookingWithAssets["bookingAssets"]; + /** * Shape of a single `BookingModelRequest` row as consumed by this * sidebar. Matches the `BOOKING_WITH_ASSETS_INCLUDE` model-requests @@ -148,52 +169,25 @@ export type DispositionBreakdown = { interface BookingAssetsSidebarProps { /** - * Booking object to render. Typed as `BookingWithAssets` plus an - * optional `modelRequests` array so callers using the narrower inline - * include (`bookings._index.tsx`) can pass their object without a - * widening cast. When `modelRequests` is missing or empty, the - * "Unassigned model reservations" section is not rendered. + * Booking object to render. + * + * `bookingAssets` is optional: the child bookings pages still ship it + * eagerly and are rendered as-is, while the bookings INDEX omits it + * (shipping `_count.bookingAssets` for the trigger label instead) and + * the sheet fetches the full payload — plus the qty-progress maps — + * from `/api/bookings/:bookingId/assets-sidebar` on open. + * + * `modelRequests` stays optional so callers using a narrower inline + * include can pass their object without a widening cast. When it is + * missing or empty, the "Unassigned model reservations" section is + * not rendered. */ - booking: BookingWithAssets & { + booking: Pick & { + bookingAssets?: SidebarBookingAssets; + _count?: { bookingAssets: number } | null; modelRequests?: SidebarModelRequest[] | null; }; trigger?: ReactNode; - /** - * Optional map of `assetId → dispositionedQuantity` for this booking, - * i.e. sum of RETURN + CONSUME + LOSS + DAMAGE ConsumptionLog rows. - * When provided, the sidebar renders the qty column as `N / M` - * progress with an explanatory tooltip and swaps the status badge - * to "Partially checked in" for qty-tracked assets that have some - * units dispositioned but a non-zero remaining. When undefined, the - * sidebar falls back to the plain `× N` booked-quantity display — - * which keeps older call sites working without changes. - */ - dispositionedByAsset?: Record; - /** - * Optional map of `assetId → per-category split`. Lets the tooltip - * show Returned / Consumed / Lost / Damaged separately instead of - * conflating them into a single "Checked in" total — lost and - * damaged units shouldn't read the same as units back in the pool. - * When undefined, the tooltip falls back to the single-total layout. - */ - dispositionBreakdownByAsset?: Record; - /** - * Optional map of `assetId → checkedOutQuantity` for this booking - * (sum of progressive PartialBookingCheckout slices across every row - * of that asset). Drives the new - * `PARTIALLY_CHECKED_OUT_QTY_PENDING_RETURN` (amber, "partially - * checked out, no returns yet") badge: an asset with - * `checkedOutQuantity > 0 && dispositionedQuantity === 0` on an active - * booking gets the amber badge, mirroring the per-row treatment on - * the booking overview. Aggregated at the asset level (not per-row) - * because the sidebar renders one row per asset. - * - * Multi-slice tie-break: if a multi-slice asset has one slice partly - * IN (any disposition) and another slice still fully OUT, the - * check-IN signal wins at this aggregate level — consistent with the - * existing `PARTIALLY_CHECKED_OUT_QTY` precedence in this component. - */ - checkedOutByAsset?: Record; /** * Optional map of `assetId → units available across the workspace pool` * (after subtracting operator custody + other-booking reservations + @@ -215,7 +209,16 @@ interface BookingAssetsSidebarProps { * AssetKit pivot. An asset has at most one kit (enforced by * `@@unique([assetId])` on AssetKit), so `kit`/`kitId` are scalars. */ -type SidebarAssetBase = BookingWithAssets["bookingAssets"][number]["asset"]; +type SidebarAssetBase = BookingWithAssets["bookingAssets"][number]["asset"] & { + /** + * Not selected by either sidebar data path today (`getBookings`' + * `BOOKINGS_LIST_ASSETS_INCLUDE` and the assets-sidebar resource + * route both omit it), so the `ConsumptionTypeBadge` slot renders + * nothing. Kept optional so a caller whose payload does carry it + * keeps compiling — and rendering the badge. + */ + consumptionType?: ConsumptionType | null; +}; type SidebarAsset = SidebarAssetBase & { bookedQuantity: number; kit: NonNullable | null; @@ -658,15 +661,54 @@ function UnassignedModelRequestsSection({ export function BookingAssetsSidebar({ booking, trigger, - dispositionedByAsset, - dispositionBreakdownByAsset, - checkedOutByAsset, availableUnitsByAsset, }: BookingAssetsSidebarProps) { const [isOpen, setIsOpen] = useState(false); const [expandedKits, setExpandedKits] = useState>({}); - const paginatedItems = groupAssets(booking.bookingAssets); + /** + * Lazy data path (bookings index): the loader ships no pivots, so the + * sheet fetches the full payload + qty-progress maps on open. Eager + * callers (child bookings pages) never trigger the fetch — their + * inline `booking.bookingAssets` wins below and the maps stay + * undefined, exactly as before this component went dual-mode. + */ + const fetcher = useFetcher(); + const fetchedData = + fetcher.data && !fetcher.data.error ? fetcher.data : undefined; + const bookingAssets = booking.bookingAssets ?? fetchedData?.bookingAssets; + const dispositionedByAsset = fetchedData?.dispositionedByAsset; + const dispositionBreakdownByAsset = fetchedData?.dispositionBreakdownByAsset; + const checkedOutByAsset = fetchedData?.checkedOutByAsset; + /** + * A settled error payload must surface instead of the spinner: the + * fetch is only retried on user action, so without this branch a + * failed load (booking deleted/permission lost after page load) + * would spin forever. + */ + const fetchError = + !booking.bookingAssets && fetcher.state === "idle" && fetcher.data?.error + ? fetcher.data.error + : undefined; + /** Only the lazy path ever shows the spinner — eager data is instant. */ + const isLoadingAssets = !bookingAssets && !fetchError; + + const loadSidebarAssets = () => { + // Eager callers already ship the payload; and don't stack a second + // request while one is in flight. Re-fetches on each re-open so the + // drawer reflects check-in/out activity since the last open — + // `fetcher.data` persists meanwhile, so reopening renders the + // previous payload (no spinner flash) until the fresh one lands. + if (booking.bookingAssets || fetcher.state !== "idle") return; + void fetcher.load(`/api/bookings/${booking.id}/assets-sidebar`); + }; + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + if (open) loadSidebarAssets(); + }; + + const paginatedItems = groupAssets(bookingAssets ?? []); const toggleKitExpansion = (kitId: string) => { setExpandedKits((prev) => ({ @@ -682,21 +724,29 @@ export function BookingAssetsSidebar({ const outstandingModelRequestCount = (booking.modelRequests ?? []).filter( (req) => req.fulfilledAt === null ).length; - const hasItems = - booking.bookingAssets.length > 0 || outstandingModelRequestCount > 0; + /** + * Eager rows are canonical. On the lazy path the loader's + * `_count.bookingAssets` wins over a previously fetched payload: + * the count revalidates with every navigation while `fetcher.data` + * can be stale from an earlier open. + */ + const assetCount = booking.bookingAssets + ? booking.bookingAssets.length + : booking._count?.bookingAssets ?? fetchedData?.bookingAssets.length ?? 0; + const hasItems = assetCount > 0 || outstandingModelRequestCount > 0; const defaultTrigger = ( ); return ( - + {trigger || defaultTrigger} @@ -706,8 +756,7 @@ export function BookingAssetsSidebar({ Assets in "{booking.name}" - {booking.bookingAssets.length}{" "} - {booking.bookingAssets.length === 1 ? "asset" : "assets"} in this + {assetCount} {assetCount === 1 ? "asset" : "assets"} in this booking @@ -720,213 +769,247 @@ export function BookingAssetsSidebar({ modelRequests={booking.modelRequests} /> ) : null} -
-
Assets & kits
-

- {paginatedItems.length} items -

-
- -
- - - - - - - - - - - {paginatedItems.map((item) => { - if (item.type === "kit") { - const kit = item.kit; - const isExpanded = expandedKits[item.id] ?? false; - - if (!kit) { - return null; - } - - return ( - - {/* Kit Row */} - - - - - - + {fetchError ? ( +
+

+ Failed to load the booking's assets. +

+ +
+ ) : isLoadingAssets ? ( + /* Lazy path only: the payload is being fetched on open. + Mirrors the centered-spinner treatment of the on-open + fetch in `booking-overview-pdf.tsx`. */ +
+ +
+ ) : ( + <> +
+
Assets & kits
+

+ {paginatedItems.length} items +

+
- {/* Kit Assets (when expanded) */} - {isExpanded && - item.assets.map((asset) => ( - +
+
- Name - - Category -
-
- -
- -

- {item.assets.length} assets -

-
-
-
- - -
- -
-
+ + + + + + + + + + {paginatedItems.map((item) => { + if (item.type === "kit") { + const kit = item.kit; + const isExpanded = expandedKits[item.id] ?? false; + + if (!kit) { + return null; + } + + return ( + + {/* Kit Row */} + - - + - - ))} - - - - - ); - } - - // Individual asset - const asset = item.assets[0]; - return ( - - + + + + + + ))} + + + + + + ); + } + + // Individual asset + const asset = item.assets[0]; + return ( + + + - - - - - ); - })} - -
+ Name + + Category +
-
-
-
-
- -
- +
+ +
+ +

+ {item.assets.length} assets +

+ - {" "} + +
+ +
-
-
-
- + {/* Kit Assets (when expanded) */} + {isExpanded && + item.assets.map((asset) => ( +
+
+
+
+
+ +
+ +
+
+
+ {" "} + + + + {" "} +
+
+
+
+ +
+ +
- +
+ - - - - -
-
+ + + + ); + })} + + + + + )}
diff --git a/apps/webapp/app/modules/asset/query.server.ts b/apps/webapp/app/modules/asset/query.server.ts index 619bafb0e8..e3badfdbc0 100644 --- a/apps/webapp/app/modules/asset/query.server.ts +++ b/apps/webapp/app/modules/asset/query.server.ts @@ -2556,6 +2556,32 @@ const CHEAP_CUSTODY_JOINS = Prisma.sql` LEFT JOIN public."TeamMember" btm ON b."custodianTeamMemberId" = btm.id `; +/** + * Direct `a.*` sort-key columns for the slim phase, keyed by the SELECT alias + * each one emits. The heavy lateral re-selects all of these for the output, + * so in the slim phase they exist purely as `ORDER BY` inputs — + * {@link buildAdvancedAssetsQuery} emits only the ones the active sort + * references. `a.id AS "assetId"` is not listed: it is always selected (it is + * the lateral join key and the sort tiebreaker). + */ +const DIRECT_SORT_KEY_SELECTS: ReadonlyArray< + [alias: string, select: Prisma.Sql] +> = [ + ["assetCreatedAt", Prisma.sql`a."createdAt" AS "assetCreatedAt"`], + ["assetUpdatedAt", Prisma.sql`a."updatedAt" AS "assetUpdatedAt"`], + ["assetValue", Prisma.sql`a.value AS "assetValue"`], + ["assetQuantity", Prisma.sql`a.quantity AS "assetQuantity"`], + ["assetTitle", Prisma.sql`a.title AS "assetTitle"`], + ["assetSequentialId", Prisma.sql`a."sequentialId" AS "assetSequentialId"`], + ["assetStatus", Prisma.sql`a.status AS "assetStatus"`], + ["assetType", Prisma.sql`a.type AS "assetType"`], + ["assetDescription", Prisma.sql`a.description AS "assetDescription"`], + [ + "assetAvailableToBook", + Prisma.sql`a."availableToBook" AS "assetAvailableToBook"`, + ], +]; + /** * Detects which sort-only subquery selects the cheap phase must emit so that * every alias the `ORDER BY` references also exists in the slim SELECT. Missing @@ -2638,7 +2664,10 @@ export type BuildAdvancedAssetsQueryParams = { * NO `GROUP BY` (the tag search/filter is EXISTS-ified in * {@link generateWhereClause}, so no fanning tag join remains). * 2. `sorted_asset_query` — `ROW_NUMBER()` freezes the sort into an integer - * `__sortRank`, then `LIMIT/OFFSET` slices the page. + * `__sortRank`, then `LIMIT/OFFSET` slices the page. On the default path + * (no search, no custom-field sort) the order is inverted: `ORDER BY` + + * `LIMIT/OFFSET` run on the slim rows first (a top-N sort bounded by the + * page) and the window ranks only the page. * 3. `count_query` — `COUNT(*)` over the slim set (full filtered total). * The final SELECT runs the ENTIRE heavy projection once per page row via * `LEFT JOIN LATERAL`, and `json_agg` orders by the integer `__sortRank` — the @@ -2704,6 +2733,24 @@ export function buildAdvancedAssetsQuery({ l.name AS "locationName"` : Prisma.empty; + // Slim-phase direct `a.*` columns are sort keys only (the heavy lateral + // re-selects everything the output needs), so emit just the ones the active + // ORDER BY references. parseSortingOptions always double-quotes these + // aliases, so the quoted-substring check is exact — and it sees the default + // sort's `"assetCreatedAt"` fallback, which `sortBy` alone cannot reveal. + // Same contract as detectActiveSortKeys: over-inclusion is safe. + const activeDirectSorts = DIRECT_SORT_KEY_SELECTS.filter(([alias]) => + orderByInner.includes(`"${alias}"`) + ); + const directSortKeySelects = + activeDirectSorts.length > 0 + ? Prisma.sql`, + ${Prisma.join( + activeDirectSorts.map(([, select]) => select), + ",\n " + )}` + : Prisma.empty; + const qrIdSortSelect = qrIdSort ? Prisma.sql`, ${QR_ID_SUBQUERY} AS "qrId"` @@ -2737,29 +2784,31 @@ export function buildAdvancedAssetsQuery({ // bundle (tsc/vitest were fine, but the production build lost it). const rankOrderBy = Prisma.sql`saq."__sortRank"`; - return Prisma.sql` - WITH asset_query AS ( - -- SLIM cheap phase: id + sort keys, one row per matching asset, no - -- GROUP BY. Cost is O(N) rows of LIGHT columns, not the heavy - -- projection — that runs once per page row in the lateral below. + // Default path (no search, no custom-field sort): ORDER BY + LIMIT/OFFSET + // run on the slim rows FIRST, so Postgres top-N sorts to the page bound + // instead of full-sorting N rows to feed ROW_NUMBER; the window then ranks + // only the page (the rank restarts per page — only its relative order is + // consumed by the json_agg). The page slice is identical either way: + // parseSortingOptions always appends an "assetId" tiebreaker, so the order + // is total. Non-default paths keep the rank-then-slice shape unchanged. + const sortedAssetQuery = + !hasSearch && customFieldSortings.length === 0 + ? Prisma.sql` + sorted_asset_query AS ( SELECT - a.id AS "assetId", - a."createdAt" AS "assetCreatedAt", - a."updatedAt" AS "assetUpdatedAt", - a.value AS "assetValue", - a.quantity AS "assetQuantity", - a.title AS "assetTitle", - a."sequentialId" AS "assetSequentialId", - a.status AS "assetStatus", - a.type AS "assetType", - a.description AS "assetDescription", - a."availableToBook" AS "assetAvailableToBook"${kitNameSelect}${categoryNameSelect}${assetModelNameSelect}${locationNameSelect}${qrIdSortSelect}${custodySortSelect}${barcodeSortSelects}${customFieldSelect} - ${baseJoins} - ${custodyJoins} - ${whereClause} - ), + "assetId", + ROW_NUMBER() OVER (ORDER BY ${Prisma.raw( + orderByInner + )}) AS "__sortRank" + FROM ( + SELECT * + FROM asset_query + ORDER BY ${Prisma.raw(orderByInner)} + ${paginationClause} + ) page + )` + : Prisma.sql` sorted_asset_query AS ( - -- Freeze the sort into a stable integer rank, then slice the page. SELECT "assetId", ROW_NUMBER() OVER (ORDER BY ${Prisma.raw( @@ -2768,7 +2817,22 @@ export function buildAdvancedAssetsQuery({ FROM asset_query ORDER BY "__sortRank" ${paginationClause} + )`; + + return Prisma.sql` + WITH asset_query AS ( + -- SLIM cheap phase: id + sort keys, one row per matching asset, no + -- GROUP BY. Cost is O(N) rows of LIGHT columns, not the heavy + -- projection — that runs once per page row in the lateral below. + SELECT + a.id AS "assetId"${directSortKeySelects}${kitNameSelect}${categoryNameSelect}${assetModelNameSelect}${locationNameSelect}${qrIdSortSelect}${custodySortSelect}${barcodeSortSelects}${customFieldSelect} + ${baseJoins} + ${custodyJoins} + ${whereClause} ), + -- Freeze the sort into a stable integer rank and slice the page (the + -- default path pages first, then ranks — see sortedAssetQuery above). + ${sortedAssetQuery}, count_query AS ( -- Full filtered total (pagination-independent) over the slim CTE. SELECT COUNT(*)::integer AS total_count diff --git a/apps/webapp/app/modules/booking/constants.ts b/apps/webapp/app/modules/booking/constants.ts index 13c353385c..1c8bc0c558 100644 --- a/apps/webapp/app/modules/booking/constants.ts +++ b/apps/webapp/app/modules/booking/constants.ts @@ -105,6 +105,99 @@ export const BOOKING_COMMON_INCLUDE = { tags: TAG_WITH_COLOR_SELECT, } as Prisma.BookingInclude; +/** + * Per-booking `bookingAssets` payload for the bookings LIST surfaces. + * + * Single source of truth for the row shape the bookings-list assets + * drawer (`BookingAssetsSidebar`) renders, shared by: + * - `getBookings` (service.server.ts) — attached when `includeAssets` is + * true (child bookings pages, CSV select-all export); + * - the `/api/bookings/:bookingId/assets-sidebar` resource route — the + * bookings INDEX no longer ships assets in its loader (perf: ~99% of + * that payload was dead weight for users who never expand a row), so + * the drawer fetches this exact shape lazily on expand. + * + * Keeping both callers on one constant is what guarantees the drawer + * renders identically no matter which path supplied the data. + */ +export const BOOKINGS_LIST_ASSETS_INCLUDE = { + bookingAssets: { + // Explicit `select` (instead of `include`) so the inferred + // type surfaces `assetKitId` on each row — the bookings list + // sidebar (`BookingAssetsSidebar`) groups by it. Without an + // explicit select, Prisma's type inference for + // `include + nested include` doesn't expose the parent + // scalars in a form the local component types accept. + select: { + id: true, + quantity: true, + assetKitId: true, + asset: { + select: { + title: true, + id: true, + type: true, + quantity: true, + custody: true, + availableToBook: true, + status: true, + mainImage: true, + thumbnailImage: true, + mainImageExpiration: true, + // Asset-code resolution fields — see `app/modules/barcode/display.ts`. + // Surfaced by the BookingAssetsSidebar so the chip matches the + // simple-mode booking overview list and every other code-bearing + // surface (see .claude/rules/code-bearing-entity-list-consistency.md). + sequentialId: true, + preferredBarcodeId: true, + qrCodes: { take: 1, select: { id: true } }, + barcodes: { select: { id: true, type: true, value: true } }, + category: { + select: { + id: true, + name: true, + color: true, + }, + }, + // NOTE: deliberately NO `bookingAssets` here. A previous + // version selected each asset's entire lifetime + // `bookingAssets: { bookingId }` pivot history, which grows + // without bound and had zero consumers (every reader of + // `asset.bookingAssets` needs `ba.booking.{id,status}` from + // asset-centric queries, which this shape cannot provide). + // If a surface ever needs conflict info here, scope it with + // a `where` on active statuses + date overlap like + // getBookingFlags does. + assetKits: { + select: { + // See the comment in `bookings.$bookingId.overview.tsx` + // for why both `id` (the AssetKit row id) and `kitId` + // are needed for kit-source grouping. + id: true, + kitId: true, + kit: { + select: { + id: true, + name: true, + image: true, + imageExpiration: true, + category: { + select: { + id: true, + name: true, + color: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +} satisfies Prisma.BookingInclude; + export const BOOKING_WITH_ASSETS_INCLUDE = { ...BOOKING_COMMON_INCLUDE, bookingAssets: { diff --git a/apps/webapp/app/modules/booking/service.server.ts b/apps/webapp/app/modules/booking/service.server.ts index 4ca6653ad9..53b11c8bde 100644 --- a/apps/webapp/app/modules/booking/service.server.ts +++ b/apps/webapp/app/modules/booking/service.server.ts @@ -92,6 +92,7 @@ import { BOOKING_INCLUDE_FOR_EMAIL, BOOKING_INCLUDE_FOR_RESERVATION_EMAIL, BOOKING_SCHEDULER_EVENTS_ENUM, + BOOKINGS_LIST_ASSETS_INCLUDE, BOOKING_WITH_ASSETS_INCLUDE, } from "./constants"; import type { @@ -8834,6 +8835,19 @@ export async function getBookings(params: { * `false`, so paginated callers are unaffected. */ skipCount?: boolean; + /** + * Include the `bookingAssets` payload — by far the heaviest part of the + * query. Callers that render no asset data (the calendar/home widgets) + * pass `false`, which omits the key from the Prisma include entirely. + * Defaults to `true`, so existing callers are unaffected. + */ + includeAssets?: boolean; + /** + * Hard cap on rows fetched, bypassing the `perPage` ≤ 100 clamp. For + * callers that fetch one bounded window in a single query (the calendar). + * Unlike `takeAll` the query stays bounded. Ignored when `takeAll` is set. + */ + takeCap?: number; }) { const { organizationId, @@ -8855,6 +8869,8 @@ export async function getBookings(params: { kitId, tags, skipCount = false, + includeAssets = true, + takeCap, } = params; try { @@ -9027,86 +9043,21 @@ export async function getBookings(params: { db.booking.findMany({ ...(!takeAll && { skip, - take, + take: takeCap ?? take, }), where, include: { ...BOOKING_COMMON_INCLUDE, - bookingAssets: { - // Explicit `select` (instead of `include`) so the inferred - // type surfaces `assetKitId` on each row — the bookings list - // sidebar (`BookingAssetsSidebar`) groups by it. Without an - // explicit select, Prisma's type inference for - // `include + nested include` doesn't expose the parent - // scalars in a form the local component types accept. - select: { - id: true, - quantity: true, - assetKitId: true, - asset: { - select: { - title: true, - id: true, - type: true, - quantity: true, - custody: true, - availableToBook: true, - status: true, - mainImage: true, - thumbnailImage: true, - mainImageExpiration: true, - // Asset-code resolution fields — see `app/modules/barcode/display.ts`. - // Surfaced by the BookingAssetsSidebar so the chip matches the - // simple-mode booking overview list and every other code-bearing - // surface (see .claude/rules/code-bearing-entity-list-consistency.md). - sequentialId: true, - preferredBarcodeId: true, - qrCodes: { take: 1, select: { id: true } }, - barcodes: { select: { id: true, type: true, value: true } }, - category: { - select: { - id: true, - name: true, - color: true, - }, - }, - // NOTE: deliberately NO `bookingAssets` here. A previous - // version selected each asset's entire lifetime - // `bookingAssets: { bookingId }` pivot history, which grows - // without bound and had zero consumers (every reader of - // `asset.bookingAssets` needs `ba.booking.{id,status}` from - // asset-centric queries, which this shape cannot provide). - // If a surface ever needs conflict info here, scope it with - // a `where` on active statuses + date overlap like - // getBookingFlags does. - assetKits: { - select: { - // See the comment in `bookings.$bookingId.overview.tsx` - // for why both `id` (the AssetKit row id) and `kitId` - // are needed for kit-source grouping. - id: true, - kitId: true, - kit: { - select: { - id: true, - name: true, - image: true, - imageExpiration: true, - category: { - select: { - id: true, - name: true, - color: true, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, + // NOTE: deliberately NO `bookingAssets` when `includeAssets` is + // false — the calendar/home render no asset data. The cast keeps + // the inferred row type stable for default-true callers; opt-out + // callers must not read `bookingAssets` (same runtime/static + // divergence as `extraInclude`, see getBookingsForICalFeed). + // The include itself lives in `./constants` so the + // assets-sidebar resource route serves the exact same shape. + ...((includeAssets + ? BOOKINGS_LIST_ASSETS_INCLUDE + : {}) as typeof BOOKINGS_LIST_ASSETS_INCLUDE), creator: { select: { id: true, @@ -10022,7 +9973,6 @@ export async function getBookingsForCalendar(params: { const { bookings } = await getBookings({ organizationId, page: 1, - perPage: 1000, search, userId, ...(status && { @@ -10034,9 +9984,25 @@ export async function getBookingsForCalendar(params: { custodianTeamMemberIds: teamMemberIds, ...selfServiceData, tags, + // The events mapped below carry no asset data, so skip the heavy + // `bookingAssets` include entirely. + includeAssets: false, + // Only `bookings` is read here; skip the COUNT companion query. + skipCount: true, extraInclude: { - custodianTeamMember: true, - custodianUser: true, + // Slim selects: the events mapping below reads only the + // display-name fields + profile picture off the custodian/creator + // users, and `name` off the team member. + custodianTeamMember: { select: { id: true, name: true } }, + custodianUser: { + select: { + id: true, + firstName: true, + lastName: true, + displayName: true, + profilePicture: true, + }, + }, creator: { select: { id: true, @@ -10048,8 +10014,23 @@ export async function getBookingsForCalendar(params: { }, tags: TAG_WITH_COLOR_SELECT, }, - takeAll: true, - }); + // Hard cap instead of `takeAll`: the window is a calendar view (~a + // month), so the fetch stays bounded even for runaway workspaces. + // Default `from asc` + `id asc` ordering keeps the capped set + // deterministic. + takeCap: 1000, + }); + + if (bookings.length >= 1000) { + // At the cap the latest-starting bookings in the window are + // silently dropped from the calendar — surface it so the cap can + // be revisited if a real workspace ever hits it. + Logger.warn({ + message: "Calendar booking fetch hit the 1000-row cap", + additionalData: { organizationId, startDate, endDate }, + label: "Booking", + }); + } const events = bookings .filter((booking) => booking.from && booking.to) diff --git a/apps/webapp/app/routes/_layout+/bookings._index.tsx b/apps/webapp/app/routes/_layout+/bookings._index.tsx index c458e8f1fd..90c2ce7e0a 100644 --- a/apps/webapp/app/routes/_layout+/bookings._index.tsx +++ b/apps/webapp/app/routes/_layout+/bookings._index.tsx @@ -14,7 +14,10 @@ import { useLoaderData, } from "react-router"; import { AvailabilityBadge } from "~/components/booking/availability-label"; -import { BookingAssetsSidebar } from "~/components/booking/booking-assets-sidebar"; +import { + BookingAssetsSidebar, + type SidebarBookingAssets, +} from "~/components/booking/booking-assets-sidebar"; import BookingFilters from "~/components/booking/booking-filters"; import { BookingStatusBadge } from "~/components/booking/booking-status-badge"; import BulkActionsDropdown from "~/components/booking/bulk-actions-dropdown"; @@ -156,8 +159,22 @@ export async function loader({ context, request }: LoaderFunctionArgs) { orderBy, orderDirection, tags: filterTags, + /** + * PERF: the index table renders only booking-level fields plus + * an asset COUNT — the heavy per-booking `bookingAssets` + * payload (assets + QR/barcodes + kits + categories) existed + * solely for the assets drawer, which now fetches it lazily + * from `/api/bookings/:bookingId/assets-sidebar` on expand. + * Shipping it here was ~99% dead payload and the dominant + * serialization/SSR cost of this route. + */ + includeAssets: false, extraInclude: { tags: TAG_WITH_COLOR_SELECT, + // Asset count for the row's drawer trigger ("N assets") — + // replaces `bookingAssets.length` now that the pivots are no + // longer loaded. + _count: { select: { bookingAssets: true } }, // Include outstanding model-level reservations so the // assets-sidebar drawer can render the "Unassigned model // reservations (N)" section — and so the drawer trigger opens @@ -212,111 +229,42 @@ export async function loader({ context, request }: LoaderFunctionArgs) { const totalPages = Math.ceil(bookingCount / perPage); /** - * Compute a per-booking map of `assetId → dispositionedQty` (sum - * of RETURN + CONSUME + LOSS + DAMAGE ConsumptionLog rows) for - * every qty-tracked asset currently visible on this page. Feeds - * the `BookingAssetsSidebar` so it can render the same qty - * progress indicator and "Partially checked in" badge the - * overview page uses. - * - * Strategy: one aggregate query scoped to the bookingIds on this - * page (at most `perPage` bookings, so bounded). Then we bucket the - * rows by bookingId and store a Map> - * keyed by bookingId. Empty record for bookings with no activity. + * Page-scoped input for the "Includes unavailable assets" row + * badge. The per-booking asset payload is no longer shipped by this + * loader (`includeAssets: false` above — the drawer fetches it + * lazily, along with the qty-progress maps this loader used to + * compute page-wide), so the badge's signal is computed here as one + * bounded query: which bookings on this page have at least one + * asset that is not bookable or is in custody (mirrors the old + * per-row `!availableToBook || hasCustody(custody)` check). */ const bookingIdsOnPage = bookings.map((b) => b.id); - const dispositionRows = - bookingIdsOnPage.length > 0 - ? await db.consumptionLog.groupBy({ - by: ["bookingId", "assetId", "category"], - where: { - bookingId: { in: bookingIdsOnPage }, - category: { in: ["RETURN", "CONSUME", "LOSS", "DAMAGE"] }, - }, - _sum: { quantity: true }, - }) - : []; - /** - * Per-booking → per-asset disposition totals AND a per-category - * breakdown. The sidebar tooltip uses the breakdown to show - * Returned / Consumed / Lost / Damaged separately (lost and - * damaged units are conceptually different from returned ones). - * Both derivations come from the same single groupBy — no extra - * DB round-trip. - */ - const dispositionedByBooking: Record> = {}; - const dispositionBreakdownByBooking: Record< - string, - Record< - string, - { returned: number; consumed: number; lost: number; damaged: number } - > - > = {}; - for (const row of dispositionRows) { - if (!row.bookingId) continue; - const qty = row._sum.quantity ?? 0; - - if (!dispositionedByBooking[row.bookingId]) { - dispositionedByBooking[row.bookingId] = {}; - } - dispositionedByBooking[row.bookingId][row.assetId] = - (dispositionedByBooking[row.bookingId][row.assetId] ?? 0) + qty; - - if (!dispositionBreakdownByBooking[row.bookingId]) { - dispositionBreakdownByBooking[row.bookingId] = {}; - } - const bucket = - dispositionBreakdownByBooking[row.bookingId][row.assetId] ?? - ({ - returned: 0, - consumed: 0, - lost: 0, - damaged: 0, - } as const); - const next = { ...bucket }; - if (row.category === "RETURN") next.returned += qty; - else if (row.category === "CONSUME") next.consumed += qty; - else if (row.category === "LOSS") next.lost += qty; - else if (row.category === "DAMAGE") next.damaged += qty; - dispositionBreakdownByBooking[row.bookingId][row.assetId] = next; - } - - /** - * Per-booking → per-asset progressively-checked-out total. Sums - * `PartialBookingCheckout.quantities[i]` across every checkout - * session for the bookings on this page, bucketed by - * `(bookingId, assetIds[i])`. Feeds the sidebar's new amber - * `PARTIALLY_CHECKED_OUT_QTY_PENDING_RETURN` badge: an asset with - * `checkedOutQuantity > 0 && dispositionedQuantity === 0` on an - * active booking is "partly out, no returns yet". - * - * Legacy fallback: pre-progressive-checkout rows have - * `quantities[].length !== assetIds[].length` (often empty). We - * count one unit per occurrence in that case, matching the - * service-layer read convention (`countCheckedOutUnitsForAsset` in - * `apps/webapp/app/modules/booking/service.server.ts`). - */ - const checkoutSessionRows = + const bookingsWithUnavailableAssets = bookingIdsOnPage.length > 0 - ? await db.partialBookingCheckout.findMany({ - where: { bookingId: { in: bookingIdsOnPage } }, - select: { bookingId: true, assetIds: true, quantities: true }, - }) + ? ( + await db.booking.findMany({ + where: { + // `bookingIdsOnPage` already comes from the org-scoped + // getBookings call; the explicit scope keeps this query + // safe on its own terms regardless. + id: { in: bookingIdsOnPage }, + organizationId, + bookingAssets: { + some: { + asset: { + OR: [ + { availableToBook: false }, + // Mirrors `hasCustody`: any custody row counts. + { custody: { some: {} } }, + ], + }, + }, + }, + }, + select: { id: true }, + }) + ).map((b) => b.id) : []; - const checkedOutByBooking: Record> = {}; - for (const session of checkoutSessionRows) { - const ids = session.assetIds ?? []; - const qtys = session.quantities ?? []; - const aligned = qtys.length === ids.length; - const bucket = - checkedOutByBooking[session.bookingId] ?? - (checkedOutByBooking[session.bookingId] = {}); - for (let i = 0; i < ids.length; i += 1) { - const assetId = ids[i]; - const quantity = aligned ? qtys[i] ?? 1 : 1; - bucket[assetId] = (bucket[assetId] ?? 0) + quantity; - } - } const header: HeaderData = { title: "Bookings", @@ -338,9 +286,7 @@ export async function loader({ context, request }: LoaderFunctionArgs) { perPage, modelName, hasActiveFilters, - dispositionedByBooking, - dispositionBreakdownByBooking, - checkedOutByBooking, + bookingsWithUnavailableAssets, ...teamMembersData, // For BASE/SELF_SERVICE users, provide dedicated form team members // For ADMIN users, reuse the filter team members @@ -502,67 +448,13 @@ export default function BookingsIndexPage({ } const ListBookingsContent = ({ - item: rawItem, + item, }: { item: Prisma.BookingGetPayload<{ include: { - bookingAssets: { - select: { - id: true; - quantity: true; - // Surfaces the kit-source discriminator the sidebar groups - // by. Without this field on the index loader's type, the - // sidebar's `BookingWithAssets` type check rejects the data. - assetKitId: true; - asset: { - select: { - id: true; - title: true; - type: true; - consumptionType: true; - availableToBook: true; - custody: true; - status: true; - mainImage: true; - thumbnailImage: true; - mainImageExpiration: true; - // Code-resolution fields - mirror of getBookings' assets select - sequentialId: true; - preferredBarcodeId: true; - qrCodes: { take: 1; select: { id: true } }; - barcodes: { select: { id: true; type: true; value: true } }; - category: { - select: { - id: true; - name: true; - color: true; - }; - }; - assetKits: { - select: { - id: true; - kitId: true; - kit: { - select: { - id: true; - name: true; - image: true; - imageExpiration: true; - category: { - select: { - id: true; - name: true; - color: true; - }; - }; - }; - }; - }; - }; - }; - }; - }; - }; + // Trigger label for the assets drawer ("N assets") — the index + // loader ships the count instead of the pivots themselves. + _count: { select: { bookingAssets: true } }; creator: { select: { id: true; @@ -587,49 +479,33 @@ const ListBookingsContent = ({ }; }; }; - }>; -}) => { - // Defensive normalisation against a Sentry-observed crash - // (SHELF-WEBAPP-1NW): a single client-side render hit - // `item.bookingAssets` as undefined and tripped `.some(...)`, breaking - // the row through the error boundary. The component's prop type - // declares `bookingAssets` as required and the loader always selects - // it, so the undefined was either a stale-bundle / hydration mismatch - // in the deploy window or an as-yet unidentified loader edge case. - // - // Normalise once at the top so EVERY downstream reader gets a safe - // array — the badge calc below, `` (which - // calls `groupAssets(booking.bookingAssets)` + reads - // `booking.bookingAssets.length` in multiple places), and any future - // additions. A scoped `?? []` on each reader would be brittle. - const item = { - ...rawItem, - bookingAssets: rawItem.bookingAssets ?? [], + }> & { + /** + * Full pivot payload — present only when the row is rendered by one + * of the child bookings pages (`assets.$assetId.bookings`, + * `me.bookings`, ...) whose loaders still ship assets eagerly. The + * bookings INDEX loader intentionally omits it (`includeAssets: + * false` — the drawer fetches on expand instead), which is also why + * every reader below treats it as optional. + */ + bookingAssets?: SidebarBookingAssets; }; - - const hasUnavaiableAssets = - item.bookingAssets.some( - (ba) => !ba.asset.availableToBook || hasCustody(ba.asset.custody) - ) && !["COMPLETE", "CANCELLED", "ARCHIVED"].includes(item.status); - - /** - * Pull this booking's slice of the page-wide dispositioned-quantity - * map so the sidebar can render qty progress + partial-checkin badge. - * Reading from loader data here (instead of threading a prop through - * ``) keeps the list plumbing unchanged. - */ +}) => { const loaderData = useLoaderData(); - const dispositionedByAsset = - loaderData?.dispositionedByBooking?.[item.id] ?? undefined; - const dispositionBreakdownByAsset = - loaderData?.dispositionBreakdownByBooking?.[item.id] ?? undefined; + /** - * Per-asset progressive-checkout totals for this booking. Drives the - * sidebar's new amber "partially checked out, no returns yet" badge - * + the `{checkedOut}/{booked}` qty display. + * "Includes unavailable assets" badge input. The index loader ships a + * page-scoped id list (it no longer loads the pivots); the child + * bookings pages don't compute that list but still ship the full + * `bookingAssets` payload, so fall back to deriving the flag from it + * there — the exact per-row check the index used to run. */ - const checkedOutByAsset = - loaderData?.checkedOutByBooking?.[item.id] ?? undefined; + const hasUnavaiableAssets = + (loaderData?.bookingsWithUnavailableAssets + ? loaderData.bookingsWithUnavailableAssets.includes(item.id) + : (item.bookingAssets ?? []).some( + (ba) => !ba.asset.availableToBook || hasCustody(ba.asset.custody) + )) && !["COMPLETE", "CANCELLED", "ARCHIVED"].includes(item.status); return ( <> @@ -678,12 +554,7 @@ const ListBookingsContent = ({ {/* Assets count */} - + diff --git a/apps/webapp/app/routes/_layout+/home.tsx b/apps/webapp/app/routes/_layout+/home.tsx index 417d25605d..f6a93ae0ff 100644 --- a/apps/webapp/app/routes/_layout+/home.tsx +++ b/apps/webapp/app/routes/_layout+/home.tsx @@ -103,6 +103,8 @@ export async function loader({ context, request }: LoaderFunctionArgs) { locationDistribution, locationsCount, categoriesCount, + // Onboarding checklist booleans + checklistData, // Cookie cookieResult, ] = await Promise.all([ @@ -198,12 +200,15 @@ export async function loader({ context, request }: LoaderFunctionArgs) { }), // 1d. Ongoing + overdue bookings for custodian merge + // Widgets only read booking scalars + custodian + `_count.bookingAssets`, + // so all four calls skip the heavy per-asset include. getBookings({ organizationId, userId, page: 1, perPage: 1000, statuses: ["ONGOING", "OVERDUE"], + includeAssets: false, extraInclude: { custodianTeamMember: true, custodianUser: true, @@ -221,6 +226,7 @@ export async function loader({ context, request }: LoaderFunctionArgs) { statuses: ["RESERVED"], bookingFrom: new Date(), bookingTo: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + includeAssets: false, extraInclude: { custodianTeamMember: true, custodianUser: true, @@ -235,6 +241,7 @@ export async function loader({ context, request }: LoaderFunctionArgs) { page: 1, perPage: 5, statuses: ["OVERDUE"], + includeAssets: false, extraInclude: { custodianTeamMember: true, custodianUser: true, @@ -249,6 +256,7 @@ export async function loader({ context, request }: LoaderFunctionArgs) { page: 1, perPage: 5, statuses: ["ONGOING"], + includeAssets: false, extraInclude: { custodianTeamMember: true, custodianUser: true, @@ -300,27 +308,43 @@ export async function loader({ context, request }: LoaderFunctionArgs) { }), // Location distribution (top 5) - db.location - .findMany({ + // Count pivot rows (one per asset placed at this location). groupBy-first + // so Postgres aggregates the pivot once instead of counting per location. + db.assetLocation + .groupBy({ + by: ["locationId"], where: { organizationId }, - select: { - id: true, - name: true, - // Count pivot rows (one per asset placed at this location). - _count: { select: { assetLocations: true } }, - }, - orderBy: { assetLocations: { _count: "desc" } }, + _count: { locationId: true }, + orderBy: { _count: { locationId: "desc" } }, take: 5, }) - .then((locs) => - locs - .filter((l) => l._count.assetLocations > 0) - .map((l) => ({ - locationId: l.id, - locationName: l.name, - assetCount: l._count.assetLocations, - })) - ), + .then(async (groups) => { + if (groups.length === 0) return []; + + const locations = await db.location.findMany({ + where: { + id: { in: groups.map((g) => g.locationId) }, + organizationId, + }, + select: { id: true, name: true }, + }); + const nameById = new Map(locations.map((l) => [l.id, l.name])); + + return groups.flatMap((g) => { + const locationName = nameById.get(g.locationId); + // Location deleted between the two queries — drop the row, + // matching the old single-query behavior. + if (!locationName) return []; + + return [ + { + locationId: g.locationId, + locationName, + assetCount: g._count.locationId, + }, + ]; + }); + }), // KPI: total locations db.location.count({ @@ -332,6 +356,9 @@ export async function loader({ context, request }: LoaderFunctionArgs) { where: { organizationId }, }), + // Onboarding checklist booleans + checklistOptions({ organizationId }), + // Cookie userPrefs.parse(request.headers.get("Cookie")).then((c: any) => c || {}), ]); @@ -375,10 +402,13 @@ export async function loader({ context, request }: LoaderFunctionArgs) { content: parseMarkdownToReact(announcement.content), } : null, - checklistOptions: await checklistOptions({ + checklistOptions: { hasAssets: totalAssets > 0, - organizationId, - }), + // Same where-clause as the `directCustodians` query above, so a + // separate custodies count would be redundant. + hasCustodies: directCustodians.length > 0, + ...checklistData, + }, }); } catch (cause) { const reason = makeShelfError(cause); diff --git a/apps/webapp/app/routes/api+/bookings.$bookingId.assets-sidebar.ts b/apps/webapp/app/routes/api+/bookings.$bookingId.assets-sidebar.ts new file mode 100644 index 0000000000..53a0f28a06 --- /dev/null +++ b/apps/webapp/app/routes/api+/bookings.$bookingId.assets-sidebar.ts @@ -0,0 +1,207 @@ +/** + * API Route: Booking Assets Sidebar (lazy drawer payload) + * + * Returns the per-booking `bookingAssets` payload plus the qty-progress + * maps that the bookings-index assets drawer (`BookingAssetsSidebar`) + * renders when a user expands a row. The bookings index loader + * intentionally no longer ships this data (`includeAssets: false`) — it + * was ~99% dead payload for users who never open the drawer and the + * dominant serialization/SSR cost of `/bookings` — so the drawer + * fetches it from here on first open instead. + * + * The `bookingAssets` shape is `BOOKINGS_LIST_ASSETS_INCLUDE`, the same + * constant `getBookings` attaches for eager callers, which is what + * guarantees an open drawer renders identically to when the index + * still shipped the payload inline. + * + * Auth mirrors the read-side gate of the bookings index: + * `requirePermission` (booking/read) scopes to the org, + * `bookingDraftVisibilityClause` hides other users' drafts, and + * `canSeeBooking` re-applies the restricted-role custody scope so + * SELF_SERVICE/BASE users can only fetch bookings the index would list + * for them. + * + * @see {@link file://./../../components/booking/booking-assets-sidebar.tsx} + * @see {@link file://./../../routes/_layout+/bookings._index.tsx} + */ +import { data, type LoaderFunctionArgs } from "react-router"; + +/** + * Closed drawers keep their fetcher mounted per row; without this, + * every page action would revalidate N previously-opened drawers + * nobody is looking at. Reopening always fetches fresh data + * (see `loadSidebarAssets`), so skipping revalidation loses nothing. + */ +export function shouldRevalidate() { + return false; +} +import { z } from "zod"; +import type { DispositionBreakdown } from "~/components/booking/booking-assets-sidebar"; +import { db } from "~/database/db.server"; +import { BOOKINGS_LIST_ASSETS_INCLUDE } from "~/modules/booking/constants"; +import { bookingDraftVisibilityClause } from "~/modules/booking/service.server"; +import { canSeeBooking } from "~/utils/booking-authorization.server"; +import { makeShelfError, ShelfError } from "~/utils/error"; +import { error, getParams, payload } from "~/utils/http.server"; +import { + PermissionAction, + PermissionEntity, +} from "~/utils/permissions/permission.data"; +import { requirePermission } from "~/utils/roles.server"; + +export async function loader({ context, request, params }: LoaderFunctionArgs) { + const authSession = context.getSession(); + const { userId } = authSession; + + const { bookingId } = getParams(params, z.object({ bookingId: z.string() }), { + additionalData: { userId }, + }); + + try { + const { organizationId, canSeeAllBookings } = await requirePermission({ + userId, + request, + entity: PermissionEntity.booking, + action: PermissionAction.read, + }); + + const booking = await db.booking.findFirst({ + where: { + id: bookingId, + organizationId, + // Drafts are visible to their creator only — same clause the + // index applies, so this route can't leak a row the list hides. + AND: [bookingDraftVisibilityClause(userId)], + }, + select: { + id: true, + custodianUserId: true, + // Custody can be recorded on the team-member link alone; the + // `canSeeBooking` gate matches on either link, so select both. + custodianTeamMember: { select: { userId: true } }, + ...BOOKINGS_LIST_ASSETS_INCLUDE, + }, + }); + + if (!booking) { + throw new ShelfError({ + cause: null, + title: "Not found", + message: "Booking not found.", + label: "Booking", + status: 404, + shouldBeCaptured: false, + }); + } + + /** + * `booking.read` passes for BASE and SELF_SERVICE too, so the org + * scope alone would let either role fetch any booking's asset list + * by id. Mirrors the gate on the activity routes and the custody + * restriction `getBookings` applies to the index itself. + */ + if (!canSeeBooking({ canSeeAllBookings, booking, userId })) { + throw new ShelfError({ + cause: null, + message: "You are not authorized to view this booking", + additionalData: { userId, bookingId, organizationId }, + label: "Booking", + status: 403, + shouldBeCaptured: false, + }); + } + + const [dispositionRows, checkoutSessionRows] = await Promise.all([ + /** + * `assetId → dispositionedQty` input (sum of RETURN + CONSUME + + * LOSS + DAMAGE ConsumptionLog rows) for this booking. Feeds the + * sidebar's qty progress indicator and "Partially checked in" + * badge — the same aggregate the bookings index used to compute + * page-wide before the drawer went lazy, now scoped to the one + * booking actually being expanded. + */ + db.consumptionLog.groupBy({ + by: ["assetId", "category"], + where: { + bookingId, + category: { in: ["RETURN", "CONSUME", "LOSS", "DAMAGE"] }, + }, + _sum: { quantity: true }, + }), + /** + * Progressive-checkout sessions for this booking. Sums + * `PartialBookingCheckout.quantities[i]` per `assetIds[i]` to + * drive the sidebar's amber + * `PARTIALLY_CHECKED_OUT_QTY_PENDING_RETURN` badge. + */ + db.partialBookingCheckout.findMany({ + where: { bookingId }, + select: { assetIds: true, quantities: true }, + }), + ]); + + /** + * Per-asset disposition totals AND a per-category breakdown. The + * sidebar tooltip uses the breakdown to show Returned / Consumed / + * Lost / Damaged separately (lost and damaged units are + * conceptually different from returned ones). Both derivations + * come from the same single groupBy — no extra DB round-trip. + */ + const dispositionedByAsset: Record = {}; + const dispositionBreakdownByAsset: Record = + {}; + for (const row of dispositionRows) { + const qty = row._sum.quantity ?? 0; + + dispositionedByAsset[row.assetId] = + (dispositionedByAsset[row.assetId] ?? 0) + qty; + + const bucket = dispositionBreakdownByAsset[row.assetId] ?? { + returned: 0, + consumed: 0, + lost: 0, + damaged: 0, + }; + const next = { ...bucket }; + if (row.category === "RETURN") next.returned += qty; + else if (row.category === "CONSUME") next.consumed += qty; + else if (row.category === "LOSS") next.lost += qty; + else if (row.category === "DAMAGE") next.damaged += qty; + dispositionBreakdownByAsset[row.assetId] = next; + } + + /** + * Per-asset progressively-checked-out total. + * + * Legacy fallback: pre-progressive-checkout rows have + * `quantities[].length !== assetIds[].length` (often empty). We + * count one unit per occurrence in that case, matching the + * service-layer read convention (`countCheckedOutUnitsForAsset` in + * `apps/webapp/app/modules/booking/service.server.ts`). + */ + const checkedOutByAsset: Record = {}; + for (const session of checkoutSessionRows) { + const ids = session.assetIds ?? []; + const qtys = session.quantities ?? []; + const aligned = qtys.length === ids.length; + for (let i = 0; i < ids.length; i += 1) { + const assetId = ids[i]; + const quantity = aligned ? qtys[i] ?? 1 : 1; + checkedOutByAsset[assetId] = + (checkedOutByAsset[assetId] ?? 0) + quantity; + } + } + + return data( + payload({ + bookingAssets: booking.bookingAssets, + dispositionedByAsset, + dispositionBreakdownByAsset, + checkedOutByAsset, + }) + ); + } catch (cause) { + const reason = makeShelfError(cause, { userId, bookingId }); + return data(error(reason), { status: reason.status }); + } +} diff --git a/apps/webapp/app/utils/dashboard.server.ts b/apps/webapp/app/utils/dashboard.server.ts index fb0b0ffa7d..2adf1e3549 100644 --- a/apps/webapp/app/utils/dashboard.server.ts +++ b/apps/webapp/app/utils/dashboard.server.ts @@ -241,59 +241,53 @@ export function getCustodiansOrderedByTotalCustodies({ // checklistOptions // --------------------------------------------------------------------------- +/** + * Computes the count-backed onboarding checklist booleans. + * + * `hasAssets` and `hasCustodies` are composed at the call site (home loader) + * from queries it already runs, so they are not re-counted here. + * + * @param args - The organization to compute the checklist for + * @returns Checklist booleans keyed by onboarding step + * @throws {ShelfError} If a count query fails + */ export async function checklistOptions({ - hasAssets, organizationId, }: { - hasAssets: boolean; organizationId: string; }) { try { - const [ - categoriesCount, - tagsCount, - teamMembersCount, - custodiesCount, - customFieldsCount, - ] = await Promise.all([ - db.category.count({ - where: { - organizationId, - name: { - notIn: defaultUserCategories.map((uc) => uc.name), + const [categoriesCount, tagsCount, teamMembersCount, customFieldsCount] = + await Promise.all([ + db.category.count({ + where: { + organizationId, + name: { + notIn: defaultUserCategories.map((uc) => uc.name), + }, }, - }, - }), - - db.tag.count({ - where: { organizationId }, - }), + }), - db.teamMember.count({ - where: { organizationId }, - }), + db.tag.count({ + where: { organizationId }, + }), - db.teamMember.count({ - where: { - organizationId, - custodies: { some: {} }, - }, - }), + db.teamMember.count({ + where: { organizationId }, + }), - db.customField.count({ - where: { - organizationId, - deletedAt: null, - }, - }), - ]); + db.customField.count({ + where: { + organizationId, + deletedAt: null, + }, + }), + ]); return { - hasAssets, hasCategories: categoriesCount > 0, hasTags: tagsCount > 0, hasTeamMembers: teamMembersCount > 0, - hasCustodies: custodiesCount > 0, hasCustomFields: customFieldsCount > 0, }; } catch (cause) { diff --git a/apps/webapp/test/components/booking/booking-assets-sidebar.test.tsx b/apps/webapp/test/components/booking/booking-assets-sidebar.test.tsx new file mode 100644 index 0000000000..3f07242108 --- /dev/null +++ b/apps/webapp/test/components/booking/booking-assets-sidebar.test.tsx @@ -0,0 +1,364 @@ +/** + * BookingAssetsSidebar — dual-mode (eager vs lazy) unit tests + * + * The sidebar sources its rows two ways (see the component's file-level + * doc): callers that ship `booking.bookingAssets` inline render eagerly + * with zero fetching, while the bookings index omits the payload and the + * sheet lazily fetches `/api/bookings/:bookingId/assets-sidebar` on open. + * These tests pin the observable contract of both modes: + * + * - Eager: rows render straight from the prop, no fetcher traffic. + * - Lazy: exactly one fetch per open, spinner while in flight, rows + * once the payload lands. + * - Lazy reopen: rows never duplicate; the previous payload renders + * immediately (no spinner flash) while the deliberate freshness + * re-fetch fires in the background. + * - Trigger state: a booking with zero concrete assets but outstanding + * model reservations is still openable (Book-by-Model), while a + * booking with nothing to show keeps an inert trigger. + * + * @see {@link file://./../../../app/components/booking/booking-assets-sidebar.tsx} + * @see {@link file://./../../../app/routes/api+/bookings.$bookingId.assets-sidebar.ts} + */ + +import type { ComponentProps, ReactNode } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + BookingAssetsSidebar, + type DispositionBreakdown, + type SidebarBookingAssets, + type SidebarModelRequest, +} from "~/components/booking/booking-assets-sidebar"; + +/** + * Shape of a successful `/api/bookings/:bookingId/assets-sidebar` response + * as seen through `fetcher.data`. Mirrors `payload()` in the resource + * route (`{ error: null, ...data }`), typed off the component's own + * exports so the mock payload stays structurally in sync with what the + * sidebar actually consumes. + */ +type AssetsSidebarPayload = { + error: null; + bookingAssets: SidebarBookingAssets; + dispositionedByAsset: Record; + dispositionBreakdownByAsset: Record; + checkedOutByAsset: Record; +}; + +/** + * Settled error payload, as produced by the resource route's catch + * (`data(error(reason), { status })`) — `error` is the only key the + * component reads on this branch. + */ +type AssetsSidebarErrorPayload = { error: { message: string } }; + +/** The slice of the fetcher API the sidebar actually touches. */ +type FetcherStub = { + state: "idle" | "loading" | "submitting"; + data: AssetsSidebarPayload | AssetsSidebarErrorPayload | undefined; + load: ReturnType; + submit: ReturnType; +}; + +function createFetcherStub(): FetcherStub { + return { + state: "idle", + data: undefined, + // The component `void`s the returned promise, so resolve immediately. + load: vi.fn(() => Promise.resolve()), + submit: vi.fn(), + }; +} + +/** + * Mutable per-test fetcher stub. Tests mutate `fetcherStub.data` to + * simulate the fetch resolving, then re-render (the real fetcher triggers + * that re-render itself when data lands). + */ +let fetcherStub: FetcherStub = createFetcherStub(); + +// why: the sidebar's lazy mode is driven by `useFetcher` — mocking it lets +// tests assert "no request fired" / "exactly one request" and hand-feed +// the resolved payload without spinning up a data router. `Link` is +// swapped for a plain anchor for the same reason (the "Scan to assign" +// link and the asset-title `Button to=` both need a router at runtime). +vi.mock("react-router", async () => { + const actual = + await vi.importActual("react-router"); + + return { + ...actual, + useFetcher: () => fetcherStub, + Link: ({ to, children, ...rest }: ComponentProps<"a"> & { to: string }) => ( + + {children} + + ), + }; +}); + +// why: useCurrentOrganization reads the `_layout` route's loader data via +// useRouteLoaderData, which requires a data-router context these tests +// don't mount. Returning undefined exercises the component's documented +// no-org branch (display-code chips are skipped) — irrelevant to the +// dual-mode behavior under test. +vi.mock("~/hooks/use-current-organization", () => ({ + useCurrentOrganization: () => undefined, +})); + +// why: the real AssetImage wires its own useFetcher for signed-URL refresh, +// which would collide with the sidebar's mocked fetcher instance (both +// callers would receive the same stub and AssetImage would misread the +// sidebar payload). A bare keeps each row's image slot inert. +vi.mock("~/components/assets/asset-image", () => ({ + AssetImage: ({ alt }: { alt: string }) => {alt}, +})); + +type SidebarBooking = ComponentProps["booking"]; + +/** + * Minimal, type-correct `BookingAsset` pivot row: a standalone (no kit) + * INDIVIDUAL asset — the simplest shape `groupAssets` renders as one + * individual row. + */ +function buildBookingAsset({ + id, + title, +}: { + id: string; + title: string; +}): SidebarBookingAssets[number] { + return { + id: `ba-${id}`, + quantity: 1, + assetKitId: null, + asset: { + id, + title, + type: "INDIVIDUAL", + availableToBook: true, + custody: [], + status: "AVAILABLE", + mainImage: null, + thumbnailImage: null, + mainImageExpiration: null, + sequentialId: null, + preferredBarcodeId: null, + qrCodes: [], + barcodes: [], + category: null, + assetKits: [], + }, + }; +} + +/** Outstanding (unfulfilled) Book-by-Model reservation: 2 of 3 remaining. */ +function buildModelRequest( + overrides?: Partial +): SidebarModelRequest { + return { + id: "mreq-1", + assetModelId: "model-1", + quantity: 3, + fulfilledQuantity: 1, + fulfilledAt: null, + assetModel: { id: "model-1", name: "Sony A7 IV" }, + ...overrides, + }; +} + +function buildBooking(overrides?: Partial): SidebarBooking { + return { + id: "booking-1", + name: "Studio session", + status: "RESERVED", + ...overrides, + }; +} + +/** Successful lazy-fetch payload with empty qty-progress maps. */ +function buildPayload( + bookingAssets: SidebarBookingAssets +): AssetsSidebarPayload { + return { + error: null, + bookingAssets, + dispositionedByAsset: {}, + dispositionBreakdownByAsset: {}, + checkedOutByAsset: {}, + }; +} + +/** The lazy path's loading indicator (shared `Spinner`, class-based). */ +function querySpinner() { + return document.querySelector(".spinner"); +} + +describe("BookingAssetsSidebar", () => { + beforeEach(() => { + fetcherStub = createFetcherStub(); + }); + + it("eager mode: renders rows from the inline payload without firing a fetch", async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole("button", { name: "2 assets" })); + + expect(screen.getByText(/Assets in "Studio session"/)).toBeInTheDocument(); + expect(screen.getByText("Camera A")).toBeInTheDocument(); + expect(screen.getByText("Tripod B")).toBeInTheDocument(); + expect(screen.getByText("2 items")).toBeInTheDocument(); + // Eager data is instant: no spinner, and the lazy endpoint is never hit. + expect(querySpinner()).not.toBeInTheDocument(); + expect(fetcherStub.load).not.toHaveBeenCalled(); + }); + + it("lazy mode: fetches exactly once on open, shows a spinner, then renders rows", async () => { + const user = userEvent.setup(); + // Bookings-index shape: `_count` for the trigger label, no pivots. + const booking = buildBooking({ _count: { bookingAssets: 2 } }); + const { rerender } = render(); + + await user.click(screen.getByRole("button", { name: "2 assets" })); + + expect(fetcherStub.load).toHaveBeenCalledTimes(1); + expect(fetcherStub.load).toHaveBeenCalledWith( + "/api/bookings/booking-1/assets-sidebar" + ); + // In flight: spinner shows, no rows yet. + expect(querySpinner()).toBeInTheDocument(); + expect(screen.queryByText("Camera A")).not.toBeInTheDocument(); + + // Resolve the fetch. The real fetcher re-renders the component when + // data lands; with the stub we mutate + re-render explicitly. + fetcherStub.data = buildPayload([ + buildBookingAsset({ id: "asset-1", title: "Camera A" }), + buildBookingAsset({ id: "asset-2", title: "Tripod B" }), + ]); + rerender(); + + expect(querySpinner()).not.toBeInTheDocument(); + expect(screen.getByText("Camera A")).toBeInTheDocument(); + expect(screen.getByText("Tripod B")).toBeInTheDocument(); + expect(screen.getByText("2 items")).toBeInTheDocument(); + }); + + it("lazy mode: closing and reopening renders each row once (refresh, not append)", async () => { + const user = userEvent.setup(); + const booking = buildBooking({ _count: { bookingAssets: 1 } }); + const { rerender } = render(); + + // First open + resolved fetch. + await user.click(screen.getByRole("button", { name: "1 assets" })); + fetcherStub.data = buildPayload([ + buildBookingAsset({ id: "asset-1", title: "Camera A" }), + ]); + rerender(); + expect(screen.getAllByText("Camera A")).toHaveLength(1); + + // Close via the sheet's X — content unmounts. + await user.click(screen.getByRole("button", { name: /close/i })); + expect(screen.queryByText("Camera A")).not.toBeInTheDocument(); + + // Reopen: the retained payload renders immediately (no spinner flash) + // and rows are NOT duplicated. The component deliberately re-fetches + // on each open for freshness — pin that too. + await user.click(screen.getByRole("button", { name: "1 assets" })); + expect(screen.getAllByText("Camera A")).toHaveLength(1); + expect(querySpinner()).not.toBeInTheDocument(); + expect(fetcherStub.load).toHaveBeenCalledTimes(2); + }); + + it("lazy mode: a settled error shows the error state with retry instead of an endless spinner", async () => { + const user = userEvent.setup(); + const booking = buildBooking({ _count: { bookingAssets: 2 } }); + const { rerender } = render(); + + await user.click(screen.getByRole("button", { name: "2 assets" })); + // Fetch settles with an error payload (booking deleted / permission + // lost between page load and drawer open). + fetcherStub.data = { error: { message: "Booking not found" } }; + rerender(); + + expect(querySpinner()).not.toBeInTheDocument(); + expect( + screen.getByText("Failed to load the booking's assets.") + ).toBeInTheDocument(); + + // Retry re-fires the fetch: once on open, once from the button. + await user.click(screen.getByRole("button", { name: "Try again" })); + expect(fetcherStub.load).toHaveBeenCalledTimes(2); + expect(fetcherStub.load).toHaveBeenLastCalledWith( + "/api/bookings/booking-1/assets-sidebar" + ); + }); + + it("zero concrete assets + outstanding model requests: trigger stays openable and renders the reservations section", async () => { + const user = userEvent.setup(); + render( + + ); + + // 0 concrete assets, but the outstanding reservation keeps the + // trigger clickable (`hasItems` counts unfulfilled model requests). + await user.click(screen.getByRole("button", { name: "0 assets" })); + + // quantity 3 − fulfilled 1 = 2 remaining across 1 model. + expect( + screen.getByText("Unassigned model reservations (2)") + ).toBeInTheDocument(); + expect(screen.getByText("Sony A7 IV")).toBeInTheDocument(); + expect(screen.getByText("2 remaining")).toBeInTheDocument(); + // RESERVED is scan-to-assign eligible. + expect( + screen.getByRole("link", { name: "Scan to assign" }) + ).toHaveAttribute("href", "/bookings/booking-1/overview/scan-assets"); + // The empty-but-present eager payload means: no lazy fetch, no + // spinner, and an empty assets table below the reservations. + expect(screen.getByText("0 items")).toBeInTheDocument(); + expect(querySpinner()).not.toBeInTheDocument(); + expect(fetcherStub.load).not.toHaveBeenCalled(); + }); + + it("zero assets + only fulfilled model requests: trigger is inert and the sheet never opens", async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole("button", { name: "0 assets" })); + + expect(screen.queryByText(/Assets in/)).not.toBeInTheDocument(); + expect(fetcherStub.load).not.toHaveBeenCalled(); + }); +}); From cf8d05157030a3c331395abeb91cd7ca28f9781e Mon Sep 17 00:00:00 2001 From: Carlos Virreira Date: Thu, 23 Jul 2026 14:45:44 +0200 Subject: [PATCH 2/4] fix(webapp): address review feedback on loader-slimming PR - co-locate the sidebar dual-mode test with its component (repo test convention; flagged by Codex review) - home custodian merge: pass takeCap 1000 so the ONGOING/OVERDUE merge sees every booking instead of the perPage-clamped first 20 (flagged by CodeRabbit; uses the takeCap primitive this PR introduces) - assets-index query test: assert direct sort-key gating against the sliced cheap phase instead of the full SQL, where the heavy lateral also matches (CodeRabbit nitpick); default sort now provably omits value/quantity from the slim CTE --- .../booking/booking-assets-sidebar.test.tsx | 11 ++++---- .../app/modules/asset/query.server.test.ts | 27 +++++++++++++++---- apps/webapp/app/routes/_layout+/home.tsx | 5 +++- 3 files changed, 32 insertions(+), 11 deletions(-) rename apps/webapp/{test => app}/components/booking/booking-assets-sidebar.test.tsx (96%) diff --git a/apps/webapp/test/components/booking/booking-assets-sidebar.test.tsx b/apps/webapp/app/components/booking/booking-assets-sidebar.test.tsx similarity index 96% rename from apps/webapp/test/components/booking/booking-assets-sidebar.test.tsx rename to apps/webapp/app/components/booking/booking-assets-sidebar.test.tsx index 3f07242108..32afa1650e 100644 --- a/apps/webapp/test/components/booking/booking-assets-sidebar.test.tsx +++ b/apps/webapp/app/components/booking/booking-assets-sidebar.test.tsx @@ -17,11 +17,11 @@ * model reservations is still openable (Book-by-Model), while a * booking with nothing to show keeps an inert trigger. * - * @see {@link file://./../../../app/components/booking/booking-assets-sidebar.tsx} - * @see {@link file://./../../../app/routes/api+/bookings.$bookingId.assets-sidebar.ts} + * @see {@link file://./booking-assets-sidebar.tsx} + * @see {@link file://./../../routes/api+/bookings.$bookingId.assets-sidebar.ts} */ -import type { ComponentProps, ReactNode } from "react"; +import type { ComponentProps } from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -86,8 +86,9 @@ let fetcherStub: FetcherStub = createFetcherStub(); // swapped for a plain anchor for the same reason (the "Scan to assign" // link and the asset-title `Button to=` both need a router at runtime). vi.mock("react-router", async () => { - const actual = - await vi.importActual("react-router"); + // Typed as a plain record: the module type can't be named here without a + // namespace import of react-router, which trips no-restricted-imports. + const actual = await vi.importActual>("react-router"); return { ...actual, diff --git a/apps/webapp/app/modules/asset/query.server.test.ts b/apps/webapp/app/modules/asset/query.server.test.ts index 25d730b024..20dd1c64cf 100644 --- a/apps/webapp/app/modules/asset/query.server.test.ts +++ b/apps/webapp/app/modules/asset/query.server.test.ts @@ -1110,12 +1110,29 @@ describe("buildAdvancedAssetsQuery", () => { expect(sql).toContain('ORDER BY saq."__sortRank"'); }); - it("keeps the slim cheap phase to id + light sort keys (no heavy projection)", () => { - const sql = getQuerySqlString(build()); + it("gates direct sort-key columns in the cheap phase on the active sort", () => { + // The heavy lateral also emits `assetValue`/`assetQuantity` aliases, so a + // full-SQL assertion can't catch a cheap-phase regression — slice the + // CHEAP phase (everything before `sorted_asset_query`) like the name-sort + // test below. + const cheap = (overrides?: Parameters[0]) => { + const sql = getQuerySqlString(build(overrides)); + return sql.slice(0, sql.indexOf("sorted_asset_query")); + }; - // Base sort keys are always selected directly off the scan. - expect(sql).toContain('a.value AS "assetValue"'); - expect(sql).toContain('a.quantity AS "assetQuantity"'); + // Default sort materializes only its own keys — value/quantity stay out. + const def = cheap({ sortBy: [] }); + expect(def).toContain('a."createdAt" AS "assetCreatedAt"'); + expect(def).not.toContain('a.value AS "assetValue"'); + expect(def).not.toContain('a.quantity AS "assetQuantity"'); + + // An active sort pulls exactly its key back into the slim SELECT. + expect(cheap({ sortBy: ["valuation:asc"] })).toContain( + 'a.value AS "assetValue"' + ); + expect(cheap({ sortBy: ["quantity:asc"] })).toContain( + 'a.quantity AS "assetQuantity"' + ); }); it("gates a name-sort column in the cheap phase on the active sort", () => { diff --git a/apps/webapp/app/routes/_layout+/home.tsx b/apps/webapp/app/routes/_layout+/home.tsx index f6a93ae0ff..cbdfdf0e9f 100644 --- a/apps/webapp/app/routes/_layout+/home.tsx +++ b/apps/webapp/app/routes/_layout+/home.tsx @@ -206,7 +206,10 @@ export async function loader({ context, request }: LoaderFunctionArgs) { organizationId, userId, page: 1, - perPage: 1000, + // `perPage` clamps to 20; `takeCap` is the bounded escape hatch so + // the custodian merge sees every ONGOING/OVERDUE booking, not the + // first page of them. + takeCap: 1000, statuses: ["ONGOING", "OVERDUE"], includeAssets: false, extraInclude: { From 119415dbc7c05a0e1d1aac0ecfd1f302d22d73b9 Mon Sep 17 00:00:00 2001 From: Carlos Virreira Date: Thu, 23 Jul 2026 14:57:51 +0200 Subject: [PATCH 3/4] test(webapp): assert inactive sort keys stay out of the slim CTE Follow-up to CodeRabbit's re-review: inclusion-only assertions would pass even if gating regressed to all-or-nothing. Valuation sort legitimately selects BOTH value and quantity (it orders by total value = assetValue * assetQuantity), so the exclusion is asserted against an unrelated key there, and against value on the single-key quantity sort. --- .../app/modules/asset/query.server.test.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/modules/asset/query.server.test.ts b/apps/webapp/app/modules/asset/query.server.test.ts index 20dd1c64cf..010b0cdc08 100644 --- a/apps/webapp/app/modules/asset/query.server.test.ts +++ b/apps/webapp/app/modules/asset/query.server.test.ts @@ -1126,13 +1126,18 @@ describe("buildAdvancedAssetsQuery", () => { expect(def).not.toContain('a.value AS "assetValue"'); expect(def).not.toContain('a.quantity AS "assetQuantity"'); - // An active sort pulls exactly its key back into the slim SELECT. - expect(cheap({ sortBy: ["valuation:asc"] })).toContain( - 'a.value AS "assetValue"' - ); - expect(cheap({ sortBy: ["quantity:asc"] })).toContain( - 'a.quantity AS "assetQuantity"' - ); + // Sorting by valuation orders by TOTAL value (assetValue * + // assetQuantity), so both keys legitimately enter the slim SELECT — + // but unrelated keys must stay out (catches all-or-nothing gating). + const valuationSort = cheap({ sortBy: ["valuation:asc"] }); + expect(valuationSort).toContain('a.value AS "assetValue"'); + expect(valuationSort).toContain('a.quantity AS "assetQuantity"'); + expect(valuationSort).not.toContain('a.title AS "assetTitle"'); + + // A single-key sort pulls exactly its key back in, nothing else. + const quantitySort = cheap({ sortBy: ["quantity:asc"] }); + expect(quantitySort).toContain('a.quantity AS "assetQuantity"'); + expect(quantitySort).not.toContain('a.value AS "assetValue"'); }); it("gates a name-sort column in the cheap phase on the active sort", () => { From 0022368818d391b0f531799edc75c44154280b55 Mon Sep 17 00:00:00 2001 From: Carlos Virreira Date: Fri, 31 Jul 2026 14:59:06 +0200 Subject: [PATCH 4/4] chore: drop the perf seeder from the PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seed-perf.ts is an operator-only script for the local perf rig, not product code — it was swept in by a blanket stage on the merge commit. Removing it also resolves the unused-import lint finding CodeRabbit raised against it. --- apps/webapp/scripts/seed-perf.ts | 1045 ------------------------------ 1 file changed, 1045 deletions(-) delete mode 100644 apps/webapp/scripts/seed-perf.ts diff --git a/apps/webapp/scripts/seed-perf.ts b/apps/webapp/scripts/seed-perf.ts deleted file mode 100644 index 4075cdad49..0000000000 --- a/apps/webapp/scripts/seed-perf.ts +++ /dev/null @@ -1,1045 +0,0 @@ -/** - * Performance-testing seeder (p95 rig). - * - * Fills a COMPLETELY EMPTY database (fresh `prisma migrate deploy`) with a - * single TEAM workspace holding enough volume to exercise p95 latencies: - * - * 1 User (id/email from env) + 1 TEAM Organization "Perf Test Org" - * 30 categories, 60 tags, 50 locations, 8 custom fields - * 120 kits, 5000 assets (~85% INDIVIDUAL / ~15% QUANTITY_TRACKED) - * 26 team members (25 non-registered + owner), ~600 custodies - * 400 bookings (status mix) with 5-40 assets each - * 1 Qr per asset, ~15000 notes, ~3000 scans - * - * Required env vars (besides DATABASE_URL / DIRECT_URL): - * SEED_USER_ID - uuid of the primary user (Supabase auth id) - * SEED_USER_EMAIL - email of the primary user - * - * Run from apps/webapp: - * ../../node_modules/.bin/dotenv -e ../../.env -- npx tsx scripts/seed-perf.ts - * - * Design notes: - * - Deterministic: all randomness flows through a seeded mulberry32 RNG and a - * counter-based cuid-shaped id generator, so re-runs produce the same shape. - * - createMany in chunks of 500-1000 everywhere; the only nested creates are - * the User and the Organization (which mirrors `createOrganization` in - * app/modules/organization/service.server.ts: UserOrganization OWNER row, - * owner TeamMember, AssetIndexSettings, WorkingHours, BookingSettings). - * - Asset<->Tag is Prisma-implicit m2m; rows go straight into "_AssetToTag" - * via $executeRaw (columns "A" = assetId, "B" = tagId, verified against - * migration 20230613115426_adding_tags_model_to_db). - * - Sequential asset ids are written directly (SAM-0001...) and the org's - * Postgres sequence is then advanced via reset_asset_sequence_for_org() - * so app-side asset creation doesn't collide. - * - Respects DB triggers: INDIVIDUAL assets get at most one AssetKit row, - * one AssetLocation row and one Custody row; QUANTITY_TRACKED pivot - * quantities never exceed Asset.quantity. - * - * @see {@link file://./seed-reporting-demo.ts} - conventions mirrored here - * @see {@link file://../../../packages/database/prisma/schema.prisma} - */ - -import { Prisma } from "@prisma/client"; - -import { createDatabaseClient } from "@shelf/database"; -import type { ExtendedPrismaClient } from "@shelf/database"; - -/* ------------------------------------------------------------------ */ -/* Targets */ -/* ------------------------------------------------------------------ */ - -const TARGETS = { - categories: 30, - tags: 60, - locations: 50, - customFields: 8, - kits: 120, - assets: 5_000, - nonRegisteredTeamMembers: 25, - custodies: 600, - bookings: 400, - notes: 15_000, - scans: 3_000, -} as const; - -/** Fractions used when decorating assets. */ -const FRACTIONS = { - quantityTracked: 0.15, - inKit: 0.4, - withCategory: 0.7, - withTags: 0.5, - withLocation: 0.6, - withCustomFieldValues: 0.6, -} as const; - -/** Booking status mix (sums to TARGETS.bookings). */ -const BOOKING_STATUS_PLAN: Array<{ - status: Prisma.BookingCreateManyInput["status"]; - count: number; -}> = [ - { status: "COMPLETE", count: 100 }, - { status: "ONGOING", count: 50 }, - { status: "RESERVED", count: 80 }, - { status: "DRAFT", count: 100 }, - { status: "OVERDUE", count: 30 }, - { status: "CANCELLED", count: 25 }, - { status: "ARCHIVED", count: 15 }, -]; - -const BATCH_SIZE = 1_000; -const RNG_SEED = 20260723; // fixed; change to reshuffle the data - -/* ------------------------------------------------------------------ */ -/* Deterministic randomness + ids */ -/* ------------------------------------------------------------------ */ - -/** mulberry32 - tiny deterministic PRNG, no external deps. */ -function mulberry32(seed: number): () => number { - let a = seed >>> 0; - return function () { - a |= 0; - a = (a + 0x6d2b79f5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -const rng = mulberry32(RNG_SEED); - -const BASE36 = "abcdefghijklmnopqrstuvwxyz0123456789"; -let idCounter = 0; - -/** - * Deterministic cuid-shaped id: "c" + 8-char base36 counter + 16 random - * base36 chars = 25 chars, passes zod's `.cuid()` shape check and is - * collision-free thanks to the embedded counter. - */ -function pid(): string { - idCounter += 1; - const counterPart = idCounter.toString(36).padStart(8, "0"); - let rand = ""; - for (let i = 0; i < 16; i++) { - rand += BASE36[Math.floor(rng() * BASE36.length)]; - } - return `c${counterPart}${rand}`; -} - -/** Integer in [min, max] inclusive. */ -function randInt(min: number, max: number): number { - return min + Math.floor(rng() * (max - min + 1)); -} - -function chance(p: number): boolean { - return rng() < p; -} - -function pick(arr: readonly T[]): T { - if (arr.length === 0) throw new Error("pick: empty array"); - return arr[Math.floor(rng() * arr.length)]; -} - -/** n distinct items via partial Fisher-Yates (caps at arr.length). */ -function pickN(arr: readonly T[], n: number): T[] { - const size = Math.min(n, arr.length); - if (size === 0) return []; - const pool = arr.slice(); - const out: T[] = []; - for (let i = 0; i < size; i++) { - const j = i + Math.floor(rng() * (pool.length - i)); - [pool[i], pool[j]] = [pool[j], pool[i]]; - out.push(pool[i]); - } - return out; -} - -const DAY_MS = 24 * 60 * 60 * 1000; - -/** Random date in [start, end]. */ -function randomDate(start: Date, end: Date): Date { - return new Date( - start.getTime() + rng() * (end.getTime() - start.getTime()) - ); -} - -function daysFromNow(days: number): Date { - return new Date(Date.now() + days * DAY_MS); -} - -/* ------------------------------------------------------------------ */ -/* Word lists (no faker - keep the script dependency-free) */ -/* ------------------------------------------------------------------ */ - -const ADJECTIVES = [ - "Rugged", "Compact", "Wireless", "Heavy-Duty", "Portable", "Digital", - "Industrial", "Precision", "Modular", "Ergonomic", "Refurbished", - "Calibrated", "Waterproof", "High-Torque", "Lightweight", "Certified", -]; -const NOUNS = [ - "Drill", "Camera", "Laptop", "Tripod", "Projector", "Scanner", "Monitor", - "Generator", "Microphone", "Mixer", "Router", "Sensor", "Charger", - "Toolkit", "Headset", "Lens", "Battery Pack", "Light Panel", "Cable Reel", - "Multimeter", -]; -const BRANDS = [ - "Makita", "Canon", "Dell", "Sony", "Bosch", "DeWalt", "Panasonic", - "Lenovo", "Shure", "Ubiquiti", "Fluke", "Manfrotto", -]; -const FIRST_NAMES = [ - "Alex", "Sam", "Jordan", "Casey", "Riley", "Morgan", "Taylor", "Jamie", - "Quinn", "Avery", "Dana", "Robin", "Lee", "Kim", "Pat", "Chris", "Noor", - "Ivan", "Mila", "Omar", "Lena", "Hugo", "Nina", "Piet", "Sofia", -]; -const LAST_NAMES = [ - "Johnson", "Smith", "Garcia", "Miller", "Davis", "Martinez", "Lopez", - "Wilson", "Anderson", "Thomas", "Moore", "Jackson", "deVries", "Bakker", - "Novak", "Kovacs", "Ivanov", "Petrov", "Kim", "Nguyen", -]; -const NOTE_SNIPPETS = [ - "Inspected on arrival, no visible damage.", - "Battery replaced during routine maintenance.", - "Returned late from previous booking, flagged for review.", - "Firmware updated to latest vendor release.", - "Minor cosmetic scratches on the casing.", - "Calibration certificate renewed.", - "Cleaned and repacked into protective case.", - "Reported intermittent power issue, could not reproduce.", - "Label reprinted after wear.", - "Spare parts ordered from vendor.", - "Checked against inventory list during spot audit.", - "Assigned protective sleeve and lock.", -]; -const USER_AGENTS = [ - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15", - "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/125.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Edg/125.0", - "ShelfCompanion/1.1.0 (iOS 17.5)", -]; - -/* ------------------------------------------------------------------ */ -/* AssetIndexSettings default columns */ -/* ------------------------------------------------------------------ */ - -/** - * Inlined copy of `defaultFields` from - * app/modules/asset-index-settings/helpers.ts (kept standalone so the script - * never drags webapp server modules in). Custom-field columns (`cf_`) - * are appended below, matching what the runtime column sync produces. - */ -const DEFAULT_INDEX_COLUMNS = [ - { name: "id", visible: false, position: 0 }, - { name: "sequentialId", visible: true, position: 1 }, - { name: "qrId", visible: true, position: 2 }, - { name: "status", visible: true, position: 3 }, - { name: "description", visible: true, position: 4 }, - { name: "valuation", visible: true, position: 5 }, - { name: "availableToBook", visible: true, position: 6 }, - { name: "createdAt", visible: true, position: 7 }, - { name: "updatedAt", visible: true, position: 8 }, - { name: "category", visible: true, position: 9 }, - { name: "tags", visible: true, position: 10 }, - { name: "location", visible: true, position: 11 }, - { name: "kit", visible: true, position: 12 }, - { name: "custody", visible: true, position: 13 }, - { name: "upcomingReminder", visible: true, position: 14 }, - { name: "actions", visible: true, position: 15 }, - { name: "upcomingBookings", visible: true, position: 16 }, - { name: "quantity", visible: false, position: 17 }, - { name: "type", visible: false, position: 18 }, - { name: "assetModel", visible: false, position: 19 }, -]; - -/* ------------------------------------------------------------------ */ -/* Custom field plan */ -/* ------------------------------------------------------------------ */ - -type CustomFieldPlan = { - id: string; - name: string; - type: NonNullable; - options: string[]; -}; - -const CONDITION_OPTIONS = ["New", "Good", "Fair", "Poor"]; -const DEPARTMENT_OPTIONS = [ - "Engineering", - "Facilities", - "Media", - "Field Ops", - "IT", -]; - -/** 8 org-scoped custom fields: 3x TEXT, 2x DATE, 2x OPTION, 1x BOOLEAN. */ -function buildCustomFieldPlans(): CustomFieldPlan[] { - return [ - { id: pid(), name: "Serial Number", type: "TEXT", options: [] }, - { id: pid(), name: "Vendor", type: "TEXT", options: [] }, - { id: pid(), name: "PO Number", type: "TEXT", options: [] }, - { id: pid(), name: "Purchase Date", type: "DATE", options: [] }, - { id: pid(), name: "Warranty Expiry", type: "DATE", options: [] }, - { id: pid(), name: "Condition", type: "OPTION", options: CONDITION_OPTIONS }, - { id: pid(), name: "Department", type: "OPTION", options: DEPARTMENT_OPTIONS }, - { id: pid(), name: "Insured", type: "BOOLEAN", options: [] }, - ]; -} - -/** - * Build the JSON value for an AssetCustomFieldValue row. Shapes mirror - * `buildCustomFieldValue` in app/utils/custom-fields.ts and satisfy the - * `ensure_value_structure_and_types` CHECK constraint (raw + one typed key). - */ -function buildCfValue(cf: CustomFieldPlan): Prisma.InputJsonValue { - switch (cf.type) { - case "TEXT": { - const text = - cf.name === "Serial Number" - ? `SN-${randInt(100000, 999999)}-${pick(BASE36.split("")).toUpperCase()}` - : cf.name === "Vendor" - ? pick(BRANDS) - : `PO-${randInt(2023, 2026)}-${randInt(1000, 9999)}`; - return { raw: text, valueText: text }; - } - case "DATE": { - const d = randomDate(daysFromNow(-1095), daysFromNow(365)); - const dateOnly = d.toISOString().slice(0, 10); // YYYY-MM-DD - const utcMidnight = new Date(`${dateOnly}T00:00:00.000Z`); - return { raw: dateOnly, valueDate: utcMidnight.toISOString() }; - } - case "OPTION": { - const opt = pick(cf.options); - return { raw: opt, valueOption: opt }; - } - case "BOOLEAN": { - const val = chance(0.5); - return { raw: val, valueBoolean: val }; - } - default: - throw new Error(`Unhandled custom field type: ${cf.type}`); - } -} - -/* ------------------------------------------------------------------ */ -/* Batch helper */ -/* ------------------------------------------------------------------ */ - -/** Run createMany in chunks; returns total inserted. */ -async function batchedCreateMany( - rows: T[], - createMany: (chunk: T[]) => Promise<{ count: number }>, - label: string -): Promise { - let total = 0; - for (let i = 0; i < rows.length; i += BATCH_SIZE) { - const chunk = rows.slice(i, i + BATCH_SIZE); - const res = await createMany(chunk); - total += res.count; - process.stdout.write( - `\r ${label}: ${Math.min(i + BATCH_SIZE, rows.length)}/${rows.length}` - ); - } - process.stdout.write("\n"); - return total; -} - -/* ------------------------------------------------------------------ */ -/* Main */ -/* ------------------------------------------------------------------ */ - -async function main(): Promise { - const SEED_USER_ID = process.env.SEED_USER_ID; - const SEED_USER_EMAIL = process.env.SEED_USER_EMAIL; - - if (!SEED_USER_ID || !SEED_USER_EMAIL) { - console.error( - "Missing env vars. Set SEED_USER_ID (uuid) and SEED_USER_EMAIL." - ); - process.exit(1); - } - if (!/^[0-9a-f-]{36}$/i.test(SEED_USER_ID)) { - console.error(`SEED_USER_ID does not look like a uuid: ${SEED_USER_ID}`); - process.exit(1); - } - if (!process.env.DATABASE_URL) { - console.error("DATABASE_URL is not set. Run via dotenv -e ../../.env."); - process.exit(1); - } - - const db = createDatabaseClient(); - const startedAt = Date.now(); - - try { - await db.$connect(); - - // Idempotence guard: this seeder assumes an empty DB. - const existingUser = await db.user.findUnique({ - where: { id: SEED_USER_ID }, - select: { id: true }, - }); - if (existingUser) { - throw new Error( - `User ${SEED_USER_ID} already exists. This seeder expects a freshly ` + - "migrated, empty database. Reset the DB and re-run." - ); - } - - /* ---------------------------------------------------------- */ - /* 1. Primary user */ - /* ---------------------------------------------------------- */ - console.log("1/12 Creating primary user..."); - const username = - SEED_USER_EMAIL.split("@")[0].replace(/[^a-zA-Z0-9._-]/g, "") + "-perf"; - await db.user.create({ - data: { - id: SEED_USER_ID, - email: SEED_USER_EMAIL, - username, - firstName: "Perf", - lastName: "Tester", - onboarded: true, - // Bookings & multi-workspace are premium-gated; keep the perf user - // out of paywalls regardless of ENABLE_PREMIUM_FEATURES. - tier: { connect: { id: "tier_2" } }, - skipSubscriptionCheck: true, - // Master-data Role rows are inserted by migration - // 20240422181938_create_default_user_roles. - roles: { connect: { name: "USER" } }, - }, - }); - - /* ---------------------------------------------------------- */ - /* 2. Organization + default org rows */ - /* ---------------------------------------------------------- */ - console.log("2/12 Creating TEAM organization with default org rows..."); - const customFieldPlans = buildCustomFieldPlans(); - const indexColumns = [ - ...DEFAULT_INDEX_COLUMNS, - ...customFieldPlans.map((cf, i) => ({ - name: `cf_${cf.name}`, - visible: true, - position: DEFAULT_INDEX_COLUMNS.length + i, - cfType: cf.type, - })), - ]; - - // Mirrors createOrganization() in app/modules/organization/service.server.ts - const org = await db.organization.create({ - data: { - name: "Perf Test Org", - type: "TEAM", - currency: "USD", - hasSequentialIdsMigrated: true, - owner: { connect: { id: SEED_USER_ID } }, - userOrganizations: { - create: { userId: SEED_USER_ID, roles: ["OWNER"] }, - }, - members: { - create: { - name: "Perf Tester (Owner)", - user: { connect: { id: SEED_USER_ID } }, - }, - }, - assetIndexSettings: { - create: { - mode: "ADVANCED", - columns: indexColumns, - user: { connect: { id: SEED_USER_ID } }, - }, - }, - workingHours: { - // weeklySchedule falls back to the schema default (Mon-Fri 9-5) - create: { enabled: false }, - }, - bookingSettings: { - create: { bufferStartTime: 0 }, - }, - }, - select: { id: true }, - }); - const orgId = org.id; - - const ownerTeamMember = await db.teamMember.findFirstOrThrow({ - where: { organizationId: orgId, userId: SEED_USER_ID }, - select: { id: true }, - }); - - /* ---------------------------------------------------------- */ - /* 3. Taxonomy: categories, tags, locations, custom fields */ - /* ---------------------------------------------------------- */ - console.log("3/12 Creating taxonomy (categories, tags, locations, custom fields)..."); - - const categoryIds: string[] = []; - const categoryRows: Prisma.CategoryCreateManyInput[] = []; - for (let i = 0; i < TARGETS.categories; i++) { - const id = pid(); - categoryIds.push(id); - categoryRows.push({ - id, - name: `${pick(ADJECTIVES)} ${pick(NOUNS)}s ${i + 1}`, - description: `Perf category #${i + 1}`, - color: `#${randInt(0, 0xffffff).toString(16).padStart(6, "0")}`, - userId: SEED_USER_ID, - organizationId: orgId, - }); - } - await db.category.createMany({ data: categoryRows }); - - const tagIds: string[] = []; - const tagRows: Prisma.TagCreateManyInput[] = []; - for (let i = 0; i < TARGETS.tags; i++) { - const id = pid(); - tagIds.push(id); - tagRows.push({ - id, - name: `perf-tag-${String(i + 1).padStart(2, "0")}-${pick(NOUNS).toLowerCase().replace(/\s+/g, "-")}`, - color: `#${randInt(0, 0xffffff).toString(16).padStart(6, "0")}`, - userId: SEED_USER_ID, - organizationId: orgId, - }); - } - await db.tag.createMany({ data: tagRows }); - - const locationIds: string[] = []; - const locationRows: Prisma.LocationCreateManyInput[] = []; - for (let i = 0; i < TARGETS.locations; i++) { - const id = pid(); - locationIds.push(id); - locationRows.push({ - id, - name: `Warehouse ${String.fromCharCode(65 + (i % 26))}${Math.floor(i / 26) + 1} - ${pick(NOUNS)} storage`, - description: `Perf location #${i + 1}`, - address: `${randInt(1, 999)} ${pick(LAST_NAMES)} Street, Springfield`, - userId: SEED_USER_ID, - organizationId: orgId, - }); - } - await db.location.createMany({ data: locationRows }); - - await db.customField.createMany({ - data: customFieldPlans.map((cf) => ({ - id: cf.id, - name: cf.name, - helpText: `Perf custom field (${cf.type})`, - type: cf.type, - options: cf.options, - required: false, - active: true, - userId: SEED_USER_ID, - organizationId: orgId, - })), - }); - console.log( - ` ${TARGETS.categories} categories, ${TARGETS.tags} tags, ` + - `${TARGETS.locations} locations, ${TARGETS.customFields} custom fields` - ); - - /* ---------------------------------------------------------- */ - /* 4. Team members */ - /* ---------------------------------------------------------- */ - console.log("4/12 Creating team members..."); - const teamMemberIds: string[] = [ownerTeamMember.id]; - const teamMemberRows: Prisma.TeamMemberCreateManyInput[] = []; - for (let i = 0; i < TARGETS.nonRegisteredTeamMembers; i++) { - const id = pid(); - teamMemberIds.push(id); - teamMemberRows.push({ - id, - name: `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`, - organizationId: orgId, - userId: null, - }); - } - await db.teamMember.createMany({ data: teamMemberRows }); - console.log(` ${teamMemberIds.length} team members (incl. owner)`); - - /* ---------------------------------------------------------- */ - /* 5. Kits */ - /* ---------------------------------------------------------- */ - console.log("5/12 Creating kits..."); - const kitIds: string[] = []; - const kitRows: Prisma.KitCreateManyInput[] = []; - for (let i = 0; i < TARGETS.kits; i++) { - const id = pid(); - kitIds.push(id); - kitRows.push({ - id, - name: `Kit ${String(i + 1).padStart(3, "0")} - ${pick(ADJECTIVES)} ${pick(NOUNS)} set`, - description: `Perf kit #${i + 1}`, - status: "AVAILABLE", - organizationId: orgId, - createdById: SEED_USER_ID, - categoryId: chance(0.5) ? pick(categoryIds) : null, - // locationId intentionally left null: kit locations would require - // kit-driven AssetLocation rows to stay app-consistent. - }); - } - await db.kit.createMany({ data: kitRows }); - console.log(` ${kitIds.length} kits`); - - /* ---------------------------------------------------------- */ - /* 6. Assets + Qr codes + pivots + custom field values + tags */ - /* ---------------------------------------------------------- */ - console.log("6/12 Creating assets (+Qr, kit/location pivots, custom field values, tags)..."); - - type AssetPlan = { - id: string; - isQuantityTracked: boolean; - quantity: number | null; - inKit: boolean; - }; - - const assetPlans: AssetPlan[] = []; - const assetRows: Prisma.AssetCreateManyInput[] = []; - const qrRows: Prisma.QrCreateManyInput[] = []; - const assetKitRows: Prisma.AssetKitCreateManyInput[] = []; - const assetLocationRows: Prisma.AssetLocationCreateManyInput[] = []; - const cfValueRows: Prisma.AssetCustomFieldValueCreateManyInput[] = []; - const tagPairs: Array<[assetId: string, tagId: string]> = []; - const qrIds: string[] = []; - - const assetCreatedStart = daysFromNow(-365); - const assetCreatedEnd = daysFromNow(-1); - - for (let i = 0; i < TARGETS.assets; i++) { - const id = pid(); - const isQuantityTracked = chance(FRACTIONS.quantityTracked); - const quantity = isQuantityTracked ? randInt(5, 100) : null; - const inKit = chance(FRACTIONS.inKit); - assetPlans.push({ id, isQuantityTracked, quantity, inKit }); - - const seq = `SAM-${String(i + 1).padStart(4, "0")}`; - assetRows.push({ - id, - title: `${pick(BRANDS)} ${pick(ADJECTIVES)} ${pick(NOUNS)} ${seq}`, - description: chance(0.8) - ? `Perf asset ${seq}. ${pick(NOTE_SNIPPETS)}` - : null, - status: "AVAILABLE", // custody / checkout statuses applied later - valuation: chance(0.7) ? randInt(25, 8000) : null, - availableToBook: chance(0.92), - sequentialId: seq, - type: isQuantityTracked ? "QUANTITY_TRACKED" : "INDIVIDUAL", - quantity, - minQuantity: isQuantityTracked && chance(0.5) ? randInt(1, 5) : null, - consumptionType: isQuantityTracked - ? chance(0.7) - ? "TWO_WAY" - : "ONE_WAY" - : null, - unitOfMeasure: isQuantityTracked - ? pick(["pcs", "boxes", "liters", "meters"]) - : null, - createdAt: randomDate(assetCreatedStart, assetCreatedEnd), - userId: SEED_USER_ID, - organizationId: orgId, - categoryId: chance(FRACTIONS.withCategory) ? pick(categoryIds) : null, - }); - - // One Qr per asset (mirrors app-side QR creation: version 0, EC "L"). - const qrId = pid(); - qrIds.push(qrId); - qrRows.push({ - id: qrId, - version: 0, - errorCorrection: "L", - assetId: id, - userId: SEED_USER_ID, - organizationId: orgId, - }); - - // Kit membership via AssetKit pivot. INDIVIDUAL assets: one kit max, - // quantity 1 (DB trigger enforced). QUANTITY_TRACKED: single kit row - // with quantity <= Asset.quantity (deferred sum trigger). - if (inKit) { - assetKitRows.push({ - id: pid(), - assetId: id, - kitId: pick(kitIds), - organizationId: orgId, - quantity: isQuantityTracked - ? randInt(1, Math.min(quantity as number, 5)) - : 1, - }); - } - - // Location via AssetLocation pivot (no Asset.locationId FK exists). - if (chance(FRACTIONS.withLocation)) { - assetLocationRows.push({ - id: pid(), - assetId: id, - locationId: pick(locationIds), - organizationId: orgId, - quantity: isQuantityTracked ? randInt(1, quantity as number) : 1, - }); - } - - // 2-4 custom field values for ~60% of assets. - if (chance(FRACTIONS.withCustomFieldValues)) { - const fields = pickN(customFieldPlans, randInt(2, 4)); - for (const cf of fields) { - cfValueRows.push({ - id: pid(), - value: buildCfValue(cf), - assetId: id, - customFieldId: cf.id, - }); - } - } - - // 1-3 tags for ~50% of assets (implicit m2m, raw insert below). - if (chance(FRACTIONS.withTags)) { - for (const tagId of pickN(tagIds, randInt(1, 3))) { - tagPairs.push([id, tagId]); - } - } - } - - await batchedCreateMany( - assetRows, - (chunk) => db.asset.createMany({ data: chunk }), - "assets" - ); - await batchedCreateMany( - qrRows, - (chunk) => db.qr.createMany({ data: chunk }), - "qr codes" - ); - await batchedCreateMany( - assetKitRows, - (chunk) => db.assetKit.createMany({ data: chunk }), - "asset-kit pivots" - ); - await batchedCreateMany( - assetLocationRows, - (chunk) => db.assetLocation.createMany({ data: chunk }), - "asset-location pivots" - ); - await batchedCreateMany( - cfValueRows, - (chunk) => db.assetCustomFieldValue.createMany({ data: chunk }), - "custom field values" - ); - - // Implicit m2m Asset<->Tag: insert into "_AssetToTag" ("A"=assetId, - // "B"=tagId) directly; createMany can't touch implicit join tables. - for (let i = 0; i < tagPairs.length; i += BATCH_SIZE) { - const chunk = tagPairs.slice(i, i + BATCH_SIZE); - await db.$executeRaw( - Prisma.sql`INSERT INTO "_AssetToTag" ("A", "B") VALUES ${Prisma.join( - chunk.map(([a, b]) => Prisma.sql`(${a}, ${b})`) - )} ON CONFLICT DO NOTHING` - ); - process.stdout.write( - `\r asset-tag links: ${Math.min(i + BATCH_SIZE, tagPairs.length)}/${tagPairs.length}` - ); - } - process.stdout.write("\n"); - - // Advance the org's sequential-id sequence past our SAM-XXXX values so - // app-side asset creation doesn't collide (function shipped in migration - // 20250818111341_add_sequential_id_sequences). - await db.$executeRaw`SELECT reset_asset_sequence_for_org(${orgId})`; - - /* ---------------------------------------------------------- */ - /* 7. Custody */ - /* ---------------------------------------------------------- */ - console.log("7/12 Creating custody rows..."); - // Only INDIVIDUAL assets outside kits: keeps the single-custody DB - // trigger and kit-custody semantics trivially satisfied. - const custodyEligible = assetPlans - .filter((a) => !a.isQuantityTracked && !a.inKit) - .map((a) => a.id); - const custodyAssetIds = pickN(custodyEligible, TARGETS.custodies); - const custodyRows: Prisma.CustodyCreateManyInput[] = custodyAssetIds.map( - (assetId) => ({ - id: pid(), - assetId, - teamMemberId: pick(teamMemberIds), - quantity: 1, - }) - ); - await batchedCreateMany( - custodyRows, - (chunk) => db.custody.createMany({ data: chunk }), - "custodies" - ); - await db.asset.updateMany({ - where: { id: { in: custodyAssetIds }, organizationId: orgId }, - data: { status: "IN_CUSTODY" }, - }); - console.log(` ${custodyAssetIds.length} assets set to IN_CUSTODY`); - - /* ---------------------------------------------------------- */ - /* 8. Bookings + BookingAsset pivots */ - /* ---------------------------------------------------------- */ - console.log("8/12 Creating bookings..."); - const inCustody = new Set(custodyAssetIds); - const bookableAssetIds = assetPlans - .filter((a) => !inCustody.has(a.id)) - .map((a) => a.id); - const qtQuantityByAsset = new Map( - assetPlans - .filter((a) => a.isQuantityTracked) - .map((a) => [a.id, a.quantity as number]) - ); - - const bookingRows: Prisma.BookingCreateManyInput[] = []; - const bookingAssetRows: Prisma.BookingAssetCreateManyInput[] = []; - const checkedOutAssetIds = new Set(); - - let bookingNo = 0; - for (const { status, count } of BOOKING_STATUS_PLAN) { - for (let i = 0; i < count; i++) { - bookingNo += 1; - const bookingId = pid(); - - // Date windows per status, spread across -6..+6 months. - let from: Date; - let to: Date; - switch (status) { - case "COMPLETE": - case "ARCHIVED": { - from = randomDate(daysFromNow(-180), daysFromNow(-20)); - to = new Date(from.getTime() + randInt(1, 14) * DAY_MS); - break; - } - case "ONGOING": { - from = randomDate(daysFromNow(-10), daysFromNow(-1)); - to = randomDate(daysFromNow(1), daysFromNow(10)); - break; - } - case "OVERDUE": { - from = randomDate(daysFromNow(-30), daysFromNow(-12)); - to = randomDate(daysFromNow(-9), daysFromNow(-1)); - break; - } - case "RESERVED": { - from = randomDate(daysFromNow(2), daysFromNow(180)); - to = new Date(from.getTime() + randInt(1, 14) * DAY_MS); - break; - } - default: { - // DRAFT / CANCELLED: anywhere in the window - from = randomDate(daysFromNow(-60), daysFromNow(180)); - to = new Date(from.getTime() + randInt(1, 14) * DAY_MS); - break; - } - } - - const custodianTeamMemberId = pick(teamMemberIds); - bookingRows.push({ - id: bookingId, - name: `PERF Booking #${String(bookingNo).padStart(3, "0")} - ${pick(NOUNS)} job`, - status, - description: chance(0.5) ? `Seeded ${status} booking for perf tests.` : "", - creatorId: SEED_USER_ID, - custodianTeamMemberId, - // Only the owner's TeamMember maps to a registered user. - custodianUserId: - custodianTeamMemberId === ownerTeamMember.id ? SEED_USER_ID : null, - organizationId: orgId, - from, - to, - createdAt: new Date( - Math.min(from.getTime() - randInt(1, 20) * DAY_MS, Date.now()) - ), - cancellationReason: - status === "CANCELLED" && chance(0.7) - ? "Cancelled during perf seeding" - : null, - archivedWithoutCheckin: false, - }); - - // 5-40 distinct assets per booking (standalone rows, assetKitId null; - // partial unique (bookingId, assetId) WHERE assetKitId IS NULL holds - // because pickN never repeats within a booking). - const linkedAssets = pickN(bookableAssetIds, randInt(5, 40)); - for (const assetId of linkedAssets) { - const qtTotal = qtQuantityByAsset.get(assetId); - bookingAssetRows.push({ - id: pid(), - bookingId, - assetId, - quantity: qtTotal ? randInt(1, Math.min(qtTotal, 3)) : 1, - }); - if (status === "ONGOING" || status === "OVERDUE") { - // Only INDIVIDUAL assets flip to CHECKED_OUT; QT assets keep - // per-slice availability semantics. - if (!qtTotal) checkedOutAssetIds.add(assetId); - } - } - } - } - - await batchedCreateMany( - bookingRows, - (chunk) => db.booking.createMany({ data: chunk }), - "bookings" - ); - await batchedCreateMany( - bookingAssetRows, - (chunk) => db.bookingAsset.createMany({ data: chunk }), - "booking-asset pivots" - ); - - // Assets on ONGOING/OVERDUE bookings go CHECKED_OUT (unless in custody). - const checkedOutList = [...checkedOutAssetIds]; - for (let i = 0; i < checkedOutList.length; i += BATCH_SIZE) { - await db.asset.updateMany({ - where: { - id: { in: checkedOutList.slice(i, i + BATCH_SIZE) }, - organizationId: orgId, - status: "AVAILABLE", - }, - data: { status: "CHECKED_OUT" }, - }); - } - console.log( - ` ${bookingRows.length} bookings, ${bookingAssetRows.length} booking-asset links, ` + - `${checkedOutList.length} assets flagged CHECKED_OUT` - ); - - /* ---------------------------------------------------------- */ - /* 9. Notes */ - /* ---------------------------------------------------------- */ - console.log("9/12 Creating notes..."); - const allAssetIds = assetPlans.map((a) => a.id); - const noteRows: Prisma.NoteCreateManyInput[] = []; - for (let i = 0; i < TARGETS.notes; i++) { - const isUpdate = chance(0.3); - noteRows.push({ - id: pid(), - content: isUpdate - ? `**Perf Tester** ${pick([ - "updated the asset", - "changed the location", - "assigned custody", - "checked the asset in", - ])}.` - : pick(NOTE_SNIPPETS), - type: isUpdate ? "UPDATE" : "COMMENT", - userId: SEED_USER_ID, - assetId: pick(allAssetIds), - createdAt: randomDate(daysFromNow(-180), new Date()), - }); - } - await batchedCreateMany( - noteRows, - (chunk) => db.note.createMany({ data: chunk }), - "notes" - ); - - /* ---------------------------------------------------------- */ - /* 10. Scans */ - /* ---------------------------------------------------------- */ - console.log("10/12 Creating scans..."); - const scanRows: Prisma.ScanCreateManyInput[] = []; - for (let i = 0; i < TARGETS.scans; i++) { - const qrId = pick(qrIds); - const withCoords = chance(0.3); - scanRows.push({ - id: pid(), - rawQrId: qrId, // raw string copy kept even if the Qr row is deleted - qrId, - userId: chance(0.6) ? SEED_USER_ID : null, - userAgent: pick(USER_AGENTS), - latitude: withCoords ? (48 + rng() * 5).toFixed(6) : null, - longitude: withCoords ? (2 + rng() * 10).toFixed(6) : null, - manuallyGenerated: chance(0.1), - createdAt: randomDate(daysFromNow(-180), new Date()), - }); - } - await batchedCreateMany( - scanRows, - (chunk) => db.scan.createMany({ data: chunk }), - "scans" - ); - - /* ---------------------------------------------------------- */ - /* 11. Sanity counts */ - /* ---------------------------------------------------------- */ - console.log("11/12 Verifying row counts..."); - const [ - users, - orgs, - categories, - tags, - locations, - customFields, - teamMembers, - kits, - assets, - qrs, - assetKits, - assetLocations, - cfValues, - custodies, - bookings, - bookingAssets, - notes, - scans, - ] = await Promise.all([ - db.user.count(), - db.organization.count(), - db.category.count({ where: { organizationId: orgId } }), - db.tag.count({ where: { organizationId: orgId } }), - db.location.count({ where: { organizationId: orgId } }), - db.customField.count({ where: { organizationId: orgId, deletedAt: null } }), - db.teamMember.count({ where: { organizationId: orgId } }), - db.kit.count({ where: { organizationId: orgId } }), - db.asset.count({ where: { organizationId: orgId } }), - db.qr.count({ where: { organizationId: orgId } }), - db.assetKit.count({ where: { organizationId: orgId } }), - db.assetLocation.count({ where: { organizationId: orgId } }), - db.assetCustomFieldValue.count(), - db.custody.count(), - db.booking.count({ where: { organizationId: orgId } }), - db.bookingAsset.count(), - db.note.count(), - db.scan.count(), - ]); - - /* ---------------------------------------------------------- */ - /* 12. Summary */ - /* ---------------------------------------------------------- */ - const secs = ((Date.now() - startedAt) / 1000).toFixed(1); - console.log(`12/12 Done in ${secs}s.\n`); - console.log("=== Seed summary (rows in DB) ==="); - const summary: Array<[string, number]> = [ - ["users", users], - ["organizations", orgs], - ["categories", categories], - ["tags", tags], - ["locations", locations], - ["customFields", customFields], - ["teamMembers", teamMembers], - ["kits", kits], - ["assets", assets], - ["qrCodes", qrs], - ["assetKit pivots", assetKits], - ["assetLocation pivots", assetLocations], - ["customFieldValues", cfValues], - ["custodies", custodies], - ["bookings", bookings], - ["bookingAsset pivots", bookingAssets], - ["notes", notes], - ["scans", scans], - ]; - for (const [label, value] of summary) { - console.log(` ${label.padEnd(22)} ${value}`); - } - console.log(`\nOrganization id: ${orgId}`); - console.log(`Owner user id: ${SEED_USER_ID}`); - } finally { - await db.$disconnect(); - } -} - -main().catch((err) => { - console.error( - "\nPerf seeder failed:\n", - err instanceof Error ? err.stack ?? err.message : err, - "\n" - ); - process.exit(1); -});