feat(11-01): implement tender-query.builder (GREEN)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -122,10 +122,12 @@ describe('buildTenderWhere', () => {
|
|||||||
const where = buildTenderWhere(dto({ valueMin: 1000 }));
|
const where = buildTenderWhere(dto({ valueMin: 1000 }));
|
||||||
|
|
||||||
const and = (where.AND as Array<Record<string, unknown>>) ?? [];
|
const and = (where.AND as Array<Record<string, unknown>>) ?? [];
|
||||||
const valueClause = and.find((c) => 'OR' in c) as
|
const valueClause = and.find(
|
||||||
| { OR: Array<Record<string, unknown>> }
|
(c) => 'OR' in c && JSON.stringify(c).includes('estimatedValue'),
|
||||||
| undefined;
|
) as { OR: Array<Record<string, unknown>> } | undefined;
|
||||||
const rangeBranch = valueClause?.OR.find((b) => 'estimatedValue' in b && b.estimatedValue !== null);
|
const rangeBranch = valueClause?.OR.find(
|
||||||
|
(b) => 'estimatedValue' in b && b.estimatedValue !== null,
|
||||||
|
);
|
||||||
expect(rangeBranch).toEqual({ estimatedValue: { gte: 1000 } });
|
expect(rangeBranch).toEqual({ estimatedValue: { gte: 1000 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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<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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user