refactor(quick-260921-m34): Aufgabe 3a - 18 Fehlerfaenger auf unknown, mit echter Eingrenzung

catch (e: any) in groups, module-grants, ldap, user, admin-seed, calendar
und vier tenders-Diensten auf catch (e: unknown) umgestellt. Die
Eingrenzung passiert an der Verwendungsstelle, nicht per Zusicherung.

Neu: apps/api/src/prisma/prisma-error.ts mit prismaErrorCode() und
prismaErrorTarget(). Bewusst Form-Pruefungen statt instanceof
Prisma.PrismaClientKnownRequestError - gemessen: samtliche Testdoppel in
apps/api werfen new Error(...) mit angehaengtem .code (groups, user, ldap,
tenders, module-grants, admin-seed) und dashboard.service.spec.ts:451 ein
reines { code: 'P2002' }. Ein instanceof-Test haette all diese Werte in den
anderen Zweig geschickt - Verhaltensaenderung, verboten nach D-03/T-M34-06.
Die Helfer bilden err?.code und err?.meta?.target eins zu eins ab.

ldap.service.ts liest zusaetzlich meta.target; prismaErrorTarget() gibt
unknown zurueck, weil der Bestand dort Array UND Zeichenkette getrennt
behandelt - ein engerer Typ waere eine Behauptung.

calendar.service.ts:341 nutzt instanceof Error statt e?.message: gemessen
wirft validateUrlNotPrivate() ausschliesslich ForbiddenException (der
eigene catch dort setzt jeden Fremdfehler in eine um), also trifft
instanceof dieselben Faelle. Ersatzzweig 'URL not allowed' unveraendert.

