feat(11-02): wire Region/PLZ/Bundesland filter into query builder + FilterPanel

TenderQueryDto gains validated plz (@MaxLength(5)), region, and
bundesland fields. buildTenderWhere adds three conditional AND-branches:
plz startsWith, bundesland exact-match against the now-backfilled indexed
column (Pitfall 1), and region startsWith (usable independent of the
bundesland column). FilterPanel gets a PLZ input and a 16-Land Bundesland
dropdown (mirrors NUTS1_BUNDESLAND — web/api are separate packages) that
write plz/bundesland into the URL searchParams; ResultsList already
forwards the full URLSearchParams to listTenders(), so no additional
fetch wiring was needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:03:21 +02:00
parent e4db602597
commit 69e25298c9
4 changed files with 155 additions and 3 deletions
+29 -2
View File
@@ -24,8 +24,8 @@ import {
*
* Used for: GET /modules/tender-radar?page=1&limit=20&status=active&q=...
*
* Region/PLZ/Bundesland/CPV/favOnly filters are added in later Phase-11
* plans (11-02/03/05) — deliberately NOT added here.
* CPV/favOnly filters are added in later Phase-11 plans (11-03/05) —
* deliberately NOT added here.
*/
export class TenderQueryDto {
/**
@@ -121,4 +121,31 @@ export class TenderQueryDto {
@IsOptional()
@IsIn(['deadline', 'value', 'published'])
sort?: string;
/**
* PLZ-Präfixfilter (FILTER-02, D-02). MaxLength(5) — German postal codes
* are 5 digits; a startsWith match still permits shorter prefixes.
*/
@IsOptional()
@IsString()
@MaxLength(5)
plz?: string;
/**
* NUTS-Region-Präfixfilter (FILTER-02, D-02) — matched via
* `region: { startsWith: ... }` in the builder.
*/
@IsOptional()
@IsString()
region?: string;
/**
* Bundesland-Name-Filter (FILTER-02, D-02, Pitfall 1). Matched against
* the backfilled/normalizer-derived `bundesland` column
* (@@index([bundesland])) — real hits only after the
* 20260721140000_tender_bundesland_backfill migration.
*/
@IsOptional()
@IsString()
bundesland?: string;
}
@@ -135,6 +135,43 @@ describe('buildTenderWhere', () => {
const where = buildTenderWhere(dto({ status: 'expired' }));
expect(where.status).toBe('expired');
});
it('plz set: adds a plz-startsWith constraint (FILTER-02)', () => {
const where = buildTenderWhere(dto({ plz: '10' }));
expect(where.AND).toEqual(
expect.arrayContaining([{ plz: { startsWith: '10' } }]),
);
});
it('bundesland set: adds an exact bundesland-equality constraint against the backfilled column (FILTER-02, Pitfall 1)', () => {
const where = buildTenderWhere(dto({ bundesland: 'Bayern' }));
expect(where.AND).toEqual(expect.arrayContaining([{ bundesland: 'Bayern' }]));
});
it('region set: adds a region-startsWith constraint, independent of bundesland (FILTER-02)', () => {
const where = buildTenderWhere(dto({ region: 'DE2' }));
expect(where.AND).toEqual(
expect.arrayContaining([{ region: { startsWith: 'DE2' } }]),
);
});
it('plz/bundesland/region are omitted from AND when unset', () => {
const where = buildTenderWhere(dto());
const and = (where.AND as unknown[]) ?? [];
const hasGeoClause = and.some(
(clause) =>
typeof clause === 'object' &&
clause !== null &&
('plz' in (clause as Record<string, unknown>) ||
'bundesland' in (clause as Record<string, unknown>) ||
'region' in (clause as Record<string, unknown>)),
);
expect(hasGeoClause).toBe(false);
});
});
describe('buildOrderBy', () => {
@@ -68,6 +68,28 @@ export function buildTenderWhere(dto: TenderQueryDto): Prisma.TenderWhereInput {
);
}
// 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 } });
}
if (AND.length) where.AND = AND;
return where;
}