|
| 1 | +import type { Request, Response } from 'express' |
| 2 | +import { z } from 'zod' |
| 3 | + |
| 4 | +import { ok } from '@/utils/api' |
| 5 | +import { validateOrThrow } from '@/utils/validation' |
| 6 | + |
| 7 | +import { MOCK_DETAILS, MOCK_PACKAGES } from './mockData' |
| 8 | + |
| 9 | +const DEFAULT_PAGE_SIZE = 20 |
| 10 | +const MAX_PAGE_SIZE = 100 |
| 11 | +const STALE_MONTHS = 18 |
| 12 | + |
| 13 | +const booleanQueryParam = z.preprocess((v) => v === 'true', z.boolean()).default(false) |
| 14 | + |
| 15 | +const lifecycleValues = ['active', 'stable', 'declining', 'abandoned'] as const |
| 16 | + |
| 17 | +const querySchema = z.object({ |
| 18 | + page: z.coerce.number().int().min(1).default(1), |
| 19 | + pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE), |
| 20 | + ecosystem: z.string().trim().optional(), |
| 21 | + lifecycle: z.enum(lifecycleValues).optional(), |
| 22 | + busFactor1Only: booleanQueryParam, |
| 23 | + staleOnly: booleanQueryParam, |
| 24 | + unstewardedOnly: booleanQueryParam, |
| 25 | + sortBy: z.enum(['name', 'health', 'impact', 'openVulns']).default('name'), |
| 26 | + sortDir: z.enum(['asc', 'desc']).default('asc'), |
| 27 | +}) |
| 28 | + |
| 29 | +// TODO: replace with real DB queries once packages DB is wired into the backend |
| 30 | +export async function listPackages(req: Request, res: Response): Promise<void> { |
| 31 | + const { |
| 32 | + page, |
| 33 | + pageSize, |
| 34 | + ecosystem, |
| 35 | + lifecycle, |
| 36 | + busFactor1Only, |
| 37 | + staleOnly, |
| 38 | + unstewardedOnly, |
| 39 | + sortBy, |
| 40 | + sortDir, |
| 41 | + } = validateOrThrow(querySchema, req.query) |
| 42 | + |
| 43 | + const staleThreshold = new Date() |
| 44 | + staleThreshold.setMonth(staleThreshold.getMonth() - STALE_MONTHS) |
| 45 | + |
| 46 | + let filtered = MOCK_PACKAGES.filter((p) => { |
| 47 | + if (ecosystem && p.ecosystem !== ecosystem) return false |
| 48 | + if (lifecycle && p.lifecycle !== lifecycle) return false |
| 49 | + if (busFactor1Only && p.maintainerBusFactor !== 1) return false |
| 50 | + if (unstewardedOnly && p.stewardship !== 'unassigned') return false |
| 51 | + if (staleOnly) { |
| 52 | + const lastRelease = MOCK_DETAILS[p.purl]?.general.riskSignals.lastRelease |
| 53 | + if (!lastRelease || new Date(lastRelease) >= staleThreshold) return false |
| 54 | + } |
| 55 | + return true |
| 56 | + }) |
| 57 | + |
| 58 | + filtered = filtered.sort((a, b) => { |
| 59 | + let cmp = 0 |
| 60 | + if (sortBy === 'name') { |
| 61 | + cmp = a.name.localeCompare(b.name) |
| 62 | + } else if (sortBy === 'health') { |
| 63 | + cmp = (a.health ?? 0) - (b.health ?? 0) |
| 64 | + } else if (sortBy === 'impact') { |
| 65 | + cmp = (a.impact ?? 0) - (b.impact ?? 0) |
| 66 | + } else if (sortBy === 'openVulns') { |
| 67 | + const sumA = a.openVulns.low + a.openVulns.medium + a.openVulns.high + a.openVulns.critical |
| 68 | + const sumB = b.openVulns.low + b.openVulns.medium + b.openVulns.high + b.openVulns.critical |
| 69 | + cmp = sumA - sumB |
| 70 | + } |
| 71 | + return sortDir === 'desc' ? -cmp : cmp |
| 72 | + }) |
| 73 | + |
| 74 | + const total = filtered.length |
| 75 | + const start = (page - 1) * pageSize |
| 76 | + const packages = filtered.slice(start, start + pageSize) |
| 77 | + |
| 78 | + ok(res, { |
| 79 | + page, |
| 80 | + pageSize, |
| 81 | + total, |
| 82 | + filters: { |
| 83 | + ecosystem: ecosystem ?? null, |
| 84 | + lifecycle: lifecycle ?? null, |
| 85 | + busFactor1Only, |
| 86 | + staleOnly, |
| 87 | + unstewardedOnly, |
| 88 | + }, |
| 89 | + sort: { by: sortBy, dir: sortDir }, |
| 90 | + packages, |
| 91 | + }) |
| 92 | +} |
0 commit comments