Files
tessera-ctl/apps/api/src/tenders/tender-query.builder.ts
T
schalli 5f97eca804 feat(11-05): GET/PUT /modules/tender-radar/triage routes + favOnly filter
Adds the batch-triage read/write routes (declared before @Get(':id') per
the route-order pitfall, T-11-13) and wires them through
TenderTriageService with userId/tenantId always derived from the request
context, never the body (T-11-10 / V4 IDOR). Extends TenderQueryDto/
buildTenderWhere with favOnly (UI-04): the controller resolves the
current user's favorited tenderIds server-side before building the
where-clause, and an empty favorites list yields zero matches instead of
the unfiltered catalog. Both batch-ids and favIds in-lists are bounded
(T-11-11 DoS). tenders.controller.spec.ts constructor calls updated for
the new TenderTriageService dependency (Rule 3 — required to keep the
existing suite compiling/passing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:33:40 +02:00

150 lines
5.7 KiB
TypeScript

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<string, Prisma.TenderOrderByWithRelationInput> = {
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;
}