import { Prisma } from '@prisma/client'; import type { TenderQueryDto } from './dto/tender-query.dto'; /** * Pure functions building the Prisma `where`/`orderBy` for the global * `Tender` catalog read path (listTenders). Kept out of the controller so * both are independently unit-testable without a live DB (RESEARCH * Pattern 1/2/4/5, Don't Hand-Roll: "Filter-where-Zusammenbau"). * * Security (T-11-01/T-11-03): every branch is a parametrized Prisma * filter — no raw SQL, no string concatenation. `sort` is resolved via a * fixed SORT_MAP whitelist (buildOrderBy), never a dynamic user-supplied * orderBy key. */ /** * T-11-11 (DoS): bounds the favOnly `id: { in: [...] }` list — a user's * own favorites are already implicitly bounded by their behavior, but a * hard cap keeps the generated query's IN-list size predictable * regardless of how many rows accumulate over time. */ const MAX_FAV_IDS = 500; /** * buildTenderWhere — conditional AND-composition. Empty DTO fields never * add a constraint; every non-empty field is a correctness/security * requirement documented inline (D-01/04/05). * * `favIds` (UI-04, T-11-10): the current user's favorited tenderIds, * resolved by the CALLER (TendersController, via * `TenderTriageService.favoriteIds(userId)`) from the auth context — * never accepted here as user input. Only consulted when `dto.favOnly` is * true. */ export function buildTenderWhere( dto: TenderQueryDto, favIds?: string[], ): Prisma.TenderWhereInput { const where: Prisma.TenderWhereInput = {}; const AND: Prisma.TenderWhereInput[] = []; // D-04 / FILTER-04: status defaults to 'active'; explicit status wins. where.status = dto.status ?? 'active'; // D-04 / FILTER-04: "nur noch offene" default view. NULL deadlines are // NEVER hidden by this branch (17% of live rows have deadlineAt=null) — // hiding them would silently drop legitimate open-ended tenders. if (dto.openOnly ?? true) { AND.push({ OR: [{ deadlineAt: { gte: new Date() } }, { deadlineAt: null }], }); } // FILTER-04: explicit deadline date-range, combinable with openOnly above. if (dto.deadlineFrom != null || dto.deadlineTo != null) { const range: Prisma.DateTimeFilter = {}; if (dto.deadlineFrom != null) range.gte = dto.deadlineFrom; if (dto.deadlineTo != null) range.lte = dto.deadlineTo; AND.push({ deadlineAt: range }); } // FILTER-01 / D-01: case-insensitive keyword over title + buyerName. if (dto.q) { AND.push({ OR: [ { title: { contains: dto.q, mode: 'insensitive' } }, { buyerName: { contains: dto.q, mode: 'insensitive' } }, ], }); } // FILTER-05 / D-05 (Pflichtkriterium): an active value filter must NEVER // silently eliminate estimatedValue=null rows (91.6% of live data). // includeNullValue defaults to true — the OR-with-null branch is the // Kern-Test for this task. if (dto.valueMin != null || dto.valueMax != null) { const range: Prisma.DecimalNullableFilter = {}; if (dto.valueMin != null) range.gte = dto.valueMin; if (dto.valueMax != null) range.lte = dto.valueMax; AND.push( (dto.includeNullValue ?? true) ? { OR: [{ estimatedValue: range }, { estimatedValue: null }] } : { estimatedValue: range }, ); } // FILTER-02 / D-02: PLZ prefix match — plz is a 5-digit German postal // code column; startsWith allows shorter prefixes to broaden the match. if (dto.plz) { AND.push({ plz: { startsWith: dto.plz } }); } // FILTER-02 / D-02: Bundesland exact match against the backfilled/ // normalizer-derived `bundesland` column (indexed, Pitfall 1) — real // hits only after the 20260721140000_tender_bundesland_backfill // migration has run. Preferred over region-prefix matching once // bundesland is populated. if (dto.bundesland) { AND.push({ bundesland: dto.bundesland }); } // FILTER-02 / D-02: raw NUTS-region prefix match, independent of the // bundesland column — usable even for rows whose bundesland derivation // hasn't run yet, and for finer-grained region-level filtering. if (dto.region) { AND.push({ region: { startsWith: dto.region } }); } // FILTER-03 / D-03 (Pitfall 2): CPV-Division-Filter — matched via // `hasSome` against the normalizer/backfill-derived `cpvDivisions` // column (typesafe, GIN-indexable), never an exact-equality match // against the inconsistently-formatted raw `cpvCodes` array. if (dto.cpv?.length) { AND.push({ cpvDivisions: { hasSome: dto.cpv } }); } // UI-04 / D-10 (Merklisten-Filter, Pflichtkriterium): favOnly restricts // the result to the current user's favorited tenders. Empty favIds (no // favorites yet, or favIds not supplied) MUST yield ZERO matches, never // "all tenders" — `'__none__'` is a sentinel that can never equal a real // Tender.id (uuid), so `{ in: ['__none__'] }` is a guaranteed-empty // match rather than an accidentally-unconstrained query. if (dto.favOnly) { const ids = (favIds ?? []).slice(0, MAX_FAV_IDS); AND.push({ id: { in: ids.length ? ids : ['__none__'] } }); } if (AND.length) where.AND = AND; return where; } /** * Sort-Whitelist (UI-01, D-06, T-11-01). Only these three keys are ever * translated into a Prisma orderBy — an unrecognized/missing key falls * back to the pre-existing default (publishedAt desc), never a * dynamically-constructed field from user input. */ const SORT_MAP: Record = { deadline: { deadlineAt: 'asc' }, value: { estimatedValue: 'desc' }, published: { publishedAt: 'desc' }, }; export function buildOrderBy( sort: string | undefined, ): Prisma.TenderOrderByWithRelationInput { return SORT_MAP[sort ?? 'published'] ?? SORT_MAP.published; }