diff --git a/apps/api/openapi.json b/apps/api/openapi.json index c4d3e79..700d7a9 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -126,6 +126,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["employees"] }, "post": { @@ -172,6 +177,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["employees"] }, "put": { @@ -1794,6 +1804,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] }, "post": { @@ -1814,6 +1829,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -1835,6 +1855,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -1856,6 +1881,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -1878,6 +1908,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -1909,6 +1944,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -1940,6 +1980,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -1971,6 +2016,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -2002,6 +2052,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -2033,6 +2088,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -2064,6 +2124,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -2095,6 +2160,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -2126,6 +2196,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -2157,6 +2232,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -2188,6 +2268,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -2210,6 +2295,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, @@ -2232,6 +2322,11 @@ "description": "" } }, + "security": [ + { + "bearer": [] + } + ], "tags": ["requests"] } }, diff --git a/apps/api/src/app/employees/employees.controller.ts b/apps/api/src/app/employees/employees.controller.ts index ba9b98a..06eed61 100644 --- a/apps/api/src/app/employees/employees.controller.ts +++ b/apps/api/src/app/employees/employees.controller.ts @@ -25,12 +25,16 @@ import { } from './employees.dto'; @ApiTags('employees') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) @Controller('employees') export class EmployeesController { constructor(private readonly employees: EmployeesService) {} @Get() - list(@Query('includeInactive') includeInactive?: string): Promise { + list( + @Query('includeInactive') includeInactive?: string, + ): Promise { return this.employees.list({ includeInactive: includeInactive === 'true' }); } @@ -40,16 +44,14 @@ export class EmployeesController { } @Post() - @ApiBearerAuth() - @UseGuards(JwtAuthGuard, RolesGuard) + @UseGuards(RolesGuard) @Roles('HRAdmin') create(@Body() dto: CreateEmployeeDto): Promise { return this.employees.create(dto); } @Put(':id') - @ApiBearerAuth() - @UseGuards(JwtAuthGuard, RolesGuard) + @UseGuards(RolesGuard) @Roles('HRAdmin') update( @Param('id', new ParseUUIDPipe()) id: string, @@ -59,8 +61,7 @@ export class EmployeesController { } @Post(':id/password') - @ApiBearerAuth() - @UseGuards(JwtAuthGuard, RolesGuard) + @UseGuards(RolesGuard) @Roles('HRAdmin') @HttpCode(HttpStatus.NO_CONTENT) setPassword( @@ -71,18 +72,20 @@ export class EmployeesController { } @Delete(':id') - @ApiBearerAuth() - @UseGuards(JwtAuthGuard, RolesGuard) + @UseGuards(RolesGuard) @Roles('HRAdmin') - deactivate(@Param('id', new ParseUUIDPipe()) id: string): Promise { + deactivate( + @Param('id', new ParseUUIDPipe()) id: string, + ): Promise { return this.employees.deactivate(id); } @Post(':id/reactivate') - @ApiBearerAuth() - @UseGuards(JwtAuthGuard, RolesGuard) + @UseGuards(RolesGuard) @Roles('HRAdmin') - reactivate(@Param('id', new ParseUUIDPipe()) id: string): Promise { + reactivate( + @Param('id', new ParseUUIDPipe()) id: string, + ): Promise { return this.employees.reactivate(id); } } diff --git a/apps/api/src/app/requests/requests.controller.ts b/apps/api/src/app/requests/requests.controller.ts index 108e1f5..ab41a63 100644 --- a/apps/api/src/app/requests/requests.controller.ts +++ b/apps/api/src/app/requests/requests.controller.ts @@ -1,5 +1,17 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { JwtUser } from '../auth/jwt.strategy'; import { RequestsService } from './requests.service'; import { BulkApproveDto, @@ -15,12 +27,15 @@ import { } from './requests.dto'; @ApiTags('requests') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) @Controller('requests') export class RequestsController { constructor(private readonly service: RequestsService) {} @Get() list( + @CurrentUser() user: JwtUser, @Query('employeeId') employeeId?: string, @Query('status') status?: string, @Query('workflowState') workflowState?: string, @@ -28,39 +43,69 @@ export class RequestsController { @Query('currentApproverId') currentApproverId?: string, @Query('substituteId') substituteId?: string, ): Promise { - return this.service.list({ employeeId, status, workflowState, approverId, currentApproverId, substituteId }); + return this.service.list( + { + employeeId, + status, + workflowState, + approverId, + currentApproverId, + substituteId, + }, + user.id, + ); } @Get(':id') - get(@Param('id', new ParseUUIDPipe()) id: string): Promise { - return this.service.getById(id); + get( + @Param('id', new ParseUUIDPipe()) id: string, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.getById(id, user.id); } @Get(':id/events') - events(@Param('id', new ParseUUIDPipe()) id: string): Promise { - return this.service.events(id); + events( + @Param('id', new ParseUUIDPipe()) id: string, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.events(id, user.id); } @Post() - create(@Body() dto: CreateRequestDto): Promise { - return this.service.create(dto); + create( + @Body() dto: CreateRequestDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.create({ ...dto, employeeId: user.id }); } @Post('vacation') - createVacation(@Body() dto: CreateVacationDto): Promise { - return this.service.createVacation(dto); + createVacation( + @Body() dto: CreateVacationDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.createVacation({ ...dto, employeeId: user.id }); } // ----- Generic approve / reject (legacy convenience) ----- @Post(':id/approve') - approve(@Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto): Promise { - return this.service.approve(id, body.actorId, body.note ?? null); + approve( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() body: TransitionDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.approve(id, user.id, body.note ?? null); } @Post(':id/reject') - reject(@Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto): Promise { - return this.service.reject(id, body.actorId, body.note ?? null); + reject( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() body: TransitionDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.reject(id, user.id, body.note ?? null); } // ----- Vacation workflow ----- @@ -69,66 +114,88 @@ export class RequestsController { managerApprove( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: ManagerApproveDto, + @CurrentUser() user: JwtUser, ): Promise { - return this.service.managerApprove(id, body.actorId, body.note ?? null, !!body.requiresHrConfirmation); + return this.service.managerApprove( + id, + user.id, + body.note ?? null, + !!body.requiresHrConfirmation, + ); } @Post(':id/manager-reject') managerReject( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto, + @CurrentUser() user: JwtUser, ): Promise { - return this.service.managerReject(id, body.actorId, body.note ?? null); + return this.service.managerReject(id, user.id, body.note ?? null); } @Post(':id/hr-confirm') - hrConfirm(@Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto): Promise { - return this.service.hrConfirm(id, body.actorId, body.note ?? null); + hrConfirm( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() body: TransitionDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.hrConfirm(id, user.id, body.note ?? null); } @Post(':id/hr-reject') hrReject( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionWithRequiredNoteDto, + @CurrentUser() user: JwtUser, ): Promise { - return this.service.hrReject(id, body.actorId, body.note); + return this.service.hrReject(id, user.id, body.note); } @Post(':id/substitute/accept') substituteAccept( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto, + @CurrentUser() user: JwtUser, ): Promise { - return this.service.substituteAccept(id, body.actorId, body.note ?? null); + return this.service.substituteAccept(id, user.id, body.note ?? null); } @Post(':id/substitute/decline') substituteDecline( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionWithRequiredNoteDto, + @CurrentUser() user: JwtUser, ): Promise { - return this.service.substituteDecline(id, body.actorId, body.note); + return this.service.substituteDecline(id, user.id, body.note); } @Post(':id/return') returnForRevision( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionWithRequiredNoteDto, + @CurrentUser() user: JwtUser, ): Promise { - return this.service.returnForRevision(id, body.actorId, body.note); + return this.service.returnForRevision(id, user.id, body.note); } @Post(':id/cancel') - cancel(@Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto): Promise { - return this.service.cancel(id, body.actorId, body.note ?? null); + cancel( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() body: TransitionDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.cancel(id, user.id, body.note ?? null); } // ----- Bulk ----- @Post('bulk-approve') - bulkApprove(@Body() body: BulkApproveDto): Promise { + bulkApprove( + @Body() body: BulkApproveDto, + @CurrentUser() user: JwtUser, + ): Promise { return this.service.bulkApprove( - body.actorId, + user.id, body.ids, body.note ?? null, !!body.requiresHrConfirmation, @@ -136,7 +203,10 @@ export class RequestsController { } @Post('bulk-reject') - bulkReject(@Body() body: BulkRejectDto): Promise { - return this.service.bulkReject(body.actorId, body.ids, body.note); + bulkReject( + @Body() body: BulkRejectDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.bulkReject(user.id, body.ids, body.note); } } diff --git a/apps/api/src/app/requests/requests.service.ts b/apps/api/src/app/requests/requests.service.ts index fd23ceb..5036c75 100644 --- a/apps/api/src/app/requests/requests.service.ts +++ b/apps/api/src/app/requests/requests.service.ts @@ -53,7 +53,10 @@ export class RequestsService { private readonly schedules: WorkSchedulesService, ) {} - async list(filter: ListRequestsFilter): Promise { + async list( + filter: ListRequestsFilter, + actorId: string, + ): Promise { const where: Prisma.RequestWhereInput = {}; if (filter.employeeId) where.employeeId = filter.employeeId; if (filter.status) @@ -66,19 +69,22 @@ export class RequestsService { where.currentApproverId = filter.currentApproverId; if (filter.substituteId) where.substituteId = filter.substituteId; const rows = await this.prisma.request.findMany({ - where, + where: { AND: [where, await this.visibilityWhere(actorId)] }, orderBy: { createdAt: 'desc' }, take: 200, }); return rows.map(toRequestDto); } - async getById(id: string): Promise { - return toRequestDto(await this.assertRequest(id)); + async getById(id: string, actorId: string): Promise { + const request = await this.assertRequest(id); + await this.assertCanView(request, actorId); + return toRequestDto(request); } - async events(id: string): Promise { - await this.assertRequest(id); + async events(id: string, actorId: string): Promise { + const request = await this.assertRequest(id); + await this.assertCanView(request, actorId); const rows = await this.prisma.requestEvent.findMany({ where: { requestId: id }, orderBy: { occurredAt: 'asc' }, @@ -247,7 +253,7 @@ export class RequestsService { if (requiresTwoStageApproval(request)) { // Off-hours TimeAdjustment: manager approves the off-hours allowance, // then HR finalises the actual time correction. - await this.assertApproverRole(actorId); + await this.assertApprover(request, actorId); return this.transitionVacation( request, 'manager_approve_with_hr', @@ -255,7 +261,7 @@ export class RequestsService { note, ); } - await this.assertApproverRole(actorId); + await this.assertApprover(request, actorId); const alreadyApproved = request.workflowState === 'Approved'; const updated = await this.prisma.$transaction(async (tx) => { const updated = await tx.request.update({ @@ -290,7 +296,7 @@ export class RequestsService { if (request.type === 'Vacation') { return this.transitionVacation(request, 'manager_reject', actorId, note); } - await this.assertApproverRole(actorId); + await this.assertApprover(request, actorId); const updated = await this.prisma.$transaction(async (tx) => { const updated = await tx.request.update({ where: { id: request.id }, @@ -321,7 +327,7 @@ export class RequestsService { requiresHrConfirmation: boolean, ): Promise { const request = await this.assertRequest(id); - await this.assertApproverRole(actorId); + await this.assertApprover(request, actorId); // Off-hours TimeAdjustments always need HR confirmation, regardless of the // flag set by the manager — the spec calls for a "Sondergenehmigung" first, // then the actual time correction. @@ -339,7 +345,7 @@ export class RequestsService { note: string | null, ): Promise { const request = await this.assertRequest(id); - await this.assertApproverRole(actorId); + await this.assertApprover(request, actorId); return this.transitionVacation(request, 'manager_reject', actorId, note); } @@ -398,7 +404,7 @@ export class RequestsService { note: string, ): Promise { const request = await this.assertRequest(id); - await this.assertApproverRole(actorId); + await this.assertApprover(request, actorId); return this.transitionVacation(request, 'manager_return', actorId, note); } @@ -424,7 +430,7 @@ export class RequestsService { try { const request = await this.assertRequest(id); if (request.workflowState === 'PendingManager') { - await this.assertApproverRole(actorId); + await this.assertApprover(request, actorId); const forced = requiresTwoStageApproval(request); const event: WorkflowEvent = requiresHrConfirmation || forced @@ -489,7 +495,7 @@ export class RequestsService { try { const request = await this.assertRequest(id); if (request.workflowState === 'PendingManager') { - await this.assertApproverRole(actorId); + await this.assertApprover(request, actorId); const updated = await this.transitionVacation( request, 'manager_reject', @@ -543,6 +549,11 @@ export class RequestsService { if (request.employeeId !== actorId) { // HR/Manager may also cancel — check role. const actor = await this.employees.getById(actorId); + if (actor.role === 'Manager' && request.currentApproverId !== actorId) { + throw new ForbiddenException( + 'Managers may only cancel assigned requests', + ); + } if (actor.role !== 'Manager' && actor.role !== 'HRAdmin') { throw new ForbiddenException( 'Only the requester or a Manager/HRAdmin may cancel', @@ -661,13 +672,52 @@ export class RequestsService { return request; } - private async assertApproverRole(actorId: string): Promise { + private async assertApprover( + request: Request, + actorId: string, + ): Promise { const actor = await this.employees.getById(actorId); if (actor.role !== 'Manager' && actor.role !== 'HRAdmin') { throw new ForbiddenException( 'Only Manager or HRAdmin may approve/reject', ); } + if (actor.role === 'Manager' && request.currentApproverId !== actorId) { + throw new ForbiddenException( + 'Managers may only transition assigned requests', + ); + } + } + + private async visibilityWhere( + actorId: string, + ): Promise { + const actor = await this.employees.getById(actorId); + if (actor.role === 'HRAdmin') return {}; + return { + OR: [ + { employeeId: actorId }, + { approverId: actorId }, + { currentApproverId: actorId }, + { substituteId: actorId }, + ], + }; + } + + private async assertCanView( + request: Request, + actorId: string, + ): Promise { + const actor = await this.employees.getById(actorId); + if ( + actor.role !== 'HRAdmin' && + request.employeeId !== actorId && + request.approverId !== actorId && + request.currentApproverId !== actorId && + request.substituteId !== actorId + ) { + throw new ForbiddenException('Not permitted to view this request'); + } } private async assertHrAdminRole(actorId: string): Promise {