diff --git a/backend/src/api/public/v1/ossprey/packageScatter.ts b/backend/src/api/public/v1/ossprey/packageScatter.ts index 89963d79c1..de79b8d69f 100644 --- a/backend/src/api/public/v1/ossprey/packageScatter.ts +++ b/backend/src/api/public/v1/ossprey/packageScatter.ts @@ -1,12 +1,21 @@ import type { Request, Response } from 'express' +import { z } from 'zod' import { listPackagesForScatter } from '@crowd/data-access-layer' import { getPackagesQx } from '@/db/packagesDb' import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +import { STEWARDSHIP_STATUS_VALUES } from '../packages/types' + +const scatterQuerySchema = z.object({ + status: z.enum(STEWARDSHIP_STATUS_VALUES).optional(), +}) export async function packageScatterHandler(req: Request, res: Response): Promise { + const { status } = validateOrThrow(scatterQuerySchema, req.query) const qx = await getPackagesQx() - const points = await listPackagesForScatter(qx) + const points = await listPackagesForScatter(qx, { status }) ok(res, { points, total: points.length }) } diff --git a/backend/src/api/public/v1/packages/listPackages.ts b/backend/src/api/public/v1/packages/listPackages.ts index 08bf82361c..8331adbce5 100644 --- a/backend/src/api/public/v1/packages/listPackages.ts +++ b/backend/src/api/public/v1/packages/listPackages.ts @@ -7,7 +7,7 @@ import { getPackagesQx } from '@/db/packagesDb' import { ok } from '@/utils/api' import { validateOrThrow } from '@/utils/validation' -import type { StewardshipStatus } from './types' +import { STEWARDSHIP_STATUS_VALUES, type StewardshipStatus } from './types' const DEFAULT_PAGE_SIZE = 20 const MAX_PAGE_SIZE = 100 @@ -15,16 +15,6 @@ const MAX_PAGE_SIZE = 100 const booleanQueryParam = z.preprocess((v) => v === 'true', z.boolean()).default(false) const lifecycleValues = ['active', 'stable', 'declining', 'abandoned'] as const -const stewardshipStatusValues = [ - 'unassigned', - 'open', - 'assessing', - 'active', - 'needs_attention', - 'escalated', - 'blocked', - 'inactive', -] as const const healthBandValues = ['healthy', 'fair', 'concerning', 'critical'] as const const vulnSeverityValues = ['any', 'high', 'critical', 'none'] as const @@ -34,7 +24,7 @@ const querySchema = z.object({ ecosystem: z.string().trim().optional(), lifecycle: z.enum(lifecycleValues).optional(), name: z.string().trim().optional(), - status: z.enum(stewardshipStatusValues).optional(), + status: z.enum(STEWARDSHIP_STATUS_VALUES).optional(), healthBand: z.enum(healthBandValues).optional(), vulnSeverity: z.enum(vulnSeverityValues).optional(), busFactor1Only: booleanQueryParam, diff --git a/backend/src/api/public/v1/packages/types.ts b/backend/src/api/public/v1/packages/types.ts index 7fad33aed5..2762708697 100644 --- a/backend/src/api/public/v1/packages/types.ts +++ b/backend/src/api/public/v1/packages/types.ts @@ -1,12 +1,15 @@ -export type StewardshipStatus = - | 'unassigned' - | 'open' - | 'assessing' - | 'active' - | 'needs_attention' - | 'escalated' - | 'blocked' - | 'inactive' +export const STEWARDSHIP_STATUS_VALUES = [ + 'unassigned', + 'open', + 'assessing', + 'active', + 'needs_attention', + 'escalated', + 'blocked', + 'inactive', +] as const + +export type StewardshipStatus = (typeof STEWARDSHIP_STATUS_VALUES)[number] export type Lifecycle = 'active' | 'stable' | 'declining' | 'abandoned' diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index 8f11092970..7df7453690 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -665,7 +665,21 @@ export interface ScatterPoint { advisoryCount: number } -export async function listPackagesForScatter(qx: QueryExecutor): Promise { +export async function listPackagesForScatter( + qx: QueryExecutor, + options: { status?: string } = {}, +): Promise { + const { status } = options + + // 'unassigned' covers packages with no stewardship row (s.id IS NULL) in addition + // to rows explicitly marked unassigned. All other statuses filter via s.status directly. + // The query always uses LEFT JOIN — the filter is applied in the WHERE clause, not the join. + const statusFilter = status + ? status === 'unassigned' + ? `AND (s.status = 'unassigned' OR s.id IS NULL)` + : `AND s.status = $(status)` + : '' + const rows: Array<{ purl: string name: string @@ -675,16 +689,17 @@ export async function listPackagesForScatter(qx: QueryExecutor): Promise = await qx.select(` + }> = await qx.select( + ` SELECT p.purl, p.name, - ROUND(COALESCE(p.impact, 0) * 100)::int AS "criticalityScore", - ROUND(COALESCE(r_sc.scorecard_score, 0) * 10)::int AS "healthScore", - r_sc.scorecard_score AS "scorecardScoreRaw", - s.id::text AS "stewardshipId", - s.status AS "stewardshipStatus", - COALESCE(ap_counts.cnt, 0) AS "openVulns" + ROUND(COALESCE(p.impact, 0) * 100)::int AS "criticalityScore", + ROUND(COALESCE(r_sc.scorecard_score, 0) * 10)::int AS "healthScore", + r_sc.scorecard_score AS "scorecardScoreRaw", + s.id::text AS "stewardshipId", + s.status AS "stewardshipStatus", + COALESCE(ap_counts.cnt, 0) AS "openVulns" FROM packages p LEFT JOIN stewardships s ON s.package_id = p.id LEFT JOIN LATERAL ( @@ -699,9 +714,12 @@ export async function listPackagesForScatter(qx: QueryExecutor): Promise ({ purl: r.purl,