noExplicitAny in apps/api/src: 56 -> 38. type-check 4/4, lint 5/5 (0
error), apps/api 72/1143, apps/web 73/531, rls-access-inventory 30/30.
noNonNullAssertion 56, as unknown as 33, Unterdrueckungsmarker 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
2026-09-21 17:18:12 +02:00
parent 52668c2c88
commit 32591b6690
11 changed files with 103 additions and 38 deletions
+10 -2
View File
@@ -338,8 +338,16 @@ export class CalendarService {
try { try {
// Exchange EWS servers are commonly on internal networks — SSRF check skipped for exchange type // Exchange EWS servers are commonly on internal networks — SSRF check skipped for exchange type
if (dto.type !== 'exchange') await this.validateUrlNotPrivate(dto.url); if (dto.type !== 'exchange') await this.validateUrlNotPrivate(dto.url);
} catch (e: any) { } catch (e: unknown) {
return { success: false, error: e?.message ?? 'URL not allowed' }; // validateUrlNotPrivate() wirft ausschliesslich ForbiddenException
// (gemessen: jeder Wurf in der Methode ist eine, und der eigene
// catch-Block dort setzt jeden Fremdfehler in eine um). instanceof
// Error trifft deshalb genau dieselben Faelle wie das bisherige
// e?.message; der Ersatzzweig bleibt derselbe (D-03).
return {
success: false,
error: e instanceof Error ? e.message : 'URL not allowed',
};
} }
const provider = this.getProvider(dto.type); const provider = this.getProvider(dto.type);
+11 -10
View File
@@ -7,6 +7,7 @@ import {
import { MembershipSource } from '@prisma/client'; import { MembershipSource } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension'; import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
/** /**
* Name der automatisch angelegten Standardgruppe (D-13). Geteilte Wahrheit * Name der automatisch angelegten Standardgruppe (D-13). Geteilte Wahrheit
@@ -91,8 +92,8 @@ export class GroupsService {
return await tenantPrisma.group.create({ return await tenantPrisma.group.create({
data: { tenantId, name }, data: { tenantId, name },
}); });
} catch (err: any) { } catch (err: unknown) {
if (err?.code === 'P2002') { if (prismaErrorCode(err) === 'P2002') {
throw new ConflictException( throw new ConflictException(
`Eine Gruppe mit dem Namen '${name}' existiert bereits in diesem Mandanten`, `Eine Gruppe mit dem Namen '${name}' existiert bereits in diesem Mandanten`,
); );
@@ -204,8 +205,8 @@ export class GroupsService {
where: { id }, where: { id },
data: updateData, data: updateData,
}); });
} catch (err: any) { } catch (err: unknown) {
if (err?.code === 'P2002') { if (prismaErrorCode(err) === 'P2002') {
throw new ConflictException( throw new ConflictException(
`Eine Gruppe mit dem Namen '${updateData.name}' existiert bereits in diesem Mandanten`, `Eine Gruppe mit dem Namen '${updateData.name}' existiert bereits in diesem Mandanten`,
); );
@@ -244,8 +245,8 @@ export class GroupsService {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId);
try { try {
return await tenantPrisma.group.delete({ where: { id } }); return await tenantPrisma.group.delete({ where: { id } });
} catch (err: any) { } catch (err: unknown) {
if (err?.code === 'P2025') { if (prismaErrorCode(err) === 'P2025') {
throw new NotFoundException(`Gruppe '${id}' nicht gefunden`); throw new NotFoundException(`Gruppe '${id}' nicht gefunden`);
} }
throw err; throw err;
@@ -398,8 +399,8 @@ export class GroupsService {
return group; return group;
}); });
} catch (err: any) { } catch (err: unknown) {
if (err?.code === 'P2002') { if (prismaErrorCode(err) === 'P2002') {
return null; return null;
} }
throw err; throw err;
@@ -468,8 +469,8 @@ export class GroupsService {
}); });
}); });
return true; return true;
} catch (err: any) { } catch (err: unknown) {
if (err?.code === 'P2002') { if (prismaErrorCode(err) === 'P2002') {
return false; return false;
} }
throw err; throw err;
+3 -2
View File
@@ -6,6 +6,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
/** /**
* Schreibseite der Modul-Freigaben (PERM-03): Grants für Gruppen und für * Schreibseite der Modul-Freigaben (PERM-03): Grants für Gruppen und für
@@ -132,8 +133,8 @@ export class ModuleGrantsService {
`Grant erteilt: tenant=${tenantId} module=${moduleId} ${target}`, `Grant erteilt: tenant=${tenantId} module=${moduleId} ${target}`,
); );
return created; return created;
} catch (err: any) { } catch (err: unknown) {
if (err?.code === 'P2002') { if (prismaErrorCode(err) === 'P2002') {
const existing = await tenantPrisma.moduleGrant.findFirst({ const existing = await tenantPrisma.moduleGrant.findFirst({
where: { where: {
tenantId, tenantId,
+9 -8
View File
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Client, EqualityFilter, Entry } from 'ldapts'; import { Client, EqualityFilter, Entry } from 'ldapts';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode, prismaErrorTarget } from '../prisma/prisma-error';
import { GroupsService } from '../groups/groups.service'; import { GroupsService } from '../groups/groups.service';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
@@ -842,9 +843,9 @@ export class LdapService {
data: { tenantId, name, ldapDn: entry.dn, ldapObjectGuid }, data: { tenantId, name, ldapDn: entry.dn, ldapObjectGuid },
}); });
result.imported++; result.imported++;
} catch (createError: any) { } catch (createError: unknown) {
if (createError?.code === 'P2002') { if (prismaErrorCode(createError) === 'P2002') {
const target = createError?.meta?.target; const target = prismaErrorTarget(createError);
const targetsGuid = Array.isArray(target) const targetsGuid = Array.isArray(target)
? target.includes('ldapObjectGuid') ? target.includes('ldapObjectGuid')
: String(target ?? '').includes('ldapObjectGuid'); : String(target ?? '').includes('ldapObjectGuid');
@@ -1526,8 +1527,8 @@ export class LdapService {
data: { name, ldapDn: dn }, data: { name, ldapDn: dn },
}); });
result.groupsRenamed++; result.groupsRenamed++;
} catch (updateError: any) { } catch (updateError: unknown) {
if (updateError?.code === 'P2002') { if (prismaErrorCode(updateError) === 'P2002') {
// WR-02 (16-REVIEW.md): this update() writes BOTH name and // WR-02 (16-REVIEW.md): this update() writes BOTH name and
// ldapDn in one call — @@unique([tenantId, name]) AND // ldapDn in one call — @@unique([tenantId, name]) AND
// @@unique([tenantId, ldapDn]) are both potential triggers // @@unique([tenantId, ldapDn]) are both potential triggers
@@ -1535,7 +1536,7 @@ export class LdapService {
// collision when updateError.meta.target says so. Mirrors // collision when updateError.meta.target says so. Mirrors
// the discrimination importGroupsByDn() already does above // the discrimination importGroupsByDn() already does above
// for its own create() call. // for its own create() call.
const target = updateError?.meta?.target; const target = prismaErrorTarget(updateError);
const targetsName = Array.isArray(target) const targetsName = Array.isArray(target)
? target.includes('name') ? target.includes('name')
: String(target ?? '').includes('name'); : String(target ?? '').includes('name');
@@ -1591,8 +1592,8 @@ export class LdapService {
await tenantPrisma.group.delete({ where: { id: group.id } }); await tenantPrisma.group.delete({ where: { id: group.id } });
result.groupsDeleted++; result.groupsDeleted++;
anyDeleted = true; anyDeleted = true;
} catch (deleteError: any) { } catch (deleteError: unknown) {
if (deleteError?.code !== 'P2025') { if (prismaErrorCode(deleteError) !== 'P2025') {
throw deleteError; throw deleteError;
} }
// Already gone (e.g. a concurrent manual delete) — not // Already gone (e.g. a concurrent manual delete) — not
+48
View File
@@ -0,0 +1,48 @@
/**
* Eingrenzung gefangener Fehlerwerte auf die Form, die der Bestandscode an
* Prisma-Fehlern tatsaechlich liest — `code` und `meta.target`, sonst nichts.
*
* Warum kein `instanceof Prisma.PrismaClientKnownRequestError`:
* Der Bestand prueft ueberall `err?.code === 'P2002'` und trifft damit JEDES
* Objekt, das ein `code`-Feld traegt. Genau darauf bauen die Testdoppel in
* `apps/api` auf — sie werfen `const err = new Error(...); err.code = 'P2002'`
* (groups, user, ldap, tenders, module-grants, admin-seed) und an einer Stelle
* ein reines `{ code: 'P2002' }` (dashboard.service.spec.ts:451). Ein
* `instanceof`-Test wuerde alle diese Werte in den jeweils ANDEREN Zweig
* schicken. Das waere eine Verhaltensaenderung und ist deshalb verboten
* (Plan 260921-m34, D-03 und T-M34-06).
*
* Diese Funktionen sind daher bewusst Form-Pruefungen ("duck typing") und
* keine Klassenpruefungen: sie bilden `err?.code` bzw. `err?.meta?.target`
* eins zu eins ab, nur eben ohne `any`.
*/
/**
* Liefert den Prisma-Fehlercode (z.B. `P2002`, `P2025`), wenn der gefangene
* Wert ein Objekt mit einem Zeichenketten-Feld `code` ist — sonst `undefined`.
*
* Ein Feld `code`, das keine Zeichenkette ist, liefert `undefined`. Auch das
* ist verhaltensgleich: ein Vergleich `err.code === 'P2002'` waere dort
* ohnehin nie wahr geworden.
*/
export function prismaErrorCode(err: unknown): string | undefined {
if (typeof err !== 'object' || err === null) return undefined;
const code = (err as { code?: unknown }).code;
return typeof code === 'string' ? code : undefined;
}
/**
* Liefert `meta.target` eines Prisma-Fehlers als `unknown`.
*
* Bewusst `unknown` und nicht `string[]`: Prisma liefert hier je nach
* Fehlerart ein Feld-Array ODER den Indexnamen als Zeichenkette, und der
* Bestand in `ldap.service.ts` behandelt genau diese beiden Faelle bereits
* getrennt (`Array.isArray(target) ? ... : String(target ?? '')`). Ein
* engerer Rueckgabetyp waere hier eine Behauptung statt einer Messung.
*/
export function prismaErrorTarget(err: unknown): unknown {
if (typeof err !== 'object' || err === null) return undefined;
const meta = (err as { meta?: unknown }).meta;
if (typeof meta !== 'object' || meta === null) return undefined;
return (meta as { target?: unknown }).target;
}
@@ -6,6 +6,7 @@ import type { InboxConfig, InboxProvider } from '../inbox/inbox-provider.interfa
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
import type { TenderEmailConfigDto } from './dto/tender-email-config.dto'; import type { TenderEmailConfigDto } from './dto/tender-email-config.dto';
/** /**
@@ -204,8 +205,8 @@ export class TenderEmailConfigService {
update: { tenantId, ...data }, update: { tenantId, ...data },
select: EMAIL_CONFIG_SAFE_SELECT, select: EMAIL_CONFIG_SAFE_SELECT,
}); });
} catch (error: any) { } catch (error: unknown) {
if (error?.code === 'P2002') { if (prismaErrorCode(error) === 'P2002') {
throw new ConflictException( throw new ConflictException(
'Die Postfach-Konfiguration konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.', 'Die Postfach-Konfiguration konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.',
); );
@@ -1,6 +1,7 @@
import { ConflictException, Injectable } from '@nestjs/common'; import { ConflictException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
/** /**
* Service for managing the per-user Tender digest interval preference * Service for managing the per-user Tender digest interval preference
@@ -70,8 +71,8 @@ export class TenderNotificationPrefService {
create: { userId, tenantId, digestInterval }, create: { userId, tenantId, digestInterval },
update: { digestInterval }, update: { digestInterval },
}); });
} catch (error: any) { } catch (error: unknown) {
if (error?.code === 'P2002') { if (prismaErrorCode(error) === 'P2002') {
throw new ConflictException( throw new ConflictException(
'Die Benachrichtigungseinstellung konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.', 'Die Benachrichtigungseinstellung konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.',
); );
@@ -2,6 +2,7 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto'; import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto';
/** /**
@@ -69,8 +70,8 @@ export class TenderSavedSearchService {
...(dto.instantAlert !== undefined && { instantAlert: dto.instantAlert }), ...(dto.instantAlert !== undefined && { instantAlert: dto.instantAlert }),
}, },
}); });
} catch (error: any) { } catch (error: unknown) {
if (error?.code === 'P2002') { if (prismaErrorCode(error) === 'P2002') {
throw new ConflictException( throw new ConflictException(
'Ein Suchprofil mit diesem Namen existiert bereits.', 'Ein Suchprofil mit diesem Namen existiert bereits.',
); );
@@ -108,8 +109,8 @@ export class TenderSavedSearchService {
where: { id }, where: { id },
data, data,
}); });
} catch (error: any) { } catch (error: unknown) {
if (error?.code === 'P2002') { if (prismaErrorCode(error) === 'P2002') {
throw new ConflictException( throw new ConflictException(
'Ein Suchprofil mit diesem Namen existiert bereits.', 'Ein Suchprofil mit diesem Namen existiert bereits.',
); );
@@ -1,6 +1,7 @@
import { ConflictException, Injectable } from '@nestjs/common'; import { ConflictException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
/** /**
* Partial triage update accepted by setTriage(). Both fields are optional * Partial triage update accepted by setTriage(). Both fields are optional
@@ -90,8 +91,8 @@ export class TenderTriageService {
favoritedAt: dto.isFavorite ? now : null, favoritedAt: dto.isFavorite ? now : null,
}, },
}); });
} catch (error: any) { } catch (error: unknown) {
if (error?.code === 'P2002') { if (prismaErrorCode(error) === 'P2002') {
throw new ConflictException( throw new ConflictException(
'Der Bearbeitungsstand zu dieser Ausschreibung konnte nicht gespeichert werden. Bitte die Seite neu laden und es erneut versuchen.', 'Der Bearbeitungsstand zu dieser Ausschreibung konnte nicht gespeichert werden. Bitte die Seite neu laden und es erneut versuchen.',
); );
+3 -2
View File
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import { GroupsService } from '../groups/groups.service'; import { GroupsService } from '../groups/groups.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
/** /**
@@ -117,7 +118,7 @@ export class AdminSeedService implements OnApplicationBootstrap {
isActive: true, isActive: true,
}, },
}); });
} catch (err: any) { } catch (err: unknown) {
// Entschaerfung der Startsperre (260910-das, Befund I): trifft die // Entschaerfung der Startsperre (260910-das, Befund I): trifft die
// Erstanlage die plattformweite Eindeutigkeit von username/email // Erstanlage die plattformweite Eindeutigkeit von username/email
// (P2002), bedeutet das an DIESER Stelle exakt dasselbe wie ein // (P2002), bedeutet das an DIESER Stelle exakt dasselbe wie ein
@@ -129,7 +130,7 @@ export class AdminSeedService implements OnApplicationBootstrap {
// festgehaltenen Absicht (seedAdmin() bleibt bewusst ungekapselt) -- // festgehaltenen Absicht (seedAdmin() bleibt bewusst ungekapselt) --
// JEDER ANDERE Fehler bricht den Start weiterhin ab. Nur dieser eine, // JEDER ANDERE Fehler bricht den Start weiterhin ab. Nur dieser eine,
// an dieser Stelle gleichbedeutende Fall wird ergaenzt. // an dieser Stelle gleichbedeutende Fall wird ergaenzt.
if (err?.code === 'P2002') { if (prismaErrorCode(err) === 'P2002') {
this.logger.log( this.logger.log(
`Admin user "${username}" seed skipped: uniqueness collision on username/email (an administrator with this identity already exists, currently invisible under this tenant context) — see docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt "Bereich user"`, `Admin user "${username}" seed skipped: uniqueness collision on username/email (an administrator with this identity already exists, currently invisible under this tenant context) — see docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt "Bereich user"`,
); );
+5 -4
View File
@@ -2,6 +2,7 @@ import { ConflictException, Injectable, Logger } from '@nestjs/common';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import { GroupsService } from '../groups/groups.service'; import { GroupsService } from '../groups/groups.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
/** /**
@@ -116,8 +117,8 @@ export class UserService {
passwordHash: password ? await argon2.hash(password) : null, passwordHash: password ? await argon2.hash(password) : null,
}, },
}); });
} catch (err: any) { } catch (err: unknown) {
if (err?.code === 'P2002') { if (prismaErrorCode(err) === 'P2002') {
throw new ConflictException( throw new ConflictException(
'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.', 'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.',
); );
@@ -174,8 +175,8 @@ export class UserService {
where: { id }, where: { id },
data: updateData, data: updateData,
}); });
} catch (err: any) { } catch (err: unknown) {
if (err?.code === 'P2002') { if (prismaErrorCode(err) === 'P2002') {
throw new ConflictException( throw new ConflictException(
'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.', 'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.',
); );