diff --git a/app/database/migrations/20241107105011_enhance_asset_search_view_validation/migration.sql b/app/database/migrations/20241107105011_enhance_asset_search_view_validation/migration.sql new file mode 100644 index 000000000..d03b4e0ce --- /dev/null +++ b/app/database/migrations/20241107105011_enhance_asset_search_view_validation/migration.sql @@ -0,0 +1,46 @@ +CREATE OR REPLACE VIEW public."AssetSearchView" +WITH (security_barrier = false, security_invoker = true) -- Explicitly set security +AS +WITH asset_base AS ( + SELECT + a.id, + a."createdAt", + a.id as "assetId", + a.title, + a.description, + a."categoryId", + a."locationId", + a."organizationId" + FROM public."Asset" a + WHERE a.id IS NOT NULL +) +SELECT + ab.id, + ab."createdAt", + ab."assetId", + ( + COALESCE(ab.title, '') + || ' ' || COALESCE(c.name, '') + || ' ' || COALESCE(ab.description, '') + || ' ' || COALESCE(string_agg(tm.name, ' '), '') + || ' ' || COALESCE(string_agg(t.name, ' '), '') + || ' ' || COALESCE(l.name, '') + ) as "searchVector" +FROM + asset_base ab + LEFT JOIN public."Category" c ON ab."categoryId" = c.id + LEFT JOIN public."Location" l ON ab."locationId" = l.id + LEFT JOIN public."_AssetToTag" atr ON ab.id = atr."A" + LEFT JOIN public."Tag" t ON atr."B" = t.id + LEFT JOIN public."Custody" custd ON ab.id = custd."assetId" + LEFT JOIN public."TeamMember" tm ON custd."teamMemberId" = tm.id +GROUP BY + ab.id, + ab."createdAt", + ab."assetId", + ab.title, + ab.description, + c.id, + c.name, + l.id, + l.name; diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index 6a9cb0b3a..9997695a4 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -76,6 +76,7 @@ import { import type { AdvancedIndexAsset, AdvancedIndexQueryResult, + AssetsFromViewItem, CreateAssetFromBackupImportPayload, CreateAssetFromContentImportPayload, ShelfAssetCustomFieldValueType, @@ -135,6 +136,13 @@ const unavailableBookingStatuses = [ BookingStatus.OVERDUE, ]; +// Enhanced type safety for the query result +type AssetSearchResult = { + asset: AssetsFromViewItem | null; +} & { + [K in keyof AssetsFromViewItem]: AssetsFromViewItem[K]; +}; + /** * Fetches assets from AssetSearchView * This is used to have a more advanced search however its less performant @@ -185,7 +193,11 @@ async function getAssetsFromView(params: { /** Default value of where. Takes the assets belonging to current user */ let where: Prisma.AssetSearchViewWhereInput = { - asset: { organizationId }, + asset: { + organizationId, + // Ensure asset exists + id: { not: undefined }, + }, }; /** If the search string exists, add it to the where object */ @@ -407,7 +419,31 @@ async function getAssetsFromView(params: { db.assetSearchView.count({ where }), ]); - return { assets: assetSearch.map((a) => a.asset), totalAssets }; + // Filter out null assets while logging errors for monitoring + const validAssets = assetSearch.filter((result) => { + if (!result.asset) { + Logger.error( + new ShelfError({ + cause: null, + message: "Found AssetSearchView record without associated asset", + label: "Assets", + additionalData: { + searchViewRecord: result, + organizationId, + query: "getAssetsFromView", + where, + }, + }) + ); + return false; + } + return true; + }); + + return { + assets: validAssets.map((result) => result.asset), + totalAssets: totalAssets, + }; } catch (cause) { throw new ShelfError({ cause,