From fed5ecbc43f0c4f65b85d2c32d6e0afea5a190bd Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 15:48:56 +0200 Subject: [PATCH] feat(11-01): implement tender-query.builder (GREEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildTenderWhere: conditional Prisma where-builder covering keyword (D-01), openOnly deadline default + explicit deadline range (D-04), and NULL-graceful value filter (D-05, Kern-Test: value filter never eliminates estimatedValue=null rows). buildOrderBy: sort whitelist (deadline/value/published) defaulting to publishedAt desc (UI-01, T-11-01 — no dynamic orderBy keys from user input). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/tenders/tender-query.builder.spec.ts | 10 +- apps/api/src/tenders/tender-query.builder.ts | 91 +++++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/tenders/tender-query.builder.ts diff --git a/apps/api/src/tenders/tender-query.builder.spec.ts b/apps/api/src/tenders/tender-query.builder.spec.ts index 729bbbd..060cd13 100644 --- a/apps/api/src/tenders/tender-query.builder.spec.ts +++ b/apps/api/src/tenders/tender-query.builder.spec.ts @@ -122,10 +122,12 @@ describe('buildTenderWhere', () => { const where = buildTenderWhere(dto({ valueMin: 1000 })); const and = (where.AND as Array>) ?? []; - const valueClause = and.find((c) => 'OR' in c) as - | { OR: Array> } - | undefined; - const rangeBranch = valueClause?.OR.find((b) => 'estimatedValue' in b && b.estimatedValue !== null); + const valueClause = and.find( + (c) => 'OR' in c && JSON.stringify(c).includes('estimatedValue'), + ) as { OR: Array> } | undefined; + const rangeBranch = valueClause?.OR.find( + (b) => 'estimatedValue' in b && b.estimatedValue !== null, + ); expect(rangeBranch).toEqual({ estimatedValue: { gte: 1000 } }); }); diff --git a/apps/api/src/tenders/tender-query.builder.ts b/apps/api/src/tenders/tender-query.builder.ts new file mode 100644 index 0000000..31bc395 --- /dev/null +++ b/apps/api/src/tenders/tender-query.builder.ts @@ -0,0 +1,91 @@ +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. + */ + +/** + * 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). + */ +export function buildTenderWhere(dto: TenderQueryDto): 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 }, + ); + } + + 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; +}