"use server"; import { redirect } from "next/navigation"; import type { FindingsFilterParam } from "@/actions/findings/findings-filters"; import { apiBaseUrl, composeSort, FG_FAIL_FIRST, FG_RECENT_LAST_SEEN, FG_SEVERITY_HIGH_FIRST, FINDING_GROUP_RESOURCES_DEFAULT_SORT, getAuthHeaders, includesMutedFindings, splitCsvFilterValues, } from "@/lib"; import { appendSanitizedProviderFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; /** * Maps filter[search] to filter[check_title__icontains] for finding-groups. * The finding-groups endpoint supports check_title__icontains for substring * matching on the human-readable check title displayed in the table. */ function mapSearchFilter( filters: Record, ): Record { const mapped = { ...filters }; const searchValue = mapped["filter[search]"]; if (searchValue) { mapped["filter[check_title__icontains]"] = searchValue; delete mapped["filter[search]"]; } return mapped; } /** * Filters that belong to finding-groups but are NOT valid for the * finding-group resources sub-endpoint. These must be stripped before * calling the resources API to avoid empty results. */ const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FindingsFilterParam[] = [ "filter[service__in]", "filter[scan__in]", "filter[scan_id]", "filter[scan_id__in]", ]; function normalizeFindingGroupResourceFilters( filters: Record, ): Record { const normalized = Object.fromEntries( Object.entries(filters).filter( ([key]) => !FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS.includes( key as FindingsFilterParam, ), ), ); const exactStatusFilter = normalized["filter[status]"]; if (exactStatusFilter !== undefined) { delete normalized["filter[status__in]"]; return normalized; } const statusValues = splitCsvFilterValues(normalized["filter[status__in]"]); if (statusValues.length === 1) { normalized["filter[status]"] = statusValues[0]; delete normalized["filter[status__in]"]; } return normalized; } // Composite sorts for finding-groups (Family B in lib/findings-sort.ts). // The `-status,-severity,...,-last_seen_at` shape is required by the API: // these endpoints map status/severity to weighted integer columns where // DESC = FAIL/critical first. The intermediate `*_count` tokens are // finding-group-specific impact tiebreakers and have no Family A analogue. const DEFAULT_FINDING_GROUPS_SORT = composeSort( FG_FAIL_FIRST, FG_SEVERITY_HIGH_FIRST, "-new_fail_count", "-changed_fail_count", "-fail_count", FG_RECENT_LAST_SEEN, ); const DEFAULT_FINDING_GROUPS_SORT_WITH_MUTED = composeSort( FG_FAIL_FIRST, FG_SEVERITY_HIGH_FIRST, "-new_fail_count", "-changed_fail_count", "-new_fail_muted_count", "-changed_fail_muted_count", "-fail_count", "-fail_muted_count", FG_RECENT_LAST_SEEN, ); const DEFAULT_FINDING_GROUP_RESOURCES_SORT = FINDING_GROUP_RESOURCES_DEFAULT_SORT; interface FetchFindingGroupsParams { page?: number; pageSize?: number; sort?: string; filters?: Record; } function getDefaultFindingGroupsSort( filters: Record, ): string { return includesMutedFindings(filters) ? DEFAULT_FINDING_GROUPS_SORT_WITH_MUTED : DEFAULT_FINDING_GROUPS_SORT; } async function fetchFindingGroupsEndpoint( endpoint: string, { page = 1, pageSize = 10, sort, filters = {} }: FetchFindingGroupsParams, ) { const headers = await getAuthHeaders({ contentType: false }); const resolvedSort = sort ?? getDefaultFindingGroupsSort(filters); if (isNaN(Number(page)) || page < 1) redirect("/findings"); const url = new URL(`${apiBaseUrl}/${endpoint}`); if (page) url.searchParams.append("page[number]", page.toString()); if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); if (resolvedSort) url.searchParams.append("sort", resolvedSort); appendSanitizedProviderFilters(url, mapSearchFilter(filters)); try { const response = await fetch(url.toString(), { headers }); return handleApiResponse(response); } catch (error) { console.error(`Error fetching ${endpoint}:`, error); return undefined; } } export const getFindingGroups = async (params: FetchFindingGroupsParams = {}) => fetchFindingGroupsEndpoint("finding-groups", params); export const getLatestFindingGroups = async ( params: FetchFindingGroupsParams = {}, ) => fetchFindingGroupsEndpoint("finding-groups/latest", params); interface FetchFindingGroupResourcesParams { checkId: string; page?: number; pageSize?: number; filters?: Record; } async function fetchFindingGroupResourcesEndpoint( endpointPrefix: string, { checkId, page = 1, pageSize = 20, filters = {}, }: FetchFindingGroupResourcesParams, ) { const headers = await getAuthHeaders({ contentType: false }); const normalizedFilters = normalizeFindingGroupResourceFilters(filters); const url = new URL( `${apiBaseUrl}/${endpointPrefix}/${encodeURIComponent(checkId)}/resources`, ); if (page) url.searchParams.append("page[number]", page.toString()); if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); url.searchParams.append("sort", DEFAULT_FINDING_GROUP_RESOURCES_SORT); appendSanitizedProviderFilters(url, normalizedFilters); try { const response = await fetch(url.toString(), { headers }); return handleApiResponse(response); } catch (error) { console.error(`Error fetching ${endpointPrefix} resources:`, error); return undefined; } } export const getFindingGroupResources = async ( params: FetchFindingGroupResourcesParams, ) => fetchFindingGroupResourcesEndpoint("finding-groups", params); export const getLatestFindingGroupResources = async ( params: FetchFindingGroupResourcesParams, ) => fetchFindingGroupResourcesEndpoint("finding-groups/latest", params);