7c9d7c1223
(req as any) und @Req() req: any durch AuthenticatedRequest ersetzt in
dashboard, favorites, calendar, groups, module-grants, module-registry,
tenders, dkv, ldap, settings; @CurrentUser() in user.controller auf AuthUser.
Die abwehrenden Pruefungen ("No tenant context", "No user context") bleiben
lebendig, weil user auf dem Anfragetyp wahlfrei ist - genau das beschreibt
den Zustand auf oeffentlichen Wegen.
Nebengewinn ohne neue Zusicherungen: req.tenantId as string | undefined
(dkv, settings), file.buffer as Buffer und file.mimetype as string
(dkv, user) sind weggefallen, weil der Typ sie jetzt traegt.
BEFUND 1 (D-03, gemeldet) dashboard.controller.ts:74 alt: der Handler las
req.user?.role NACH extractContext und gab sie an getWidgets(role: Role)
weiter, das eine Rolle zwingend verlangt. Die Annahme "hier gibt es immer
einen Aufrufer" stimmt - die Pruefung "No user context" erzwingt sie -, aber
sie stand in einer anderen Methode, wo der Compiler sie nicht sehen konnte.
extractContext gibt die Rolle jetzt mit zurueck: keine neue Pruefung, kein
erfundener Wert, gleiche Reihenfolge, gleiche Meldungen.
BEFUND 2 (D-03, gemeldet) tenders.controller.ts:142: resolveRequestingTenantId
erklaerte string | undefined, liest aber req.tenantId, das TenantGuard fuer
einen SUPER_ADMIN ohne Mandanten auf null setzt. Die Erklaerung war also nie
vollstaendig. Erweitert auf string | null | undefined, und buildTenderWhere
nimmt string | null - beides nur Erklaerung, kein Verhalten: die Funktion
entscheidet seit jeher ueber Wahrheitswert und faellt bei beiden zu
(nur global sichtbare Ausschreibungen).
Fixtures in user.controller.spec.ts ergaenzt (username, mustChangePassword,
originalname, size). Testzahlen unveraendert.
noExplicitAny in apps/api/src: 137 -> 66. type-check 4/4, lint 5/5,
apps/api 72/1143, apps/web 73/531, tenant.guard.ts unveraendert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
170 lines
6.7 KiB
TypeScript
170 lines
6.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.
|
|
*
|
|
* `ownerTenantId` (Phase 14, Plan 03, D-13): the requesting tenant's id,
|
|
* resolved by the CALLER (TendersController) from the auth context — never
|
|
* from `dto`. Applies the OR[global, mine] visibility rule for privately-
|
|
* sourced (email-alert) tenders: `{ OR: [{ownerTenantId:null},
|
|
* {ownerTenantId}] }`. When the caller cannot resolve a requesting tenant
|
|
* (no auth context), this fails CLOSED — only globally-visible
|
|
* (`ownerTenantId: null`) tenders are returned, never a private tenant's
|
|
* rows leaked to an unidentified requester.
|
|
*/
|
|
export function buildTenderWhere(
|
|
dto: TenderQueryDto,
|
|
favIds?: string[],
|
|
ownerTenantId?: string | null,
|
|
): 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__'] } });
|
|
}
|
|
|
|
// D-13 (Phase 14, Plan 03): private (email-alert) tender visibility.
|
|
// A resolved requesting tenant sees global tenders (null) PLUS its own;
|
|
// an unidentified requester (ownerTenantId undefined) sees ONLY global
|
|
// tenders — fail-closed, never an accidental cross-tenant leak.
|
|
AND.push(
|
|
ownerTenantId
|
|
? { OR: [{ ownerTenantId: null }, { ownerTenantId }] }
|
|
: { ownerTenantId: null },
|
|
);
|
|
|
|
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;
|
|
}
|