refactor(quick-260921-m34): Aufgabe 2b - getypte Anfrage in elf Controllern, zwei Befunde gemeldet

(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
This commit is contained in:
2026-09-21 17:08:44 +02:00
parent f2fc39f51c
commit 7c9d7c1223
13 changed files with 182 additions and 155 deletions
+10 -10
View File
@@ -10,7 +10,7 @@ import {
Query, Query,
Req, Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { Request } from 'express'; import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { CalendarService } from './calendar.service'; import { CalendarService } from './calendar.service';
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto'; import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto'; import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto';
@@ -45,10 +45,10 @@ import { CalendarEventsQueryDto } from './dto/calendar-events-query.dto';
export class CalendarController { export class CalendarController {
constructor(private readonly calendarService: CalendarService) {} constructor(private readonly calendarService: CalendarService) {}
private extractContext(req: Request) { private extractContext(req: AuthenticatedRequest) {
const userId = (req as any).user?.id; const userId = req.user?.id;
const tenantId = const tenantId =
(req as any).tenantId ?? (req as any).user?.tenantId; req.tenantId ?? req.user?.tenantId;
if (!tenantId) { if (!tenantId) {
throw new ForbiddenException('No tenant context'); throw new ForbiddenException('No tenant context');
@@ -66,7 +66,7 @@ export class CalendarController {
* T-05-09: Uses Prisma select to exclude credentials; returns hasCredentials boolean. * T-05-09: Uses Prisma select to exclude credentials; returns hasCredentials boolean.
*/ */
@Get('sources') @Get('sources')
async getSources(@Req() req: Request) { async getSources(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
return this.calendarService.getSources(userId, tenantId); return this.calendarService.getSources(userId, tenantId);
} }
@@ -78,7 +78,7 @@ export class CalendarController {
*/ */
@Post('sources') @Post('sources')
async addSource( async addSource(
@Req() req: Request, @Req() req: AuthenticatedRequest,
@Body() dto: CreateCalendarSourceDto, @Body() dto: CreateCalendarSourceDto,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
@@ -93,7 +93,7 @@ export class CalendarController {
@Patch('sources/:id') @Patch('sources/:id')
async updateSource( async updateSource(
@Param('id') id: string, @Param('id') id: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
@Body() dto: UpdateCalendarSourceDto, @Body() dto: UpdateCalendarSourceDto,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
@@ -107,7 +107,7 @@ export class CalendarController {
@Delete('sources/:id') @Delete('sources/:id')
async deleteSource( async deleteSource(
@Param('id') id: string, @Param('id') id: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
return this.calendarService.deleteSource(id, userId, tenantId); return this.calendarService.deleteSource(id, userId, tenantId);
@@ -132,7 +132,7 @@ export class CalendarController {
@Post('sources/:id/test') @Post('sources/:id/test')
async testSource( async testSource(
@Param('id') id: string, @Param('id') id: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
return this.calendarService.testConnection(id, userId, tenantId); return this.calendarService.testConnection(id, userId, tenantId);
@@ -144,7 +144,7 @@ export class CalendarController {
*/ */
@Get('events') @Get('events')
async getEvents( async getEvents(
@Req() req: Request, @Req() req: AuthenticatedRequest,
@Query() query: CalendarEventsQueryDto, @Query() query: CalendarEventsQueryDto,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
+28 -17
View File
@@ -10,7 +10,7 @@ import {
Put, Put,
Req, Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { Request } from 'express'; import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
import { CreateSearchProviderDto } from './dto/create-search-provider.dto'; import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
import { CreateWidgetDto } from './dto/create-widget.dto'; import { CreateWidgetDto } from './dto/create-widget.dto';
@@ -39,42 +39,53 @@ import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
export class DashboardController { export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {} constructor(private readonly dashboardService: DashboardService) {}
private extractContext(req: Request) { /**
const userId = (req as any).user?.id; * BEFUND quick-260921-m34 (D-03, gemeldet nicht repariert): `getWidgets`
* las die Rolle vorher als `req.user?.role` NACH dieser Pruefung und gab
* sie an `DashboardService.getWidgets(role: Role)` weiter, das eine Rolle
* zwingend verlangt. Der Bestandscode nahm also an, dass an dieser Stelle
* immer ein Aufrufer vorliegt. Die Annahme stimmt — die Pruefung "No user
* context" direkt darunter erzwingt sie seit jeher —, aber der Compiler
* konnte die beiden Stellen nicht verbinden, weil sie in zwei Methoden
* standen. Deshalb gibt diese Methode die Rolle jetzt MIT zurueck: keine
* neue Pruefung, kein erfundener Wert, gleiche Reihenfolge, gleiche
* Meldungen, gleiches Verhalten — nur sichtbar statt angenommen.
*/
private extractContext(req: AuthenticatedRequest) {
const user = req.user;
const tenantId = const tenantId =
(req as any).tenantId ?? (req as any).user?.tenantId; req.tenantId ?? user?.tenantId;
if (!tenantId) { if (!tenantId) {
throw new ForbiddenException('No tenant context'); throw new ForbiddenException('No tenant context');
} }
if (!userId) { if (!user?.id) {
throw new ForbiddenException('No user context'); throw new ForbiddenException('No user context');
} }
return { userId, tenantId }; return { userId: user.id, tenantId, role: user.role };
} }
@Get('layout') @Get('layout')
async getLayout(@Req() req: Request) { async getLayout(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.getLayout(userId, tenantId); return this.dashboardService.getLayout(userId, tenantId);
} }
@Put('layout') @Put('layout')
async saveLayout(@Req() req: Request, @Body() dto: SaveLayoutDto) { async saveLayout(@Req() req: AuthenticatedRequest, @Body() dto: SaveLayoutDto) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.saveLayout(userId, tenantId, dto); return this.dashboardService.saveLayout(userId, tenantId, dto);
} }
@Get('widgets') @Get('widgets')
async getWidgets(@Req() req: Request) { async getWidgets(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId, role } = this.extractContext(req);
const role = (req as any).user?.role;
return this.dashboardService.getWidgets(userId, tenantId, role); return this.dashboardService.getWidgets(userId, tenantId, role);
} }
@Post('widgets') @Post('widgets')
async addWidget(@Req() req: Request, @Body() dto: CreateWidgetDto) { async addWidget(@Req() req: AuthenticatedRequest, @Body() dto: CreateWidgetDto) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.addWidget(userId, tenantId, dto); return this.dashboardService.addWidget(userId, tenantId, dto);
} }
@@ -82,7 +93,7 @@ export class DashboardController {
@Patch('widgets/:id/config') @Patch('widgets/:id/config')
async updateWidgetConfig( async updateWidgetConfig(
@Param('id') id: string, @Param('id') id: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
@Body() dto: UpdateWidgetConfigDto, @Body() dto: UpdateWidgetConfigDto,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
@@ -92,7 +103,7 @@ export class DashboardController {
@Delete('widgets/:id') @Delete('widgets/:id')
async removeWidget( async removeWidget(
@Param('id') id: string, @Param('id') id: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.removeWidget(id, userId, tenantId); return this.dashboardService.removeWidget(id, userId, tenantId);
@@ -101,14 +112,14 @@ export class DashboardController {
// --- Search Providers (05-02, D-15) --- // --- Search Providers (05-02, D-15) ---
@Get('search-providers') @Get('search-providers')
async getSearchProviders(@Req() req: Request) { async getSearchProviders(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.getSearchProviders(userId, tenantId); return this.dashboardService.getSearchProviders(userId, tenantId);
} }
@Post('search-providers') @Post('search-providers')
async addSearchProvider( async addSearchProvider(
@Req() req: Request, @Req() req: AuthenticatedRequest,
@Body() dto: CreateSearchProviderDto, @Body() dto: CreateSearchProviderDto,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
@@ -118,7 +129,7 @@ export class DashboardController {
@Delete('search-providers/:id') @Delete('search-providers/:id')
async removeSearchProvider( async removeSearchProvider(
@Param('id') id: string, @Param('id') id: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.removeSearchProvider(id, userId, tenantId); return this.dashboardService.removeSearchProvider(id, userId, tenantId);
+21 -16
View File
@@ -17,6 +17,11 @@ import {
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import type {
AuthenticatedRequest,
UploadedFileLike,
} from '../auth/types/auth-user';
import type { Response } from 'express';
import { DkvSchedulerService } from './dkv-scheduler.service'; import { DkvSchedulerService } from './dkv-scheduler.service';
import { DkvService } from './dkv.service'; import { DkvService } from './dkv.service';
import { DkvConfigDto } from './dto/dkv-config.dto'; import { DkvConfigDto } from './dto/dkv-config.dto';
@@ -58,7 +63,7 @@ export class DkvController {
/** GET /dkv/config — returns module config with username + hasPassword. 404 when not yet configured. */ /** GET /dkv/config — returns module config with username + hasPassword. 404 when not yet configured. */
@Get('config') @Get('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getConfig(@Req() req: any) { async getConfig(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
const config = await this.dkvService.getConfigForApi(tenantId); const config = await this.dkvService.getConfigForApi(tenantId);
if (!config) { if (!config) {
@@ -75,7 +80,7 @@ export class DkvController {
*/ */
@Put('config') @Put('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async saveConfig(@Req() req: any, @Body() dto: DkvConfigDto) { async saveConfig(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
const result = await this.dkvService.saveConfig(tenantId, dto); const result = await this.dkvService.saveConfig(tenantId, dto);
@@ -94,7 +99,7 @@ export class DkvController {
/** POST /dkv/check-now — immediately run the inbox processing pipeline. */ /** POST /dkv/check-now — immediately run the inbox processing pipeline. */
@Post('check-now') @Post('check-now')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async checkNow(@Req() req: any) { async checkNow(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
return this.dkvService.checkNow(tenantId); return this.dkvService.checkNow(tenantId);
} }
@@ -105,7 +110,7 @@ export class DkvController {
*/ */
@Post('test-connection') @Post('test-connection')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testConnection(@Req() req: any, @Body() dto: DkvConfigDto) { async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
return this.dkvService.testConnection(tenantId, dto); return this.dkvService.testConnection(tenantId, dto);
} }
@@ -118,7 +123,7 @@ export class DkvController {
*/ */
@Get('history') @Get('history')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getHistory(@Req() req: any, @Query() query: DkvHistoryQueryDto) { async getHistory(@Req() req: AuthenticatedRequest, @Query() query: DkvHistoryQueryDto) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
const page = query.page ?? 1; const page = query.page ?? 1;
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
@@ -137,9 +142,9 @@ export class DkvController {
@Get('exports/:filename') @Get('exports/:filename')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async downloadExport( async downloadExport(
@Req() req: any, @Req() req: AuthenticatedRequest,
@Param('filename') filename: string, @Param('filename') filename: string,
@Res() res: any, @Res() res: Response,
) { ) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
@@ -164,7 +169,7 @@ export class DkvController {
/** GET /dkv/vehicles — list all vehicle master records for this tenant. */ /** GET /dkv/vehicles — list all vehicle master records for this tenant. */
@Get('vehicles') @Get('vehicles')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async listVehicles(@Req() req: any) { async listVehicles(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
return this.dkvService.listVehicles(tenantId); return this.dkvService.listVehicles(tenantId);
} }
@@ -172,7 +177,7 @@ export class DkvController {
/** POST /dkv/vehicles — create a new vehicle master record. */ /** POST /dkv/vehicles — create a new vehicle master record. */
@Post('vehicles') @Post('vehicles')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async createVehicle(@Req() req: any, @Body() dto: CreateVehicleDto) { async createVehicle(@Req() req: AuthenticatedRequest, @Body() dto: CreateVehicleDto) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
return this.dkvService.createVehicle(tenantId, dto); return this.dkvService.createVehicle(tenantId, dto);
} }
@@ -181,7 +186,7 @@ export class DkvController {
@Put('vehicles/:id') @Put('vehicles/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async updateVehicle( async updateVehicle(
@Req() req: any, @Req() req: AuthenticatedRequest,
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateVehicleDto, @Body() dto: UpdateVehicleDto,
) { ) {
@@ -192,7 +197,7 @@ export class DkvController {
/** DELETE /dkv/vehicles/:id — delete a vehicle master record. */ /** DELETE /dkv/vehicles/:id — delete a vehicle master record. */
@Delete('vehicles/:id') @Delete('vehicles/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async deleteVehicle(@Req() req: any, @Param('id') id: string) { async deleteVehicle(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
return this.dkvService.deleteVehicle(tenantId, id); return this.dkvService.deleteVehicle(tenantId, id);
} }
@@ -213,8 +218,8 @@ export class DkvController {
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any realistic vehicle list (WR-05) limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any realistic vehicle list (WR-05)
})) }))
async importVehicles( async importVehicles(
@Req() req: any, @Req() req: AuthenticatedRequest,
@UploadedFile() file: any, @UploadedFile() file: UploadedFileLike | undefined,
@Body('mode') mode: string, @Body('mode') mode: string,
) { ) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
@@ -223,7 +228,7 @@ export class DkvController {
throw new BadRequestException('No CSV file uploaded (field name must be "file")'); throw new BadRequestException('No CSV file uploaded (field name must be "file")');
} }
const csvText = (file.buffer as Buffer).toString('utf-8'); const csvText = file.buffer.toString('utf-8');
const importMode = mode === 'replace' ? 'replace' : 'merge'; const importMode = mode === 'replace' ? 'replace' : 'merge';
return this.dkvService.importVehiclesCsv(tenantId, csvText, importMode); return this.dkvService.importVehiclesCsv(tenantId, csvText, importMode);
@@ -232,8 +237,8 @@ export class DkvController {
// ─── Private helpers ─────────────────────────────────────────────────────── // ─── Private helpers ───────────────────────────────────────────────────────
/** Extract and validate tenantId from request; throw BadRequestException when absent. */ /** Extract and validate tenantId from request; throw BadRequestException when absent. */
private _requireTenant(req: any): string { private _requireTenant(req: AuthenticatedRequest): string {
const tenantId = req.tenantId as string | undefined; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
} }
+11 -10
View File
@@ -13,7 +13,8 @@ import {
Req, Req,
Res, Res,
} from '@nestjs/common'; } from '@nestjs/common';
import { Request, Response } from 'express'; import { Response } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { CreateFavoriteDto } from './dto/create-favorite.dto'; import { CreateFavoriteDto } from './dto/create-favorite.dto';
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto'; import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
import { UpdateFavoriteDto } from './dto/update-favorite.dto'; import { UpdateFavoriteDto } from './dto/update-favorite.dto';
@@ -46,10 +47,10 @@ import { FavoritesService } from './favorites.service';
export class FavoritesController { export class FavoritesController {
constructor(private readonly favoritesService: FavoritesService) {} constructor(private readonly favoritesService: FavoritesService) {}
private extractContext(req: Request) { private extractContext(req: AuthenticatedRequest) {
const userId = (req as any).user?.id; const userId = req.user?.id;
const tenantId = const tenantId =
(req as any).tenantId ?? (req as any).user?.tenantId; req.tenantId ?? req.user?.tenantId;
if (!tenantId) { if (!tenantId) {
throw new ForbiddenException('No tenant context'); throw new ForbiddenException('No tenant context');
@@ -64,7 +65,7 @@ export class FavoritesController {
@Get() @Get()
async list( async list(
@Query('widgetId', ParseUUIDPipe) widgetId: string, @Query('widgetId', ParseUUIDPipe) widgetId: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
@@ -74,7 +75,7 @@ export class FavoritesController {
@Post() @Post()
async create( async create(
@Body() dto: CreateFavoriteDto, @Body() dto: CreateFavoriteDto,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
@@ -91,7 +92,7 @@ export class FavoritesController {
@Put('order') @Put('order')
async reorder( async reorder(
@Body() dto: ReorderFavoritesDto, @Body() dto: ReorderFavoritesDto,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
@@ -110,7 +111,7 @@ export class FavoritesController {
@Get(':id/icon') @Get(':id/icon')
async getIcon( async getIcon(
@Param('id') id: string, @Param('id') id: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
@Res() res: Response, @Res() res: Response,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
@@ -135,7 +136,7 @@ export class FavoritesController {
async update( async update(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateFavoriteDto, @Body() dto: UpdateFavoriteDto,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
@@ -145,7 +146,7 @@ export class FavoritesController {
@Delete(':id') @Delete(':id')
async remove( async remove(
@Param('id') id: string, @Param('id') id: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
+11 -11
View File
@@ -11,7 +11,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { Request } from 'express'; import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { AddGroupMembersDto } from './dto/add-group-members.dto'; import { AddGroupMembersDto } from './dto/add-group-members.dto';
@@ -30,8 +30,8 @@ import { GroupsService } from './groups.service';
export class GroupsController { export class GroupsController {
constructor(private readonly groupsService: GroupsService) {} constructor(private readonly groupsService: GroupsService) {}
private getTenantId(req: Request): string { private getTenantId(req: AuthenticatedRequest): string {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) { if (!tenantId) {
throw new ForbiddenException('No tenant context'); throw new ForbiddenException('No tenant context');
} }
@@ -45,7 +45,7 @@ export class GroupsController {
@Get() @Get()
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async list(@Req() req: Request) { async list(@Req() req: AuthenticatedRequest) {
return this.groupsService.listForTenant(this.getTenantId(req)); return this.groupsService.listForTenant(this.getTenantId(req));
} }
@@ -56,7 +56,7 @@ export class GroupsController {
@Post() @Post()
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Body() dto: CreateGroupDto, @Req() req: Request) { async create(@Body() dto: CreateGroupDto, @Req() req: AuthenticatedRequest) {
return this.groupsService.create(this.getTenantId(req), dto); return this.groupsService.create(this.getTenantId(req), dto);
} }
@@ -70,7 +70,7 @@ export class GroupsController {
async update( async update(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateGroupDto, @Body() dto: UpdateGroupDto,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
return this.groupsService.update(this.getTenantId(req), id, dto); return this.groupsService.update(this.getTenantId(req), id, dto);
} }
@@ -82,7 +82,7 @@ export class GroupsController {
@Delete(':id') @Delete(':id')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Param('id') id: string, @Req() req: Request) { async remove(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
return this.groupsService.remove(this.getTenantId(req), id); return this.groupsService.remove(this.getTenantId(req), id);
} }
@@ -93,7 +93,7 @@ export class GroupsController {
@Get(':id/impact') @Get(':id/impact')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async impact(@Param('id') id: string, @Req() req: Request) { async impact(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
return this.groupsService.getImpact(this.getTenantId(req), id); return this.groupsService.getImpact(this.getTenantId(req), id);
} }
@@ -104,7 +104,7 @@ export class GroupsController {
@Get(':id/members') @Get(':id/members')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async members(@Param('id') id: string, @Req() req: Request) { async members(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
return this.groupsService.listMembers(this.getTenantId(req), id); return this.groupsService.listMembers(this.getTenantId(req), id);
} }
@@ -118,7 +118,7 @@ export class GroupsController {
async addMembers( async addMembers(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: AddGroupMembersDto, @Body() dto: AddGroupMembersDto,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
return this.groupsService.addMembers(this.getTenantId(req), id, dto.userIds); return this.groupsService.addMembers(this.getTenantId(req), id, dto.userIds);
} }
@@ -134,7 +134,7 @@ export class GroupsController {
async removeMember( async removeMember(
@Param('id') id: string, @Param('id') id: string,
@Param('userId') userId: string, @Param('userId') userId: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
await this.groupsService.removeMember(this.getTenantId(req), id, userId); await this.groupsService.removeMember(this.getTenantId(req), id, userId);
return { success: true }; return { success: true };
@@ -10,7 +10,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { Request } from 'express'; import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { CreateModuleGrantDto } from './dto/create-module-grant.dto'; import { CreateModuleGrantDto } from './dto/create-module-grant.dto';
@@ -31,8 +31,8 @@ import { ModuleGrantsService } from './module-grants.service';
export class ModuleGrantsController { export class ModuleGrantsController {
constructor(private readonly moduleGrantsService: ModuleGrantsService) {} constructor(private readonly moduleGrantsService: ModuleGrantsService) {}
private getTenantId(req: Request): string { private getTenantId(req: AuthenticatedRequest): string {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) { if (!tenantId) {
throw new ForbiddenException('No tenant context'); throw new ForbiddenException('No tenant context');
} }
@@ -46,7 +46,7 @@ export class ModuleGrantsController {
@Get('matrix') @Get('matrix')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async matrix(@Req() req: Request) { async matrix(@Req() req: AuthenticatedRequest) {
return this.moduleGrantsService.getMatrix(this.getTenantId(req)); return this.moduleGrantsService.getMatrix(this.getTenantId(req));
} }
@@ -61,7 +61,7 @@ export class ModuleGrantsController {
@Get('users/:userId') @Get('users/:userId')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async userAccess(@Param('userId') userId: string, @Req() req: Request) { async userAccess(@Param('userId') userId: string, @Req() req: AuthenticatedRequest) {
return this.moduleGrantsService.getUserAccess(this.getTenantId(req), userId); return this.moduleGrantsService.getUserAccess(this.getTenantId(req), userId);
} }
@@ -72,7 +72,7 @@ export class ModuleGrantsController {
@Post() @Post()
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Body() dto: CreateModuleGrantDto, @Req() req: Request) { async create(@Body() dto: CreateModuleGrantDto, @Req() req: AuthenticatedRequest) {
return this.moduleGrantsService.grant(this.getTenantId(req), dto); return this.moduleGrantsService.grant(this.getTenantId(req), dto);
} }
@@ -84,7 +84,7 @@ export class ModuleGrantsController {
@Delete() @Delete()
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Body() dto: CreateModuleGrantDto, @Req() req: Request) { async remove(@Body() dto: CreateModuleGrantDto, @Req() req: AuthenticatedRequest) {
await this.moduleGrantsService.revoke(this.getTenantId(req), dto); await this.moduleGrantsService.revoke(this.getTenantId(req), dto);
return { success: true }; return { success: true };
} }
+12 -11
View File
@@ -13,6 +13,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { import {
CreateFieldMappingDto, CreateFieldMappingDto,
CreateLdapConfigDto, CreateLdapConfigDto,
@@ -41,7 +42,7 @@ export class LdapController {
*/ */
@Get('config') @Get('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getConfig(@Req() req: any) { async getConfig(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
@@ -65,7 +66,7 @@ export class LdapController {
*/ */
@Post('config') @Post('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async createConfig(@Req() req: any, @Body() dto: CreateLdapConfigDto) { async createConfig(@Req() req: AuthenticatedRequest, @Body() dto: CreateLdapConfigDto) {
const tenantId = req.tenantId; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
@@ -92,7 +93,7 @@ export class LdapController {
*/ */
@Patch('config') @Patch('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async updateConfig(@Req() req: any, @Body() dto: UpdateLdapConfigDto) { async updateConfig(@Req() req: AuthenticatedRequest, @Body() dto: UpdateLdapConfigDto) {
const tenantId = req.tenantId; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
@@ -123,7 +124,7 @@ export class LdapController {
*/ */
@Post('test-connection') @Post('test-connection')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testConnection(@Req() req: any, @Body() dto: TestConnectionDto) { async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: TestConnectionDto) {
const tenantId = req.tenantId; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
@@ -158,7 +159,7 @@ export class LdapController {
*/ */
@Get('groups') @Get('groups')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async listGroups(@Req() req: any) { async listGroups(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
@@ -189,7 +190,7 @@ export class LdapController {
*/ */
@Post('groups/import') @Post('groups/import')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async importGroups(@Req() req: any, @Body() dto: ImportGroupsDto) { async importGroups(@Req() req: AuthenticatedRequest, @Body() dto: ImportGroupsDto) {
const tenantId = req.tenantId; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
@@ -225,7 +226,7 @@ export class LdapController {
*/ */
@Get('users/search') @Get('users/search')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async searchUsers(@Req() req: any, @Query('q') q: string) { async searchUsers(@Req() req: AuthenticatedRequest, @Query('q') q: string) {
const tenantId = req.tenantId; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
@@ -256,7 +257,7 @@ export class LdapController {
*/ */
@Post('users/import') @Post('users/import')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async importUsers(@Req() req: any, @Body() dto: ImportUsersDto) { async importUsers(@Req() req: AuthenticatedRequest, @Body() dto: ImportUsersDto) {
const tenantId = req.tenantId; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
@@ -292,7 +293,7 @@ export class LdapController {
*/ */
@Post('sync') @Post('sync')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async triggerSync(@Req() req: any) { async triggerSync(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
@@ -326,7 +327,7 @@ export class LdapController {
*/ */
@Post('config/mappings') @Post('config/mappings')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async addFieldMapping(@Req() req: any, @Body() dto: CreateFieldMappingDto) { async addFieldMapping(@Req() req: AuthenticatedRequest, @Body() dto: CreateFieldMappingDto) {
const tenantId = req.tenantId; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
@@ -351,7 +352,7 @@ export class LdapController {
*/ */
@Delete('config/mappings/:id') @Delete('config/mappings/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async removeFieldMapping(@Req() req: any, @Param('id') id: string) { async removeFieldMapping(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = req.tenantId; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
@@ -8,7 +8,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { Request } from 'express'; import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { ModuleAccessService } from './module-access.service'; import { ModuleAccessService } from './module-access.service';
@@ -53,10 +53,10 @@ export class ModuleRegistryController {
* catalog (mandantenweite Sicht, kein Benutzerfilter). * catalog (mandantenweite Sicht, kein Benutzerfilter).
*/ */
@Get('active') @Get('active')
async findActive(@Req() req: Request) { async findActive(@Req() req: AuthenticatedRequest) {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; const tenantId = req.tenantId ?? req.user?.tenantId;
const userId = (req as any).user?.id; const userId = req.user?.id;
const role = (req as any).user?.role; const role = req.user?.role;
if (!tenantId || !userId || !role) { if (!tenantId || !userId || !role) {
throw new ForbiddenException('No user context'); throw new ForbiddenException('No user context');
} }
@@ -74,10 +74,10 @@ export class ModuleRegistryController {
* Erreichbar für jeden authentifizierten Benutzer, wie GET /modules. * Erreichbar für jeden authentifizierten Benutzer, wie GET /modules.
*/ */
@Get('catalog') @Get('catalog')
async findCatalog(@Req() req: Request) { async findCatalog(@Req() req: AuthenticatedRequest) {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; const tenantId = req.tenantId ?? req.user?.tenantId;
const userId = (req as any).user?.id; const userId = req.user?.id;
const role = (req as any).user?.role; const role = req.user?.role;
if (!tenantId || !userId || !role) { if (!tenantId || !userId || !role) {
throw new ForbiddenException('No user context'); throw new ForbiddenException('No user context');
} }
@@ -105,9 +105,9 @@ export class ModuleRegistryController {
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async activate( async activate(
@Param('moduleId') moduleId: string, @Param('moduleId') moduleId: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) { if (!tenantId) {
throw new ForbiddenException('No tenant context'); throw new ForbiddenException('No tenant context');
} }
@@ -124,9 +124,9 @@ export class ModuleRegistryController {
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async deactivate( async deactivate(
@Param('moduleId') moduleId: string, @Param('moduleId') moduleId: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) { if (!tenantId) {
throw new ForbiddenException('No tenant context'); throw new ForbiddenException('No tenant context');
} }
+7 -6
View File
@@ -9,6 +9,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { SmtpConfigDto } from './dto/smtp-config.dto'; import { SmtpConfigDto } from './dto/smtp-config.dto';
import { SettingsService } from './settings.service'; import { SettingsService } from './settings.service';
@@ -29,8 +30,8 @@ export class SettingsController {
*/ */
@Get('smtp') @Get('smtp')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getSmtpConfig(@Req() req: any) { async getSmtpConfig(@Req() req: AuthenticatedRequest) {
const tenantId = req.tenantId as string | undefined; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
} }
@@ -50,8 +51,8 @@ export class SettingsController {
*/ */
@Put('smtp') @Put('smtp')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async saveSmtpConfig(@Req() req: any, @Body() dto: SmtpConfigDto) { async saveSmtpConfig(@Req() req: AuthenticatedRequest, @Body() dto: SmtpConfigDto) {
const tenantId = req.tenantId as string | undefined; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
} }
@@ -67,8 +68,8 @@ export class SettingsController {
*/ */
@Post('smtp/test') @Post('smtp/test')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testSmtpConfig(@Req() req: any, @Body() dto: SmtpConfigDto) { async testSmtpConfig(@Req() req: AuthenticatedRequest, @Body() dto: SmtpConfigDto) {
const tenantId = req.tenantId as string | undefined; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
} }
+1 -1
View File
@@ -44,7 +44,7 @@ const MAX_FAV_IDS = 500;
export function buildTenderWhere( export function buildTenderWhere(
dto: TenderQueryDto, dto: TenderQueryDto,
favIds?: string[], favIds?: string[],
ownerTenantId?: string, ownerTenantId?: string | null,
): Prisma.TenderWhereInput { ): Prisma.TenderWhereInput {
const where: Prisma.TenderWhereInput = {}; const where: Prisma.TenderWhereInput = {};
const AND: Prisma.TenderWhereInput[] = []; const AND: Prisma.TenderWhereInput[] = [];
+26 -24
View File
@@ -13,7 +13,7 @@ import {
Req, Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { Request } from 'express'; import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { UseModule } from '../module-registry/module.guard'; import { UseModule } from '../module-registry/module.guard';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -115,10 +115,10 @@ export class TendersController {
* (`roles.guard.ts`) — one single spot in this controller resolves * (`roles.guard.ts`) — one single spot in this controller resolves
* account data from the request. * account data from the request.
*/ */
private extractTriageContext(req: Request) { private extractTriageContext(req: AuthenticatedRequest) {
const userId = (req as any).user?.id; const userId = req.user?.id;
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; const tenantId = req.tenantId ?? req.user?.tenantId;
const role = (req as any).user?.role; const role = req.user?.role;
if (!tenantId) { if (!tenantId) {
throw new ForbiddenException('No tenant context'); throw new ForbiddenException('No tenant context');
@@ -138,8 +138,10 @@ export class TendersController {
* only" (fail-closed, tender-query.builder.ts) rather than 403 when no * only" (fail-closed, tender-query.builder.ts) rather than 403 when no
* tenant context is present. * tenant context is present.
*/ */
private resolveRequestingTenantId(req?: Request): string | undefined { private resolveRequestingTenantId(
return (req as any)?.user?.tenantId ?? (req as any)?.tenantId; req?: AuthenticatedRequest,
): string | null | undefined {
return req?.user?.tenantId ?? req?.tenantId;
} }
// ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ──────────────────── // ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ────────────────────
@@ -164,7 +166,7 @@ export class TendersController {
*/ */
@Get() @Get()
@UseModule('tender-radar') @UseModule('tender-radar')
async listTenders(@Query() query: TenderQueryDto, @Req() req?: Request) { async listTenders(@Query() query: TenderQueryDto, @Req() req?: AuthenticatedRequest) {
const page = query.page ?? 1; const page = query.page ?? 1;
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
@@ -175,7 +177,7 @@ export class TendersController {
// optional type only accommodates unit tests that call this method // optional type only accommodates unit tests that call this method
// directly without favOnly set (T-11-10: extractTriageContext // directly without favOnly set (T-11-10: extractTriageContext
// throws ForbiddenException if req/user context is genuinely absent). // throws ForbiddenException if req/user context is genuinely absent).
const { userId, tenantId } = this.extractTriageContext(req as Request); const { userId, tenantId } = this.extractTriageContext(req as AuthenticatedRequest);
favIds = await this.tenderTriage.favoriteIds(userId, tenantId); favIds = await this.tenderTriage.favoriteIds(userId, tenantId);
} }
@@ -263,7 +265,7 @@ export class TendersController {
*/ */
@Get('rss-feeds') @Get('rss-feeds')
@UseModule('tender-radar') @UseModule('tender-radar')
async listRssFeeds(@Req() req: Request) { async listRssFeeds(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
const feeds = await this.tenderRssFeedSource.listForUser(userId, tenantId); const feeds = await this.tenderRssFeedSource.listForUser(userId, tenantId);
@@ -286,7 +288,7 @@ export class TendersController {
*/ */
@Post('rss-feeds') @Post('rss-feeds')
@UseModule('tender-radar') @UseModule('tender-radar')
async createRssFeed(@Body() dto: TenderRssFeedDto, @Req() req: Request) { async createRssFeed(@Body() dto: TenderRssFeedDto, @Req() req: AuthenticatedRequest) {
const { userId, tenantId, role } = this.extractTriageContext(req); const { userId, tenantId, role } = this.extractTriageContext(req);
if (dto.scope === 'platform') { if (dto.scope === 'platform') {
@@ -319,7 +321,7 @@ export class TendersController {
*/ */
@Delete('rss-feeds/:feedId') @Delete('rss-feeds/:feedId')
@UseModule('tender-radar') @UseModule('tender-radar')
async removeRssFeed(@Param('feedId') feedId: string, @Req() req: Request) { async removeRssFeed(@Param('feedId') feedId: string, @Req() req: AuthenticatedRequest) {
const { userId, role } = this.extractTriageContext(req); const { userId, role } = this.extractTriageContext(req);
const isAdmin = role === Role.ADMIN || role === Role.SUPER_ADMIN; const isAdmin = role === Role.ADMIN || role === Role.SUPER_ADMIN;
return this.tenderRssFeedSource.remove(feedId, { userId, isAdmin }); return this.tenderRssFeedSource.remove(feedId, { userId, isAdmin });
@@ -342,7 +344,7 @@ export class TendersController {
*/ */
@Get('email-config') @Get('email-config')
@UseModule('tender-radar') @UseModule('tender-radar')
async getEmailConfig(@Req() req: Request) { async getEmailConfig(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.getConfigForApi(userId, tenantId); return this.tenderEmailConfig.getConfigForApi(userId, tenantId);
} }
@@ -356,7 +358,7 @@ export class TendersController {
*/ */
@Put('email-config') @Put('email-config')
@UseModule('tender-radar') @UseModule('tender-radar')
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) { async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.saveConfig({ userId, tenantId }, dto); return this.tenderEmailConfig.saveConfig({ userId, tenantId }, dto);
} }
@@ -381,7 +383,7 @@ export class TendersController {
*/ */
@Post('email-config/test') @Post('email-config/test')
@UseModule('tender-radar') @UseModule('tender-radar')
async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: Request) { async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderEmailConfig.testConnection(userId, tenantId, dto); return this.tenderEmailConfig.testConnection(userId, tenantId, dto);
} }
@@ -456,7 +458,7 @@ export class TendersController {
*/ */
@Get('triage') @Get('triage')
@UseModule('tender-radar') @UseModule('tender-radar')
async listTriage(@Query('ids') ids: string | undefined, @Req() req: Request) { async listTriage(@Query('ids') ids: string | undefined, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
const tenderIds = (ids ?? '') const tenderIds = (ids ?? '')
.split(',') .split(',')
@@ -480,7 +482,7 @@ export class TendersController {
*/ */
@Put('triage') @Put('triage')
@UseModule('tender-radar') @UseModule('tender-radar')
async setTriage(@Body() dto: TenderTriageDto, @Req() req: Request) { async setTriage(@Body() dto: TenderTriageDto, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderTriage.setTriage(userId, tenantId, dto.tenderId, { return this.tenderTriage.setTriage(userId, tenantId, dto.tenderId, {
@@ -502,7 +504,7 @@ export class TendersController {
*/ */
@Get('saved-searches') @Get('saved-searches')
@UseModule('tender-radar') @UseModule('tender-radar')
async listSavedSearches(@Req() req: Request) { async listSavedSearches(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderSavedSearch.list(userId, tenantId); return this.tenderSavedSearch.list(userId, tenantId);
} }
@@ -517,7 +519,7 @@ export class TendersController {
@UseModule('tender-radar') @UseModule('tender-radar')
async createSavedSearch( async createSavedSearch(
@Body() dto: CreateSavedSearchDto, @Body() dto: CreateSavedSearchDto,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const { userId, tenantId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderSavedSearch.create(userId, tenantId, dto); return this.tenderSavedSearch.create(userId, tenantId, dto);
@@ -535,7 +537,7 @@ export class TendersController {
async updateSavedSearch( async updateSavedSearch(
@Param('searchId') searchId: string, @Param('searchId') searchId: string,
@Body() dto: UpdateSavedSearchDto, @Body() dto: UpdateSavedSearchDto,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const { userId, tenantId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderSavedSearch.update(searchId, userId, tenantId, dto); return this.tenderSavedSearch.update(searchId, userId, tenantId, dto);
@@ -550,7 +552,7 @@ export class TendersController {
@UseModule('tender-radar') @UseModule('tender-radar')
async removeSavedSearch( async removeSavedSearch(
@Param('searchId') searchId: string, @Param('searchId') searchId: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const { userId, tenantId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
await this.tenderSavedSearch.remove(searchId, userId, tenantId); await this.tenderSavedSearch.remove(searchId, userId, tenantId);
@@ -571,7 +573,7 @@ export class TendersController {
*/ */
@Get('notification-pref') @Get('notification-pref')
@UseModule('tender-radar') @UseModule('tender-radar')
async getNotificationPref(@Req() req: Request) { async getNotificationPref(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderNotificationPref.getForUser(userId, tenantId); return this.tenderNotificationPref.getForUser(userId, tenantId);
} }
@@ -586,7 +588,7 @@ export class TendersController {
@UseModule('tender-radar') @UseModule('tender-radar')
async setNotificationPref( async setNotificationPref(
@Body() dto: UpdateNotificationPrefDto, @Body() dto: UpdateNotificationPrefDto,
@Req() req: Request, @Req() req: AuthenticatedRequest,
) { ) {
const { userId, tenantId } = this.extractTriageContext(req); const { userId, tenantId } = this.extractTriageContext(req);
return this.tenderNotificationPref.setForUser(userId, tenantId, dto.digestInterval); return this.tenderNotificationPref.setForUser(userId, tenantId, dto.digestInterval);
@@ -606,7 +608,7 @@ export class TendersController {
*/ */
@Get(':id') @Get(':id')
@UseModule('tender-radar') @UseModule('tender-radar')
async getTender(@Param('id') id: string, @Req() req?: Request) { async getTender(@Param('id') id: string, @Req() req?: AuthenticatedRequest) {
const tender = await this.prisma.tender.findUnique({ const tender = await this.prisma.tender.findUnique({
where: { id }, where: { id },
include: { include: {
+21 -16
View File
@@ -153,7 +153,7 @@ describe('UserController', () => {
lastLoginAt: null, lastLoginAt: null,
}); });
const result = await controller.findAll({ role: Role.ADMIN, tenantId: 't1', id: 'admin1' }); const result = await controller.findAll({ role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false });
expect(result.map((u: any) => u.username)).toEqual(['alice']); expect(result.map((u: any) => u.username)).toEqual(['alice']);
expectBoundCall(prisma, 't1', 'user', 'findMany'); expectBoundCall(prisma, 't1', 'user', 'findMany');
@@ -170,6 +170,8 @@ describe('UserController', () => {
role: Role.SUPER_ADMIN, role: Role.SUPER_ADMIN,
tenantId: 't1', tenantId: 't1',
id: 'super1', id: 'super1',
username: 'super1',
mustChangePassword: false,
}); });
expect(result).toBe(expected); expect(result).toBe(expected);
@@ -183,11 +185,11 @@ describe('UserController', () => {
const targetUser = { id: 'u-x', username: 'x', tenantId: 't1' }; const targetUser = { id: 'u-x', username: 'x', tenantId: 't1' };
userService.findById.mockResolvedValue(targetUser); userService.findById.mockResolvedValue(targetUser);
await controller.findOne('u-x', { role: Role.ADMIN, tenantId: 't1', id: 'admin1' }); await controller.findOne('u-x', { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false });
expect(userService.findById).toHaveBeenCalledWith('t1', 'u-x'); expect(userService.findById).toHaveBeenCalledWith('t1', 'u-x');
userService.findByIdForPlatformAdmin.mockResolvedValue(targetUser); userService.findByIdForPlatformAdmin.mockResolvedValue(targetUser);
await controller.findOne('u-x', { role: Role.SUPER_ADMIN, tenantId: 't2', id: 'super1' }); await controller.findOne('u-x', { role: Role.SUPER_ADMIN, tenantId: 't2', id: 'super1', username: 'super1', mustChangePassword: false });
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('u-x'); expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('u-x');
}); });
@@ -195,14 +197,14 @@ describe('UserController', () => {
userService.findById.mockResolvedValue(null); userService.findById.mockResolvedValue(null);
await expect( await expect(
controller.findOne('u-foreign', { role: Role.ADMIN, tenantId: 't1', id: 'admin1' }), controller.findOne('u-foreign', { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false }),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
}); });
describe('create', () => { describe('create', () => {
it('Test 5: ein Mandanten-Administrator kann weiterhin keine oberste Rolle vergeben, und die Anlage eines Benutzers landet weiterhin im Mandanten des Aufrufers, wenn dieser nicht die oberste Rolle trägt', async () => { it('Test 5: ein Mandanten-Administrator kann weiterhin keine oberste Rolle vergeben, und die Anlage eines Benutzers landet weiterhin im Mandanten des Aufrufers, wenn dieser nicht die oberste Rolle trägt', async () => {
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' }; const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
await expect( await expect(
controller.create( controller.create(
@@ -224,7 +226,7 @@ describe('UserController', () => {
describe('remove — Selbstlöschriegel (Befund H)', () => { describe('remove — Selbstlöschriegel (Befund H)', () => {
it('Test 6: der Riegel gegen das Löschen des eigenen Kontos greift', async () => { it('Test 6: der Riegel gegen das Löschen des eigenen Kontos greift', async () => {
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' }; const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'admin1', tenantId: 't1' }); userService.findById.mockResolvedValue({ id: 'admin1', tenantId: 't1' });
await expect(controller.remove('admin1', currentUser)).rejects.toBeInstanceOf( await expect(controller.remove('admin1', currentUser)).rejects.toBeInstanceOf(
@@ -235,7 +237,7 @@ describe('UserController', () => {
}); });
describe('Selbstbedienungswege (Befund G)', () => { describe('Selbstbedienungswege (Befund G)', () => {
const currentUser = { role: Role.USER, tenantId: 't1', id: 'me' }; const currentUser = { role: Role.USER, tenantId: 't1', id: 'me', username: 'me', mustChangePassword: false };
beforeEach(() => { beforeEach(() => {
prisma.__seedUser({ prisma.__seedUser({
@@ -247,7 +249,10 @@ describe('UserController', () => {
}); });
it('Test 7: alle fünf Zugriffe der vier Selbstbedienungswege stehen gebunden im Protokoll, mit der Mandantenkennung aus dem Sitzungsnachweis', async () => { it('Test 7: alle fünf Zugriffe der vier Selbstbedienungswege stehen gebunden im Protokoll, mit der Mandantenkennung aus dem Sitzungsnachweis', async () => {
await controller.uploadAvatar({ buffer: Buffer.from('x'), mimetype: 'image/png' }, currentUser); await controller.uploadAvatar(
{ buffer: Buffer.from('x'), mimetype: 'image/png', originalname: 'x.png', size: 1 },
currentUser,
);
await controller.deleteAvatar(currentUser); await controller.deleteAvatar(currentUser);
await controller.updateAccentColor({ color: '#ff00aa' }, currentUser); await controller.updateAccentColor({ color: '#ff00aa' }, currentUser);
@@ -280,7 +285,7 @@ describe('UserController', () => {
describe('update/remove — Zielrolle SUPER_ADMIN (WINDOWS #29)', () => { describe('update/remove — Zielrolle SUPER_ADMIN (WINDOWS #29)', () => {
it('Test 9: ein Mandanten-Administrator kann den SUPER_ADMIN des eigenen Mandanten weder übernehmen (Kennwort setzen), noch aussperren (isActive=false), noch herabstufen (role=USER) — alle drei Angriffsformen werden mit der Zielrollen-Ausnahme abgelehnt, und der Dienst wird in keinem der drei Fälle aufgerufen', async () => { it('Test 9: ein Mandanten-Administrator kann den SUPER_ADMIN des eigenen Mandanten weder übernehmen (Kennwort setzen), noch aussperren (isActive=false), noch herabstufen (role=USER) — alle drei Angriffsformen werden mit der Zielrollen-Ausnahme abgelehnt, und der Dienst wird in keinem der drei Fälle aufgerufen', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' }; const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'boss', tenantId: 't1', role: Role.SUPER_ADMIN }); userService.findById.mockResolvedValue({ id: 'boss', tenantId: 't1', role: Role.SUPER_ADMIN });
await expect( await expect(
@@ -300,7 +305,7 @@ describe('UserController', () => {
}); });
it('Test 10: ein SUPER_ADMIN kann einen anderen SUPER_ADMIN weiterhin ändern — der Zielrollen-Riegel gilt nur für Nicht-SUPER_ADMIN-Aufrufer', async () => { it('Test 10: ein SUPER_ADMIN kann einen anderen SUPER_ADMIN weiterhin ändern — der Zielrollen-Riegel gilt nur für Nicht-SUPER_ADMIN-Aufrufer', async () => {
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1' }; const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1', username: 'super1', mustChangePassword: false };
userService.findByIdForPlatformAdmin.mockResolvedValue({ userService.findByIdForPlatformAdmin.mockResolvedValue({
id: 'boss', id: 'boss',
tenantId: 't1', tenantId: 't1',
@@ -324,7 +329,7 @@ describe('UserController', () => {
}); });
it('Test 11: ein Mandanten-Administrator kann einen USER seines Mandanten weiterhin ändern — Regressionsschutz, der Zielrollen-Riegel engt bestehende Wege nicht zusätzlich ein', async () => { it('Test 11: ein Mandanten-Administrator kann einen USER seines Mandanten weiterhin ändern — Regressionsschutz, der Zielrollen-Riegel engt bestehende Wege nicht zusätzlich ein', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' }; const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER }); userService.findById.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
userService.update.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER }); userService.update.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
@@ -338,7 +343,7 @@ describe('UserController', () => {
}); });
it('Test 12: die Mandantengrenze wird VOR der Zielrollen-Prüfung geprüft — ein Administrator, der (bei einer fehlerhaften Auflösung) ein Ziel eines fremden Mandanten mit der obersten Rolle erhält, bekommt die Mandanten-Meldung, nicht die Zielrollen-Meldung, und erfährt so nichts über die Rolle des fremden Benutzers', async () => { it('Test 12: die Mandantengrenze wird VOR der Zielrollen-Prüfung geprüft — ein Administrator, der (bei einer fehlerhaften Auflösung) ein Ziel eines fremden Mandanten mit der obersten Rolle erhält, bekommt die Mandanten-Meldung, nicht die Zielrollen-Meldung, und erfährt so nichts über die Rolle des fremden Benutzers', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' }; const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'boss2', tenantId: 't2', role: Role.SUPER_ADMIN }); userService.findById.mockResolvedValue({ id: 'boss2', tenantId: 't2', role: Role.SUPER_ADMIN });
await expect( await expect(
@@ -348,7 +353,7 @@ describe('UserController', () => {
}); });
it('Test 13: ein Mandanten-Administrator kann den SUPER_ADMIN des eigenen Mandanten nicht löschen — die Zielrollen-Ausnahme greift, und der Dienst wird nicht aufgerufen', async () => { it('Test 13: ein Mandanten-Administrator kann den SUPER_ADMIN des eigenen Mandanten nicht löschen — die Zielrollen-Ausnahme greift, und der Dienst wird nicht aufgerufen', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' }; const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'boss', tenantId: 't1', role: Role.SUPER_ADMIN }); userService.findById.mockResolvedValue({ id: 'boss', tenantId: 't1', role: Role.SUPER_ADMIN });
await expect(controller.remove('boss', admin)).rejects.toThrow( await expect(controller.remove('boss', admin)).rejects.toThrow(
@@ -358,7 +363,7 @@ describe('UserController', () => {
}); });
it('Test 14: ein SUPER_ADMIN kann einen anderen SUPER_ADMIN weiterhin löschen — der Zielrollen-Riegel gilt nur für Nicht-SUPER_ADMIN-Aufrufer', async () => { it('Test 14: ein SUPER_ADMIN kann einen anderen SUPER_ADMIN weiterhin löschen — der Zielrollen-Riegel gilt nur für Nicht-SUPER_ADMIN-Aufrufer', async () => {
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1' }; const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1', username: 'super1', mustChangePassword: false };
userService.findByIdForPlatformAdmin.mockResolvedValue({ userService.findByIdForPlatformAdmin.mockResolvedValue({
id: 'boss', id: 'boss',
tenantId: 't1', tenantId: 't1',
@@ -373,7 +378,7 @@ describe('UserController', () => {
}); });
it('Test 15: ein Mandanten-Administrator kann einen USER seines Mandanten weiterhin löschen — Regressionsschutz', async () => { it('Test 15: ein Mandanten-Administrator kann einen USER seines Mandanten weiterhin löschen — Regressionsschutz', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' }; const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER }); userService.findById.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
userService.delete.mockResolvedValue({ message: 'User deleted' }); userService.delete.mockResolvedValue({ message: 'User deleted' });
@@ -383,7 +388,7 @@ describe('UserController', () => {
}); });
it('Test 16: die Mandantengrenze wird VOR der Zielrollen-Prüfung geprüft — beim Löschen bekommt ein Administrator mit einem fremdmandantigen Ziel der obersten Rolle die Mandanten-Meldung, nicht die Zielrollen-Meldung', async () => { it('Test 16: die Mandantengrenze wird VOR der Zielrollen-Prüfung geprüft — beim Löschen bekommt ein Administrator mit einem fremdmandantigen Ziel der obersten Rolle die Mandanten-Meldung, nicht die Zielrollen-Meldung', async () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' }; const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
userService.findById.mockResolvedValue({ id: 'boss2', tenantId: 't2', role: Role.SUPER_ADMIN }); userService.findById.mockResolvedValue({ id: 'boss2', tenantId: 't2', role: Role.SUPER_ADMIN });
await expect(controller.remove('boss2', admin)).rejects.toThrow( await expect(controller.remove('boss2', admin)).rejects.toThrow(
+14 -13
View File
@@ -21,6 +21,7 @@ import * as path from 'node:path';
import { Response } from 'express'; import { Response } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -65,7 +66,7 @@ export class UserController {
* uebergreifende Sicht von der mandantengebundenen unterscheidet, und sie * uebergreifende Sicht von der mandantengebundenen unterscheidet, und sie
* darf nicht eingeebnet werden. * darf nicht eingeebnet werden.
*/ */
private async resolveTargetUser(currentUser: any, id: string) { private async resolveTargetUser(currentUser: AuthUser, id: string) {
if (currentUser.role === Role.SUPER_ADMIN) { if (currentUser.role === Role.SUPER_ADMIN) {
return this.userService.findByIdForPlatformAdmin(id); return this.userService.findByIdForPlatformAdmin(id);
} }
@@ -79,7 +80,7 @@ export class UserController {
*/ */
@Get() @Get()
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async findAll(@CurrentUser() currentUser: any) { async findAll(@CurrentUser() currentUser: AuthUser) {
if (currentUser.role === Role.SUPER_ADMIN) { if (currentUser.role === Role.SUPER_ADMIN) {
// Plattform-Administratorsicht (Befund F): die bestehende, gewollte // Plattform-Administratorsicht (Befund F): die bestehende, gewollte
// Funktion der obersten Rolle bleibt erhalten, laeuft aber ueber die // Funktion der obersten Rolle bleibt erhalten, laeuft aber ueber die
@@ -115,7 +116,7 @@ export class UserController {
*/ */
@Get(':id') @Get(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async findOne(@Param('id') id: string, @CurrentUser() currentUser: any) { async findOne(@Param('id') id: string, @CurrentUser() currentUser: AuthUser) {
const user = await this.resolveTargetUser(currentUser, id); const user = await this.resolveTargetUser(currentUser, id);
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new NotFoundException('User not found');
@@ -139,7 +140,7 @@ export class UserController {
*/ */
@Post() @Post()
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Body() dto: CreateUserDto, @CurrentUser() currentUser: any) { async create(@Body() dto: CreateUserDto, @CurrentUser() currentUser: AuthUser) {
// ADMIN can only create users in own tenant // ADMIN can only create users in own tenant
const tenantId = const tenantId =
currentUser.role === Role.SUPER_ADMIN && dto.tenantId currentUser.role === Role.SUPER_ADMIN && dto.tenantId
@@ -173,7 +174,7 @@ export class UserController {
async update( async update(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateUserDto, @Body() dto: UpdateUserDto,
@CurrentUser() currentUser: any, @CurrentUser() currentUser: AuthUser,
) { ) {
const user = await this.resolveTargetUser(currentUser, id); const user = await this.resolveTargetUser(currentUser, id);
if (!user) { if (!user) {
@@ -228,7 +229,7 @@ export class UserController {
*/ */
@Delete(':id') @Delete(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Param('id') id: string, @CurrentUser() currentUser: any) { async remove(@Param('id') id: string, @CurrentUser() currentUser: AuthUser) {
const user = await this.resolveTargetUser(currentUser, id); const user = await this.resolveTargetUser(currentUser, id);
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new NotFoundException('User not found');
@@ -290,14 +291,14 @@ export class UserController {
FileInterceptor('file', { limits: { fileSize: 2 * 1024 * 1024 } }), FileInterceptor('file', { limits: { fileSize: 2 * 1024 * 1024 } }),
) )
async uploadAvatar( async uploadAvatar(
@UploadedFile() file: any, @UploadedFile() file: UploadedFileLike | undefined,
@CurrentUser() currentUser: any, @CurrentUser() currentUser: AuthUser,
) { ) {
if (!file?.buffer) { if (!file?.buffer) {
throw new BadRequestException('No file provided'); throw new BadRequestException('No file provided');
} }
const ext = AVATAR_MIME_TO_EXT[file.mimetype as string]; const ext = AVATAR_MIME_TO_EXT[file.mimetype];
if (!ext) { if (!ext) {
throw new BadRequestException( throw new BadRequestException(
'Invalid file type. Allowed: image/png, image/jpeg, image/webp', 'Invalid file type. Allowed: image/png, image/jpeg, image/webp',
@@ -318,7 +319,7 @@ export class UserController {
} }
} }
fs.writeFileSync(filePath, file.buffer as Buffer); fs.writeFileSync(filePath, file.buffer);
// Persist relative path (relative to monorepo root) // Persist relative path (relative to monorepo root)
const relativePath = path.join('user-files', 'avatars', filename); const relativePath = path.join('user-files', 'avatars', filename);
@@ -336,7 +337,7 @@ export class UserController {
* Remove the current user's profile picture. * Remove the current user's profile picture.
*/ */
@Delete('me/avatar') @Delete('me/avatar')
async deleteAvatar(@CurrentUser() currentUser: any) { async deleteAvatar(@CurrentUser() currentUser: AuthUser) {
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId); const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
const user = await tenantPrisma.user.findUnique({ const user = await tenantPrisma.user.findUnique({
where: { id: currentUser.id }, where: { id: currentUser.id },
@@ -366,7 +367,7 @@ export class UserController {
@Patch('me/accent-color') @Patch('me/accent-color')
async updateAccentColor( async updateAccentColor(
@Body() body: { color: string | null }, @Body() body: { color: string | null },
@CurrentUser() currentUser: any, @CurrentUser() currentUser: AuthUser,
) { ) {
if (body.color !== null && body.color !== undefined && !/^#[0-9a-fA-F]{6}$/.test(body.color)) { if (body.color !== null && body.color !== undefined && !/^#[0-9a-fA-F]{6}$/.test(body.color)) {
throw new BadRequestException('Invalid color format. Use hex (#rrggbb).'); throw new BadRequestException('Invalid color format. Use hex (#rrggbb).');
@@ -388,7 +389,7 @@ export class UserController {
*/ */
@Get('me/avatar') @Get('me/avatar')
async getAvatar( async getAvatar(
@CurrentUser() currentUser: any, @CurrentUser() currentUser: AuthUser,
@Res() res: Response, @Res() res: Response,
) { ) {
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId); const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);