diff --git a/apps/api/src/tenders/dto/tender-query.dto.ts b/apps/api/src/tenders/dto/tender-query.dto.ts index ce33d43..aa9417d 100644 --- a/apps/api/src/tenders/dto/tender-query.dto.ts +++ b/apps/api/src/tenders/dto/tender-query.dto.ts @@ -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; } diff --git a/apps/api/src/tenders/tender-query.builder.spec.ts b/apps/api/src/tenders/tender-query.builder.spec.ts index 060cd13..62c466a 100644 --- a/apps/api/src/tenders/tender-query.builder.spec.ts +++ b/apps/api/src/tenders/tender-query.builder.spec.ts @@ -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) || + 'bundesland' in (clause as Record) || + 'region' in (clause as Record)), + ); + expect(hasGeoClause).toBe(false); + }); }); describe('buildOrderBy', () => { diff --git a/apps/api/src/tenders/tender-query.builder.ts b/apps/api/src/tenders/tender-query.builder.ts index 31bc395..12bdc4e 100644 --- a/apps/api/src/tenders/tender-query.builder.ts +++ b/apps/api/src/tenders/tender-query.builder.ts @@ -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; } diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx index 3d150e8..8043702 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx @@ -9,7 +9,8 @@ import { useRouter, useSearchParams } from 'next/navigation'; * The `deadlineFrom`/`deadlineTo` param names deliberately match the * TenderQueryDto field names exactly — the Saved-Search serialization * contract in Plan 11-06 depends on the URL param names and the DTO field - * names being identical strings (11-01-PLAN.md action note). + * names being identical strings (11-01-PLAN.md action note). `plz` and + * `bundesland` (Plan 11-02) follow the same convention. * * Hardcoded German strings — i18n rollout is Phase 14 (Research Open * Question 3, RESOLVED). @@ -21,6 +22,32 @@ const SORT_OPTIONS: Array<{ value: string; label: string }> = [ { value: 'value', label: 'Auftragswert' }, ]; +/** + * Mirrors apps/api/src/tenders/geo/nuts-bundesland.ts's NUTS1_BUNDESLAND + * values (web and api are separate packages — no shared module for this + * small, stable 16-entry list; RESEARCH's "kleine Konstante spiegeln" + * option, FILTER-02 Plan 11-02 Task 3). Keep in sync if the Bundesland set + * ever changes (it will not — this is the fixed EU NUTS-1/DE schema). + */ +const BUNDESLAND_OPTIONS: string[] = [ + 'Baden-Württemberg', + 'Bayern', + 'Berlin', + 'Brandenburg', + 'Bremen', + 'Hamburg', + 'Hessen', + 'Mecklenburg-Vorpommern', + 'Niedersachsen', + 'Nordrhein-Westfalen', + 'Rheinland-Pfalz', + 'Saarland', + 'Sachsen', + 'Sachsen-Anhalt', + 'Schleswig-Holstein', + 'Thüringen', +]; + export function FilterPanel() { const router = useRouter(); const searchParams = useSearchParams(); @@ -31,6 +58,8 @@ export function FilterPanel() { const openOnly = searchParams.get('openOnly') !== 'false'; const deadlineFrom = searchParams.get('deadlineFrom') ?? ''; const deadlineTo = searchParams.get('deadlineTo') ?? ''; + const plz = searchParams.get('plz') ?? ''; + const bundesland = searchParams.get('bundesland') ?? ''; const setParam = (key: string, value: string | null) => { const next = new URLSearchParams(searchParams.toString()); @@ -124,6 +153,43 @@ export function FilterPanel() { /> + +
+
+ + setParam('plz', e.target.value)} + className="h-9 w-28 rounded border border-border bg-background px-3 text-sm text-foreground" + /> +
+ +
+ + +
+
); }