import { BadRequestException, Body, Controller, Delete, ForbiddenException, Get, NotFoundException, Param, Patch, Post, Res, UploadedFile, UseGuards, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { Role } from '@prisma/client'; import * as fs from 'fs'; import * as path from 'path'; import { Response } from 'express'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { RolesGuard } from '../auth/guards/roles.guard'; import { PrismaService } from '../prisma/prisma.service'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UserService } from './user.service'; /** Map accepted MIME types to file extensions (T-gbh-01). */ const AVATAR_MIME_TO_EXT: Record = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', }; /** Resolve the avatars storage directory relative to the monorepo root. * At runtime __dirname = apps/api/dist/user/ → go up 4 levels. */ function resolveAvatarsDir(): string { return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'avatars'); } @Controller('users') @UseGuards(RolesGuard) export class UserController { constructor( private readonly userService: UserService, private readonly prisma: PrismaService, ) {} /** * GET /users * ADMIN sees own-tenant users only. SUPER_ADMIN sees all users. * T-02-10: ADMIN filtered by tenant at controller level. */ @Get() @Roles(Role.ADMIN, Role.SUPER_ADMIN) async findAll(@CurrentUser() currentUser: any) { if (currentUser.role === Role.SUPER_ADMIN) { return this.prisma.user.findMany({ select: { id: true, username: true, email: true, displayName: true, role: true, isActive: true, tenantId: true, createdAt: true, lastLoginAt: true, }, orderBy: { username: 'asc' }, }); } // ADMIN: filter by own tenant return this.prisma.user.findMany({ where: { tenantId: currentUser.tenantId }, select: { id: true, username: true, email: true, displayName: true, role: true, isActive: true, tenantId: true, createdAt: true, lastLoginAt: true, }, orderBy: { username: 'asc' }, }); } /** * GET /users/:id */ @Get(':id') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async findOne(@Param('id') id: string, @CurrentUser() currentUser: any) { // 260910-das, Aufgabe 2: UserService.findById() bekommt einen // Pflicht-Mandanten (siehe user.service.ts). Nur die Signatur wird hier // nachgezogen, damit die Typpruefung sauber bleibt -- die Rollenlogik // (insbesondere die uebergreifende SUPER_ADMIN-Sicht ueber // findByIdForPlatformAdmin()) wird erst in Aufgabe 3 vollstaendig // verdrahtet. const user = await this.userService.findById(currentUser.tenantId, id); if (!user) { throw new NotFoundException('User not found'); } // ADMIN can only view users in own tenant if ( currentUser.role !== Role.SUPER_ADMIN && user.tenantId !== currentUser.tenantId ) { throw new ForbiddenException('Cannot access users from other tenants'); } const { passwordHash, ...result } = user; return result; } /** * POST /users * T-02-08: ADMIN can only create users in own tenant; cannot set SUPER_ADMIN role. */ @Post() @Roles(Role.ADMIN, Role.SUPER_ADMIN) async create(@Body() dto: CreateUserDto, @CurrentUser() currentUser: any) { // ADMIN can only create users in own tenant const tenantId = currentUser.role === Role.SUPER_ADMIN && dto.tenantId ? dto.tenantId : currentUser.tenantId; // T-02-08: ADMIN cannot create SUPER_ADMIN users if (currentUser.role !== Role.SUPER_ADMIN && dto.role === Role.SUPER_ADMIN) { throw new ForbiddenException('Cannot assign SUPER_ADMIN role'); } const user = await this.userService.create({ username: dto.username, email: dto.email, password: dto.password, displayName: dto.displayName, role: dto.role ?? Role.USER, tenantId, }); const { passwordHash, ...result } = user; return result; } /** * PATCH /users/:id * T-02-08: ADMIN cannot escalate to SUPER_ADMIN or modify other tenants' users. */ @Patch(':id') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async update( @Param('id') id: string, @Body() dto: UpdateUserDto, @CurrentUser() currentUser: any, ) { // 260910-das, Aufgabe 2: siehe Kommentar in findOne() oben. const user = await this.userService.findById(currentUser.tenantId, id); if (!user) { throw new NotFoundException('User not found'); } // ADMIN can only update users in own tenant if ( currentUser.role !== Role.SUPER_ADMIN && user.tenantId !== currentUser.tenantId ) { throw new ForbiddenException('Cannot modify users from other tenants'); } // T-02-08: ADMIN cannot set role to SUPER_ADMIN if (currentUser.role !== Role.SUPER_ADMIN && dto.role === Role.SUPER_ADMIN) { throw new ForbiddenException('Cannot assign SUPER_ADMIN role'); } const updated = await this.userService.update(currentUser.tenantId, id, { username: dto.username, email: dto.email, password: dto.password, displayName: dto.displayName, role: dto.role, isActive: dto.isActive, }); const { passwordHash, ...result } = updated; return result; } /** * DELETE /users/:id * ADMIN cannot delete self or users from other tenants. */ @Delete(':id') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async remove(@Param('id') id: string, @CurrentUser() currentUser: any) { // 260910-das, Aufgabe 2: siehe Kommentar in findOne() oben. const user = await this.userService.findById(currentUser.tenantId, id); if (!user) { throw new NotFoundException('User not found'); } // Cannot delete self if (user.id === currentUser.sub) { throw new ForbiddenException('Cannot delete your own account'); } // ADMIN can only delete users in own tenant if ( currentUser.role !== Role.SUPER_ADMIN && user.tenantId !== currentUser.tenantId ) { throw new ForbiddenException('Cannot delete users from other tenants'); } await this.userService.delete(currentUser.tenantId, id); return { message: 'User deleted' }; } // ─── Self-service avatar endpoints (all authenticated roles) ─────────────── // No @Roles() → RolesGuard.canActivate() returns true when requiredRoles is // empty (see guards/roles.guard.ts). Global JwtAuthGuard still enforces auth. /** * POST /users/me/avatar * Upload or replace the current user's profile picture. * T-gbh-01: 2 MB size limit + image-only MIME allowlist. * T-gbh-02: userId derived from @CurrentUser() only — never from request body. * T-gbh-04: filename = {userId}.{ext} derived from MIME — no path traversal. */ @Post('me/avatar') @UseInterceptors( FileInterceptor('file', { limits: { fileSize: 2 * 1024 * 1024 } }), ) async uploadAvatar( @UploadedFile() file: any, @CurrentUser() currentUser: any, ) { if (!file || !file.buffer) { throw new BadRequestException('No file provided'); } const ext = AVATAR_MIME_TO_EXT[file.mimetype as string]; if (!ext) { throw new BadRequestException( 'Invalid file type. Allowed: image/png, image/jpeg, image/webp', ); } const avatarsDir = resolveAvatarsDir(); fs.mkdirSync(avatarsDir, { recursive: true }); const filename = `${currentUser.id}.${ext}`; const filePath = path.join(avatarsDir, filename); // Remove any previous avatar files for this user (different extension) for (const existingExt of Object.values(AVATAR_MIME_TO_EXT)) { const candidate = path.join(avatarsDir, `${currentUser.id}.${existingExt}`); if (candidate !== filePath && fs.existsSync(candidate)) { fs.unlinkSync(candidate); } } fs.writeFileSync(filePath, file.buffer as Buffer); // Persist relative path (relative to monorepo root) const relativePath = path.join('user-files', 'avatars', filename); await this.prisma.user.update({ where: { id: currentUser.id }, data: { avatarPath: relativePath }, }); return { success: true }; } /** * DELETE /users/me/avatar * Remove the current user's profile picture. */ @Delete('me/avatar') async deleteAvatar(@CurrentUser() currentUser: any) { const user = await this.prisma.user.findUnique({ where: { id: currentUser.id }, select: { avatarPath: true }, }); if (user?.avatarPath) { const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..'); const absolutePath = path.join(monorepoRoot, user.avatarPath); if (fs.existsSync(absolutePath)) { fs.unlinkSync(absolutePath); } await this.prisma.user.update({ where: { id: currentUser.id }, data: { avatarPath: null }, }); } return { success: true }; } /** * PATCH /users/me/accent-color * Set or clear the current user's accent color. * T-acc-01: hex color validated server-side. */ @Patch('me/accent-color') async updateAccentColor( @Body() body: { color: string | null }, @CurrentUser() currentUser: any, ) { if (body.color !== null && body.color !== undefined && !/^#[0-9a-fA-F]{6}$/.test(body.color)) { throw new BadRequestException('Invalid color format. Use hex (#rrggbb).'); } await this.prisma.user.update({ where: { id: currentUser.id }, data: { accentColor: body.color ?? null }, }); return { success: true }; } /** * GET /users/me/avatar * Stream the current user's avatar image. * T-gbh-05: Only the authenticated user's own file is served here. */ @Get('me/avatar') async getAvatar( @CurrentUser() currentUser: any, @Res() res: Response, ) { const user = await this.prisma.user.findUnique({ where: { id: currentUser.id }, select: { avatarPath: true }, }); if (!user?.avatarPath) { throw new NotFoundException('No avatar set'); } // Resolve from monorepo root (same upward-walk pattern as DkvService) const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..'); const absolutePath = path.join(monorepoRoot, user.avatarPath); if (!fs.existsSync(absolutePath)) { throw new NotFoundException('Avatar file not found'); } const ext = path.extname(absolutePath).slice(1).toLowerCase(); const mimeTypes: Record = { png: 'image/png', jpg: 'image/jpeg', webp: 'image/webp', }; const contentType = mimeTypes[ext] ?? 'application/octet-stream'; const buffer = fs.readFileSync(absolutePath); res.setHeader('Content-Type', contentType); res.setHeader('Cache-Control', 'no-store'); res.send(buffer); } }