From e8a18444fde7b93139002e3f5d9ca1d1ab15f498 Mon Sep 17 00:00:00 2001
From: Patrick Schiller
Date: Sat, 10 Oct 2026 09:47:02 +0200
Subject: [PATCH 1/2] fix(api): authorize employee data access
Signed-off-by: Codex
---
apps/api/openapi.json | 20 +++++++++
.../src/app/accounts/accounts.controller.ts | 35 +++++++++++++---
apps/api/src/app/accounts/accounts.service.ts | 42 +++++++++++++------
.../src/app/employees/employees.controller.ts | 32 +++++++++-----
.../src/app/employees/employees.service.ts | 38 +++++++++++++++++
5 files changed, 138 insertions(+), 29 deletions(-)
diff --git a/apps/api/openapi.json b/apps/api/openapi.json
index c4d3e79..099a8df 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": {
@@ -1704,6 +1714,11 @@
"description": ""
}
},
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
"tags": ["accounts"]
}
},
@@ -1733,6 +1748,11 @@
"description": ""
}
},
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
"tags": ["accounts"]
}
},
diff --git a/apps/api/src/app/accounts/accounts.controller.ts b/apps/api/src/app/accounts/accounts.controller.ts
index b60c9d3..1239d7c 100644
--- a/apps/api/src/app/accounts/accounts.controller.ts
+++ b/apps/api/src/app/accounts/accounts.controller.ts
@@ -1,27 +1,50 @@
-import { Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Query } from '@nestjs/common';
-import { ApiTags } from '@nestjs/swagger';
+import {
+ Controller,
+ Get,
+ Param,
+ ParseIntPipe,
+ ParseUUIDPipe,
+ 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 { EmployeesService } from '../employees/employees.service';
import { AccountsService } from './accounts.service';
import { VacationBalanceService } from './vacation-balance.service';
import type { AccountDto, VacationBalanceDto } from './accounts.dto';
@ApiTags('accounts')
+@ApiBearerAuth()
+@UseGuards(JwtAuthGuard)
@Controller('accounts')
export class AccountsController {
constructor(
private readonly accounts: AccountsService,
private readonly vacation: VacationBalanceService,
+ private readonly employees: EmployeesService,
) {}
@Get(':employeeId')
- account(@Param('employeeId', new ParseUUIDPipe()) employeeId: string): Promise {
- return this.accounts.account(employeeId);
+ account(
+ @Param('employeeId', new ParseUUIDPipe()) employeeId: string,
+ @CurrentUser() user: JwtUser,
+ ): Promise {
+ return this.accounts.account(employeeId, user);
}
@Get(':employeeId/vacation')
- vacationBalance(
+ async vacationBalance(
@Param('employeeId', new ParseUUIDPipe()) employeeId: string,
+ @CurrentUser() user: JwtUser,
@Query('year', new ParseIntPipe({ optional: true })) year?: number,
): Promise {
- return this.vacation.compute(employeeId, year ?? new Date().getUTCFullYear());
+ await this.employees.assertCanRead(employeeId, user);
+ return this.vacation.compute(
+ employeeId,
+ year ?? new Date().getUTCFullYear(),
+ );
}
}
diff --git a/apps/api/src/app/accounts/accounts.service.ts b/apps/api/src/app/accounts/accounts.service.ts
index d999325..bccb273 100644
--- a/apps/api/src/app/accounts/accounts.service.ts
+++ b/apps/api/src/app/accounts/accounts.service.ts
@@ -9,6 +9,7 @@ import type { HolidayProvider } from 'shared';
import { EmployeesService } from '../employees/employees.service';
import { PrismaService } from '../prisma/prisma.service';
import { WorkSchedulesService } from '../work-schedules/work-schedules.service';
+import type { JwtUser } from '../auth/jwt.strategy';
import { VacationBalanceService } from './vacation-balance.service';
import type { AccountDto } from './accounts.dto';
@@ -21,7 +22,8 @@ export class AccountsService {
private readonly schedules: WorkSchedulesService,
) {}
- async account(employeeId: string): Promise {
+ async account(employeeId: string, actor: JwtUser): Promise {
+ await this.employees.assertCanRead(employeeId, actor);
const employee = await this.employees.getById(employeeId);
const schedule = await this.schedules.resolveForEmployee(employeeId);
const now = new Date();
@@ -30,13 +32,19 @@ export class AccountsService {
// Sum net minutes from completed time entries YTD.
const entries = await this.prisma.timeEntry.findMany({
- where: { employeeId, clockIn: { gte: yearStart }, clockOut: { not: null } },
+ where: {
+ employeeId,
+ clockIn: { gte: yearStart },
+ clockOut: { not: null },
+ },
select: { clockIn: true, clockOut: true },
});
let netMinutesYtd = 0;
for (const e of entries) {
if (!e.clockOut) continue;
- const gross = Math.floor((e.clockOut.getTime() - e.clockIn.getTime()) / 60_000);
+ const gross = Math.floor(
+ (e.clockOut.getTime() - e.clockIn.getTime()) / 60_000,
+ );
netMinutesYtd += calculateNetMinutes(gross);
}
@@ -44,13 +52,16 @@ export class AccountsService {
// excused working days — their absence on a workday is not a deficit.
// Flextime/Gleittage are intentionally NOT excused: that's what makes
// them drain the overtime account.
- const sollFrom = employee.startDate.getTime() > yearStart.getTime()
- ? new Date(Date.UTC(
- employee.startDate.getUTCFullYear(),
- employee.startDate.getUTCMonth(),
- employee.startDate.getUTCDate(),
- ))
- : yearStart;
+ const sollFrom =
+ employee.startDate.getTime() > yearStart.getTime()
+ ? new Date(
+ Date.UTC(
+ employee.startDate.getUTCFullYear(),
+ employee.startDate.getUTCMonth(),
+ employee.startDate.getUTCDate(),
+ ),
+ )
+ : yearStart;
const excusedDays = await this.excusedWorkingDays(
employeeId,
sollFrom,
@@ -123,15 +134,20 @@ export class AccountsService {
for (const a of absences) {
const start = a.from.getTime() < from.getTime() ? from : a.from;
const end = a.to.getTime() > to.getTime() ? to : a.to;
- total += calculateWorkingDays(start, end, { holidayProvider, workingDays });
+ total += calculateWorkingDays(start, end, {
+ holidayProvider,
+ workingDays,
+ });
}
for (const v of vacationRequests) {
// Only credit half a day when the half-day boundary falls inside our
// window — if we clipped that end off, it would otherwise be lost.
const clippedStart = v.from.getTime() < from.getTime() ? from : v.from;
const clippedEnd = v.to.getTime() > to.getTime() ? to : v.to;
- const halfDayStart = v.halfDayStart && clippedStart.getTime() === v.from.getTime();
- const halfDayEnd = v.halfDayEnd && clippedEnd.getTime() === v.to.getTime();
+ const halfDayStart =
+ v.halfDayStart && clippedStart.getTime() === v.from.getTime();
+ const halfDayEnd =
+ v.halfDayEnd && clippedEnd.getTime() === v.to.getTime();
total += calculateVacationDays(clippedStart, clippedEnd, {
holidayProvider,
workingDays,
diff --git a/apps/api/src/app/employees/employees.controller.ts b/apps/api/src/app/employees/employees.controller.ts
index ba9b98a..60c8c3b 100644
--- a/apps/api/src/app/employees/employees.controller.ts
+++ b/apps/api/src/app/employees/employees.controller.ts
@@ -14,6 +14,8 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CurrentUser } from '../auth/current-user.decorator';
+import type { JwtUser } from '../auth/jwt.strategy';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { EmployeesService } from './employees.service';
@@ -25,22 +27,32 @@ 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 {
- return this.employees.list({ includeInactive: includeInactive === 'true' });
+ list(
+ @CurrentUser() user: JwtUser,
+ @Query('includeInactive') includeInactive?: string,
+ ): Promise {
+ return this.employees.listForActor(user, {
+ includeInactive: includeInactive === 'true',
+ });
}
@Get(':id')
- get(@Param('id', new ParseUUIDPipe()) id: string): Promise {
+ async get(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @CurrentUser() user: JwtUser,
+ ): Promise {
+ await this.employees.assertCanRead(id, user);
return this.employees.getDtoById(id);
}
@Post()
- @ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('HRAdmin')
create(@Body() dto: CreateEmployeeDto): Promise {
@@ -48,7 +60,6 @@ export class EmployeesController {
}
@Put(':id')
- @ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('HRAdmin')
update(
@@ -59,7 +70,6 @@ export class EmployeesController {
}
@Post(':id/password')
- @ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('HRAdmin')
@HttpCode(HttpStatus.NO_CONTENT)
@@ -71,18 +81,20 @@ export class EmployeesController {
}
@Delete(':id')
- @ApiBearerAuth()
@UseGuards(JwtAuthGuard, 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)
@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/employees/employees.service.ts b/apps/api/src/app/employees/employees.service.ts
index e5206fc..6552e91 100644
--- a/apps/api/src/app/employees/employees.service.ts
+++ b/apps/api/src/app/employees/employees.service.ts
@@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import type { Employee, Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
+import type { JwtUser } from '../auth/jwt.strategy';
import { PrismaService } from '../prisma/prisma.service';
import {
toEmployeeDto,
@@ -30,6 +31,43 @@ export class EmployeesService {
return employees.map(toEmployeeDto);
}
+ async listForActor(
+ actor: JwtUser,
+ opts: { includeInactive?: boolean } = {},
+ ): Promise {
+ const accessFilter: Prisma.EmployeeWhereInput =
+ actor.role === 'HRAdmin'
+ ? {}
+ : actor.role === 'Manager'
+ ? { OR: [{ id: actor.id }, { managerId: actor.id }] }
+ : { id: actor.id };
+ const employees = await this.prisma.employee.findMany({
+ where: {
+ AND: [
+ accessFilter,
+ ...(opts.includeInactive ? [] : [{ isActive: true }]),
+ ],
+ },
+ include: { workSchedule: true },
+ orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
+ });
+ return employees.map(toEmployeeDto);
+ }
+
+ async assertCanRead(id: string, actor: JwtUser): Promise {
+ if (actor.role === 'HRAdmin' || id === actor.id) return;
+ const visible =
+ actor.role === 'Manager' &&
+ (await this.prisma.employee.count({
+ where: { id, managerId: actor.id },
+ })) > 0;
+ if (!visible) {
+ throw new ForbiddenException(
+ 'Employees may only access their own or their team records',
+ );
+ }
+ }
+
async getById(id: string): Promise {
const employee = await this.prisma.employee.findUnique({ where: { id } });
if (!employee) throw new NotFoundException(`Employee ${id} not found`);
From b2cd3130c2f258874dbc7612052c0b1314c917a8 Mon Sep 17 00:00:00 2001
From: Patrick Schiller
Date: Sat, 10 Oct 2026 10:02:00 +0200
Subject: [PATCH 2/2] fix(api): close related personnel read paths and preserve
directory workflows
Signed-off-by: Patrick Schiller
---
.../src/api/core-time-violations.e2e.spec.ts | 6 +
.../api/employee-account-access.e2e.spec.ts | 449 ++++++++++++++++++
apps/api/openapi.json | 53 +++
.../src/app/absences/absences.controller.ts | 7 +-
apps/api/src/app/absences/absences.module.ts | 3 +-
apps/api/src/app/absences/absences.service.ts | 12 +-
.../src/app/employees/employees.controller.ts | 10 +-
apps/api/src/app/employees/employees.dto.ts | 12 +
.../src/app/employees/employees.service.ts | 25 +-
.../leave-allowances.controller.ts | 19 +-
.../leave-allowances.module.ts | 3 +-
.../leave-allowances.service.ts | 43 +-
.../app/violations/violations.controller.ts | 11 +-
.../src/app/violations/violations.service.ts | 14 +-
apps/web/src/api/client.ts | 8 +
apps/web/src/api/generated.ts | 41 ++
.../web/src/routes/AdminProjectsPage.spec.tsx | 57 ++-
apps/web/src/routes/AdminProjectsPage.tsx | 17 +-
apps/web/src/routes/RequestsPage.spec.tsx | 75 +++
apps/web/src/routes/RequestsPage.tsx | 6 +-
20 files changed, 795 insertions(+), 76 deletions(-)
create mode 100644 apps/api-e2e/src/api/employee-account-access.e2e.spec.ts
create mode 100644 apps/web/src/routes/RequestsPage.spec.tsx
diff --git a/apps/api-e2e/src/api/core-time-violations.e2e.spec.ts b/apps/api-e2e/src/api/core-time-violations.e2e.spec.ts
index 67335e5..2e9ea04 100644
--- a/apps/api-e2e/src/api/core-time-violations.e2e.spec.ts
+++ b/apps/api-e2e/src/api/core-time-violations.e2e.spec.ts
@@ -197,8 +197,10 @@ describe('Violations — gap-based core-time detection', () => {
},
});
+ const token = await login(ctx.http, anna.email);
const res = await ctx.http
.get(`/api/violations?employeeId=${anna.id}`)
+ .set('Authorization', `Bearer ${token}`)
.expect(200);
expect(res.body).toEqual([]);
});
@@ -221,8 +223,10 @@ describe('Violations — gap-based core-time detection', () => {
},
});
+ const token = await login(ctx.http, anna.email);
const res = await ctx.http
.get(`/api/violations?employeeId=${anna.id}`)
+ .set('Authorization', `Bearer ${token}`)
.expect(200);
expect(res.body).toHaveLength(1);
expect(res.body[0]).toMatchObject({ boundary: '10:00–11:00', deltaMinutes: 60 });
@@ -290,8 +294,10 @@ describe('Violations — gap-based core-time detection', () => {
],
});
+ const token = await login(ctx.http, anna.email);
const res = await ctx.http
.get(`/api/violations?employeeId=${anna.id}`)
+ .set('Authorization', `Bearer ${token}`)
.expect(200);
// Only yesterday's violation surfaces — today is in progress.
diff --git a/apps/api-e2e/src/api/employee-account-access.e2e.spec.ts b/apps/api-e2e/src/api/employee-account-access.e2e.spec.ts
new file mode 100644
index 0000000..bf90751
--- /dev/null
+++ b/apps/api-e2e/src/api/employee-account-access.e2e.spec.ts
@@ -0,0 +1,449 @@
+import { JwtService } from '@nestjs/jwt';
+import {
+ createTestApp,
+ login,
+ seedEmployee,
+ seedLeaveAllowance,
+ type TestContext,
+} from '../support/test-app';
+
+const YEAR = new Date().getUTCFullYear();
+const ABSENCE_DAY = `${YEAR - 1}-05-05`;
+
+async function seedAccessFixture(ctx: TestContext) {
+ const hr = await seedEmployee(ctx.prisma, {
+ personalNo: 'ACCESS-HR',
+ firstName: 'Hannah',
+ lastName: 'Human Resources',
+ email: 'access.hr@test.local',
+ role: 'HRAdmin',
+ });
+ const manager = await seedEmployee(ctx.prisma, {
+ personalNo: 'ACCESS-MANAGER',
+ firstName: 'Mara',
+ lastName: 'Manager',
+ email: 'access.manager@test.local',
+ role: 'Manager',
+ });
+ const employee = await seedEmployee(ctx.prisma, {
+ personalNo: 'ACCESS-EMPLOYEE',
+ firstName: 'Emil',
+ lastName: 'Employee',
+ email: 'access.employee@test.local',
+ managerId: manager.id,
+ });
+ const foreign = await seedEmployee(ctx.prisma, {
+ personalNo: 'ACCESS-FOREIGN',
+ firstName: 'Frida',
+ lastName: 'Foreign',
+ email: 'access.foreign@test.local',
+ });
+ const inactive = await seedEmployee(ctx.prisma, {
+ personalNo: 'ACCESS-INACTIVE',
+ firstName: 'Ida',
+ lastName: 'Inactive',
+ email: 'access.inactive@test.local',
+ managerId: manager.id,
+ });
+ await ctx.prisma.employee.update({
+ where: { id: inactive.id },
+ data: { isActive: false },
+ });
+ const people = { hr, manager, employee, foreign, inactive };
+ for (const person of Object.values(people)) {
+ await seedLeaveAllowance(
+ ctx.prisma,
+ person.id,
+ YEAR,
+ person.id === employee.id ? 17 : person.id === foreign.id ? 23 : 30,
+ );
+ }
+ await ctx.prisma.absence.createMany({
+ data: Object.values(people).map((person) => ({
+ employeeId: person.id,
+ kind: 'Sickness' as const,
+ from: new Date(`${ABSENCE_DAY}T00:00:00.000Z`),
+ to: new Date(`${ABSENCE_DAY}T00:00:00.000Z`),
+ certified: true,
+ note: `Synthetic private absence for ${person.personalNo}`,
+ })),
+ });
+ await ctx.prisma.workSchedule.create({
+ data: {
+ name: 'Access test core window',
+ frameStart: '07:00',
+ frameEnd: '18:00',
+ isDefault: true,
+ coreTimes: {
+ create: [
+ { label: 'Core', start: '10:00', end: '11:00', weekdays: 127 },
+ ],
+ },
+ },
+ });
+ await ctx.prisma.timeEntry.createMany({
+ data: Object.values(people).map((person) => ({
+ employeeId: person.id,
+ clockIn: new Date(YEAR - 1, 4, 5, 11, 30),
+ clockOut: new Date(YEAR - 1, 4, 5, 12, 0),
+ status: 'Approved' as const,
+ })),
+ });
+ return people;
+}
+
+function recordPaths(employeeId: string) {
+ return [
+ `/api/employees/${employeeId}`,
+ `/api/accounts/${employeeId}`,
+ `/api/accounts/${employeeId}/vacation?year=${YEAR}`,
+ ];
+}
+
+describe('Personal employee data read access', () => {
+ let ctx: TestContext;
+ let fixture: Awaited>;
+
+ beforeAll(async () => {
+ ctx = await createTestApp();
+ });
+ afterAll(async () => {
+ await ctx.close();
+ });
+ beforeEach(async () => {
+ await ctx.reset();
+ fixture = await seedAccessFixture(ctx);
+ });
+
+ async function expectRecordAccess(
+ token: string,
+ employeeId: string,
+ status: number,
+ query: Record = {},
+ ) {
+ for (const [index, path] of recordPaths(employeeId).entries()) {
+ const response = await ctx.http
+ .get(path)
+ .query(query)
+ .set('Authorization', `Bearer ${token}`)
+ .expect(status);
+ if (status === 200) {
+ expect(response.body).toMatchObject(
+ index === 0 ? { id: employeeId } : { employeeId },
+ );
+ expect(response.body).not.toHaveProperty('passwordHash');
+ }
+ }
+ for (const path of [
+ `/api/employees/${employeeId}/leave-allowances`,
+ '/api/violations',
+ '/api/absences',
+ ]) {
+ const response = await ctx.http
+ .get(path)
+ .query({
+ ...query,
+ // On these endpoints employeeId selects the record. Keep the target
+ // fixed while testing spoofed actor/role parameters.
+ ...(path.startsWith('/api/employees/') ? {} : { employeeId }),
+ })
+ .set('Authorization', `Bearer ${token}`)
+ .expect(status);
+ if (status === 200) {
+ expect(response.body).toHaveLength(1);
+ expect(response.body[0]).toMatchObject({ employeeId });
+ }
+ }
+ }
+
+ async function expectProtectedResourcesReject(token: string) {
+ for (const path of [
+ '/api/employees',
+ '/api/employees/directory',
+ ...recordPaths(fixture.employee.id),
+ `/api/employees/${fixture.employee.id}/leave-allowances`,
+ `/api/violations?employeeId=${fixture.employee.id}`,
+ `/api/absences?employeeId=${fixture.employee.id}`,
+ ]) {
+ await ctx.http
+ .get(path)
+ .set('Authorization', `Bearer ${token}`)
+ .expect(401);
+ }
+ }
+
+ it.each([
+ 'list',
+ 'directory',
+ 'profile',
+ 'account',
+ 'vacation',
+ 'leave allowances',
+ 'violations',
+ 'absences',
+ ])('rejects anonymous access to the %s endpoint', async (resource) => {
+ const paths: Record = {
+ list: '/api/employees',
+ directory: '/api/employees/directory',
+ profile: `/api/employees/${fixture.employee.id}`,
+ account: `/api/accounts/${fixture.employee.id}`,
+ vacation: `/api/accounts/${fixture.employee.id}/vacation?year=${YEAR}`,
+ 'leave allowances': `/api/employees/${fixture.employee.id}/leave-allowances`,
+ violations: `/api/violations?employeeId=${fixture.employee.id}`,
+ absences: `/api/absences?employeeId=${fixture.employee.id}`,
+ };
+ await ctx.http.get(paths[resource]).expect(401);
+ });
+
+ it('lists only the authenticated employee, including with spoofed query parameters', async () => {
+ const token = await login(ctx.http, fixture.employee.email);
+ for (const query of [
+ {},
+ {
+ includeInactive: 'true',
+ employeeId: fixture.foreign.id,
+ actorId: fixture.hr.id,
+ role: 'HRAdmin',
+ },
+ ]) {
+ const response = await ctx.http
+ .get('/api/employees')
+ .query(query)
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ expect(response.body.map((entry: { id: string }) => entry.id)).toEqual([
+ fixture.employee.id,
+ ]);
+ }
+ });
+
+ it('allows an employee to read their own profile, balances, allowances, violations, and absences', async () => {
+ const token = await login(ctx.http, fixture.employee.email);
+ await expectRecordAccess(token, fixture.employee.id, 200);
+ const vacation = await ctx.http
+ .get(`/api/accounts/${fixture.employee.id}/vacation`)
+ .query({ year: YEAR, employeeId: fixture.foreign.id })
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ expect(vacation.body).toMatchObject({
+ employeeId: fixture.employee.id,
+ year: YEAR,
+ baseDays: 17,
+ totalEntitlement: 17,
+ });
+ });
+
+ it('forbids employees from reading colleagues, managers, HR, or inactive employees', async () => {
+ const token = await login(ctx.http, fixture.employee.email);
+ for (const target of [
+ fixture.foreign,
+ fixture.manager,
+ fixture.hr,
+ fixture.inactive,
+ ]) {
+ await expectRecordAccess(token, target.id, 403);
+ }
+ });
+
+ it('does not let query parameters grant employee access to foreign records', async () => {
+ const token = await login(ctx.http, fixture.employee.email);
+ await expectRecordAccess(token, fixture.foreign.id, 403, {
+ employeeId: fixture.employee.id,
+ actorId: fixture.hr.id,
+ role: 'HRAdmin',
+ includeInactive: 'true',
+ });
+ });
+
+ it('lists only a manager and their direct reports, retaining the active default filter', async () => {
+ const token = await login(ctx.http, fixture.manager.email);
+ for (const includeInactive of [false, true]) {
+ const response = await ctx.http
+ .get('/api/employees')
+ .query(includeInactive ? { includeInactive: 'true' } : {})
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ const expectedIds = [fixture.manager.id, fixture.employee.id];
+ if (includeInactive) expectedIds.push(fixture.inactive.id);
+ expect(
+ response.body.map((entry: { id: string }) => entry.id).sort(),
+ ).toEqual(expectedIds.sort());
+ }
+ });
+
+ it('allows managers to read themselves and their direct reports', async () => {
+ const token = await login(ctx.http, fixture.manager.email);
+ for (const target of [
+ fixture.manager,
+ fixture.employee,
+ fixture.inactive,
+ ]) {
+ await expectRecordAccess(token, target.id, 200);
+ }
+ });
+
+ it('forbids managers from reading unrelated employees or HR, even with spoofed query parameters', async () => {
+ const token = await login(ctx.http, fixture.manager.email);
+ for (const target of [fixture.foreign, fixture.hr]) {
+ await expectRecordAccess(token, target.id, 403, {
+ employeeId: fixture.employee.id,
+ actorId: fixture.hr.id,
+ role: 'HRAdmin',
+ });
+ }
+ });
+
+ it('allows HR to list all employees while excluding inactive employees by default', async () => {
+ const token = await login(ctx.http, fixture.hr.email);
+ for (const includeInactive of [false, true]) {
+ const response = await ctx.http
+ .get('/api/employees')
+ .query(includeInactive ? { includeInactive: 'true' } : {})
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ const expectedIds = [
+ fixture.hr.id,
+ fixture.manager.id,
+ fixture.employee.id,
+ fixture.foreign.id,
+ ];
+ if (includeInactive) expectedIds.push(fixture.inactive.id);
+ expect(
+ response.body.map((entry: { id: string }) => entry.id).sort(),
+ ).toEqual(expectedIds.sort());
+ }
+ });
+
+ it('allows HR to read all employee records, including inactive employees', async () => {
+ const token = await login(ctx.http, fixture.hr.email);
+ for (const target of Object.values(fixture)) {
+ await expectRecordAccess(token, target.id, 200);
+ }
+ });
+
+ it.each(['Employee', 'Manager', 'HRAdmin'] as const)(
+ 'scopes unfiltered absence lists to the authenticated %s, including with dates and spoofed actor parameters',
+ async (role) => {
+ const actor =
+ role === 'Employee'
+ ? fixture.employee
+ : role === 'Manager'
+ ? fixture.manager
+ : fixture.hr;
+ const expectedIds =
+ role === 'Employee'
+ ? [fixture.employee.id]
+ : role === 'Manager'
+ ? [fixture.manager.id, fixture.employee.id, fixture.inactive.id]
+ : Object.values(fixture).map((person) => person.id);
+ const token = await login(ctx.http, actor.email);
+ for (const query of [
+ {},
+ {
+ from: ABSENCE_DAY,
+ to: ABSENCE_DAY,
+ actorId: fixture.hr.id,
+ role: 'HRAdmin',
+ includeInactive: 'true',
+ },
+ ]) {
+ const response = await ctx.http
+ .get('/api/absences')
+ .query(query)
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ expect(
+ response.body
+ .map((entry: { employeeId: string }) => entry.employeeId)
+ .sort(),
+ ).toEqual([...expectedIds].sort());
+ expect(
+ response.body.every(
+ (entry: { certified: boolean }) => entry.certified,
+ ),
+ ).toBe(true);
+ }
+ },
+ );
+
+ it.each([false, true])(
+ 'exposes only active names and IDs in the directory (includeInactive=%s)',
+ async (includeInactive) => {
+ const token = await login(ctx.http, fixture.employee.email);
+ const response = await ctx.http
+ .get('/api/employees/directory')
+ .query(includeInactive ? { includeInactive: 'true' } : {})
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ const expected = [
+ fixture.hr,
+ fixture.manager,
+ fixture.employee,
+ fixture.foreign,
+ ].map(({ id, firstName, lastName }) => ({ id, firstName, lastName }));
+ expect(response.body).toHaveLength(expected.length);
+ expect(response.body).toEqual(expect.arrayContaining(expected));
+ for (const entry of response.body) {
+ expect(Object.keys(entry).sort()).toEqual([
+ 'firstName',
+ 'id',
+ 'lastName',
+ ]);
+ }
+ await expectRecordAccess(token, fixture.foreign.id, 403);
+ },
+ );
+
+ it('rejects a JWT whose role claim was altered without a valid signature', async () => {
+ const token = await login(ctx.http, fixture.employee.email);
+ const [header, payload, signature] = token.split('.');
+ const claims = JSON.parse(
+ Buffer.from(payload, 'base64url').toString('utf8'),
+ );
+ const forgedPayload = Buffer.from(
+ JSON.stringify({ ...claims, role: 'HRAdmin' }),
+ ).toString('base64url');
+ await expectProtectedResourcesReject(
+ `${header}.${forgedPayload}.${signature}`,
+ );
+ });
+
+ it('rejects a correctly signed but expired access token', async () => {
+ const token = ctx.app.get(JwtService).sign(
+ {
+ sub: fixture.employee.id,
+ email: fixture.employee.email,
+ role: fixture.employee.role,
+ typ: 'access',
+ ver: fixture.employee.authVersion,
+ },
+ { expiresIn: -1 },
+ );
+ await expectProtectedResourcesReject(token);
+ });
+
+ it('rejects an existing access token immediately after its employee is deactivated', async () => {
+ const token = await login(ctx.http, fixture.employee.email);
+ await ctx.prisma.employee.update({
+ where: { id: fixture.employee.id },
+ data: { isActive: false },
+ });
+ await expectProtectedResourcesReject(token);
+ });
+
+ it('uses the current database role instead of stale HR claims in an access token', async () => {
+ const token = await login(ctx.http, fixture.hr.email);
+ await ctx.prisma.employee.update({
+ where: { id: fixture.hr.id },
+ data: { role: 'Employee' },
+ });
+ const response = await ctx.http
+ .get('/api/employees')
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ expect(response.body.map((entry: { id: string }) => entry.id)).toEqual([
+ fixture.hr.id,
+ ]);
+ await expectRecordAccess(token, fixture.foreign.id, 403);
+ });
+});
diff --git a/apps/api/openapi.json b/apps/api/openapi.json
index 1e3499e..1658dc8 100644
--- a/apps/api/openapi.json
+++ b/apps/api/openapi.json
@@ -815,6 +815,33 @@
"tags": ["employees"]
}
},
+ "/api/employees/directory": {
+ "get": {
+ "operationId": "EmployeesController_directory",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EmployeeDirectoryDto"
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["employees"]
+ }
+ },
"/api/employees/{id}": {
"get": {
"operationId": "EmployeesController_get",
@@ -2782,6 +2809,11 @@
"description": ""
}
},
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
"tags": ["leave-allowances"]
}
},
@@ -3780,6 +3812,11 @@
"description": ""
}
},
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
"tags": ["violations"]
}
},
@@ -4662,6 +4699,22 @@
},
"required": ["status", "service", "version", "utcTimestamp"]
},
+ "EmployeeDirectoryDto": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "firstName": {
+ "type": "string"
+ },
+ "lastName": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "firstName", "lastName"]
+ },
"CreateEmployeeDto": {
"type": "object",
"properties": {
diff --git a/apps/api/src/app/absences/absences.controller.ts b/apps/api/src/app/absences/absences.controller.ts
index 91ceb6b..d111860 100644
--- a/apps/api/src/app/absences/absences.controller.ts
+++ b/apps/api/src/app/absences/absences.controller.ts
@@ -32,11 +32,13 @@ export class AbsencesController {
@Get()
list(
+ @CurrentUser() user: JwtUser,
@Query('employeeId') employeeId?: string,
@Query('from') from?: string,
@Query('to') to?: string,
): Promise {
return this.absences.list(
+ user,
employeeId,
from ? new Date(from) : undefined,
to ? new Date(to) : undefined,
@@ -44,7 +46,10 @@ export class AbsencesController {
}
@Post()
- create(@CurrentUser() user: JwtUser, @Body() dto: CreateAbsenceDto): Promise {
+ create(
+ @CurrentUser() user: JwtUser,
+ @Body() dto: CreateAbsenceDto,
+ ): Promise {
return this.absences.create(user, dto);
}
diff --git a/apps/api/src/app/absences/absences.module.ts b/apps/api/src/app/absences/absences.module.ts
index 587c258..c6a7eb2 100644
--- a/apps/api/src/app/absences/absences.module.ts
+++ b/apps/api/src/app/absences/absences.module.ts
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
+import { EmployeesModule } from '../employees/employees.module';
import { AbsencesController } from './absences.controller';
import { AbsencesService } from './absences.service';
@Module({
- imports: [AuthModule],
+ imports: [AuthModule, EmployeesModule],
controllers: [AbsencesController],
providers: [AbsencesService],
exports: [AbsencesService],
diff --git a/apps/api/src/app/absences/absences.service.ts b/apps/api/src/app/absences/absences.service.ts
index 5f1fa1e..3dccf12 100644
--- a/apps/api/src/app/absences/absences.service.ts
+++ b/apps/api/src/app/absences/absences.service.ts
@@ -8,6 +8,7 @@ import {
import type { Absence, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { JwtUser } from '../auth/jwt.strategy';
+import { EmployeesService } from '../employees/employees.service';
import {
toAbsenceDto,
type AbsenceDto,
@@ -17,14 +18,21 @@ import {
@Injectable()
export class AbsencesService {
- constructor(private readonly prisma: PrismaService) {}
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly employees: EmployeesService,
+ ) {}
async list(
+ actor: JwtUser,
employeeId: string | undefined,
from?: Date,
to?: Date,
): Promise {
- const where: Prisma.AbsenceWhereInput = {};
+ if (employeeId) await this.employees.assertCanRead(employeeId, actor);
+ const where: Prisma.AbsenceWhereInput = {
+ employee: this.employees.readScope(actor),
+ };
if (employeeId) where.employeeId = employeeId;
if (from || to) {
where.OR = [
diff --git a/apps/api/src/app/employees/employees.controller.ts b/apps/api/src/app/employees/employees.controller.ts
index 18c3aca..4606d06 100644
--- a/apps/api/src/app/employees/employees.controller.ts
+++ b/apps/api/src/app/employees/employees.controller.ts
@@ -12,7 +12,7 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
-import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { JwtUser } from '../auth/jwt.strategy';
@@ -21,6 +21,7 @@ import { RolesGuard } from '../auth/roles.guard';
import { EmployeesService } from './employees.service';
import {
CreateEmployeeDto,
+ EmployeeDirectoryDto,
SetPasswordDto,
UpdateEmployeeDto,
type EmployeeDto,
@@ -43,6 +44,13 @@ export class EmployeesController {
});
}
+ // Static route before ':id'; authenticated names without personnel details.
+ @Get('directory')
+ @ApiOkResponse({ type: [EmployeeDirectoryDto] })
+ directory(): Promise {
+ return this.employees.directory();
+ }
+
@Get(':id')
async get(
@Param('id', new ParseUUIDPipe()) id: string,
diff --git a/apps/api/src/app/employees/employees.dto.ts b/apps/api/src/app/employees/employees.dto.ts
index 32bcede..5e5c7ee 100644
--- a/apps/api/src/app/employees/employees.dto.ts
+++ b/apps/api/src/app/employees/employees.dto.ts
@@ -51,6 +51,18 @@ export type EmployeeWithSchedule = Employee & {
workSchedule: WorkSchedule | null;
};
+/** Minimal names for choosing a substitute or assigning a project. */
+export class EmployeeDirectoryDto {
+ @ApiProperty({ format: 'uuid' })
+ id!: string;
+
+ @ApiProperty()
+ firstName!: string;
+
+ @ApiProperty()
+ lastName!: string;
+}
+
export interface EmployeeDto {
id: string;
personalNo: string;
diff --git a/apps/api/src/app/employees/employees.service.ts b/apps/api/src/app/employees/employees.service.ts
index e7f8c86..fa1752e 100644
--- a/apps/api/src/app/employees/employees.service.ts
+++ b/apps/api/src/app/employees/employees.service.ts
@@ -12,6 +12,7 @@ import { PrismaService } from '../prisma/prisma.service';
import {
toEmployeeDto,
type CreateEmployeeDto,
+ type EmployeeDirectoryDto,
type EmployeeDto,
type UpdateEmployeeDto,
} from './employees.dto';
@@ -22,6 +23,14 @@ const BCRYPT_ROUNDS = 10;
export class EmployeesService {
constructor(private readonly prisma: PrismaService) {}
+ async directory(): Promise {
+ return this.prisma.employee.findMany({
+ where: { isActive: true },
+ select: { id: true, firstName: true, lastName: true },
+ orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
+ });
+ }
+
async list(opts: { includeInactive?: boolean } = {}): Promise {
const employees = await this.prisma.employee.findMany({
where: opts.includeInactive ? undefined : { isActive: true },
@@ -35,16 +44,10 @@ export class EmployeesService {
actor: JwtUser,
opts: { includeInactive?: boolean } = {},
): Promise {
- const accessFilter: Prisma.EmployeeWhereInput =
- actor.role === 'HRAdmin'
- ? {}
- : actor.role === 'Manager'
- ? { OR: [{ id: actor.id }, { managerId: actor.id }] }
- : { id: actor.id };
const employees = await this.prisma.employee.findMany({
where: {
AND: [
- accessFilter,
+ this.readScope(actor),
...(opts.includeInactive ? [] : [{ isActive: true }]),
],
},
@@ -54,6 +57,14 @@ export class EmployeesService {
return employees.map(toEmployeeDto);
}
+ readScope(actor: JwtUser): Prisma.EmployeeWhereInput {
+ if (actor.role === 'HRAdmin') return {};
+ if (actor.role === 'Manager') {
+ return { OR: [{ id: actor.id }, { managerId: actor.id }] };
+ }
+ return { id: actor.id };
+ }
+
async assertCanRead(id: string, actor: JwtUser): Promise {
if (actor.role === 'HRAdmin' || id === actor.id) return;
const visible =
diff --git a/apps/api/src/app/leave-allowances/leave-allowances.controller.ts b/apps/api/src/app/leave-allowances/leave-allowances.controller.ts
index 2e4e73f..7bf458f 100644
--- a/apps/api/src/app/leave-allowances/leave-allowances.controller.ts
+++ b/apps/api/src/app/leave-allowances/leave-allowances.controller.ts
@@ -13,25 +13,34 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CurrentUser } from '../auth/current-user.decorator';
+import type { JwtUser } from '../auth/jwt.strategy';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { CronKeyGuard } from './cron-key.guard';
import { LeaveAllowancesService } from './leave-allowances.service';
-import { UpsertLeaveAllowanceDto, type LeaveAllowanceDto } from './leave-allowances.dto';
+import {
+ UpsertLeaveAllowanceDto,
+ type LeaveAllowanceDto,
+} from './leave-allowances.dto';
@ApiTags('leave-allowances')
+@ApiBearerAuth()
+@UseGuards(JwtAuthGuard)
@Controller('employees/:employeeId/leave-allowances')
export class LeaveAllowancesController {
constructor(private readonly service: LeaveAllowancesService) {}
@Get()
- list(@Param('employeeId', new ParseUUIDPipe()) employeeId: string): Promise {
- return this.service.list(employeeId);
+ list(
+ @Param('employeeId', new ParseUUIDPipe()) employeeId: string,
+ @CurrentUser() user: JwtUser,
+ ): Promise {
+ return this.service.list(employeeId, user);
}
@Put(':year')
- @ApiBearerAuth()
- @UseGuards(JwtAuthGuard, RolesGuard)
+ @UseGuards(RolesGuard)
@Roles('HRAdmin')
upsert(
@Param('employeeId', new ParseUUIDPipe()) employeeId: string,
diff --git a/apps/api/src/app/leave-allowances/leave-allowances.module.ts b/apps/api/src/app/leave-allowances/leave-allowances.module.ts
index 7517d74..34f54b6 100644
--- a/apps/api/src/app/leave-allowances/leave-allowances.module.ts
+++ b/apps/api/src/app/leave-allowances/leave-allowances.module.ts
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
+import { EmployeesModule } from '../employees/employees.module';
import { CronKeyGuard } from './cron-key.guard';
import {
CronCarryOverController,
@@ -9,7 +10,7 @@ import {
import { LeaveAllowancesService } from './leave-allowances.service';
@Module({
- imports: [AuthModule],
+ imports: [AuthModule, EmployeesModule],
controllers: [
LeaveAllowancesController,
LeaveAllowancesAdminController,
diff --git a/apps/api/src/app/leave-allowances/leave-allowances.service.ts b/apps/api/src/app/leave-allowances/leave-allowances.service.ts
index 5547540..76c1f78 100644
--- a/apps/api/src/app/leave-allowances/leave-allowances.service.ts
+++ b/apps/api/src/app/leave-allowances/leave-allowances.service.ts
@@ -1,13 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { EmployeeLeaveAllowance } from '@prisma/client';
+import type { JwtUser } from '../auth/jwt.strategy';
+import { EmployeesService } from '../employees/employees.service';
import { PrismaService } from '../prisma/prisma.service';
-import { toLeaveAllowanceDto, type LeaveAllowanceDto, type UpsertLeaveAllowanceDto } from './leave-allowances.dto';
+import {
+ toLeaveAllowanceDto,
+ type LeaveAllowanceDto,
+ type UpsertLeaveAllowanceDto,
+} from './leave-allowances.dto';
@Injectable()
export class LeaveAllowancesService {
- constructor(private readonly prisma: PrismaService) {}
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly employees: EmployeesService,
+ ) {}
- async list(employeeId: string): Promise {
+ async list(employeeId: string, actor: JwtUser): Promise {
+ await this.employees.assertCanRead(employeeId, actor);
await this.assertEmployee(employeeId);
const rows = await this.prisma.employeeLeaveAllowance.findMany({
where: { employeeId },
@@ -16,7 +26,10 @@ export class LeaveAllowancesService {
return rows.map(toLeaveAllowanceDto);
}
- async getOrDefault(employeeId: string, year: number): Promise {
+ async getOrDefault(
+ employeeId: string,
+ year: number,
+ ): Promise {
const existing = await this.prisma.employeeLeaveAllowance.findUnique({
where: { employeeId_year: { employeeId, year } },
});
@@ -39,7 +52,9 @@ export class LeaveAllowancesService {
dto: UpsertLeaveAllowanceDto,
): Promise {
await this.assertEmployee(employeeId);
- const carryOverExpiresOn = dto.carryOverExpiresOn ? new Date(dto.carryOverExpiresOn) : null;
+ const carryOverExpiresOn = dto.carryOverExpiresOn
+ ? new Date(dto.carryOverExpiresOn)
+ : null;
const row = await this.prisma.employeeLeaveAllowance.upsert({
where: { employeeId_year: { employeeId, year } },
create: {
@@ -71,7 +86,9 @@ export class LeaveAllowancesService {
* The reason — including the amount and the original expiry date — is
* appended to `adjustmentReason` so the audit trail survives the write.
*/
- async expireCarryOvers(asOf: Date = new Date()): Promise<{ scanned: number; expired: number }> {
+ async expireCarryOvers(
+ asOf: Date = new Date(),
+ ): Promise<{ scanned: number; expired: number }> {
const candidates = await this.prisma.employeeLeaveAllowance.findMany({
where: {
carryOverExpiresOn: { not: null, lt: asOf },
@@ -81,9 +98,12 @@ export class LeaveAllowancesService {
let expired = 0;
for (const row of candidates) {
const lost = Number(row.carryOverDays);
- const dateStr = row.carryOverExpiresOn?.toISOString().slice(0, 10) ?? 'unknown';
+ const dateStr =
+ row.carryOverExpiresOn?.toISOString().slice(0, 10) ?? 'unknown';
const note = `Carry-over of ${lost.toFixed(2)} days expired on ${dateStr}`;
- const reason = row.adjustmentReason ? `${row.adjustmentReason}; ${note}` : note;
+ const reason = row.adjustmentReason
+ ? `${row.adjustmentReason}; ${note}`
+ : note;
await this.prisma.employeeLeaveAllowance.update({
where: { id: row.id },
data: { carryOverDays: 0, adjustmentReason: reason },
@@ -94,8 +114,11 @@ export class LeaveAllowancesService {
}
private async assertEmployee(employeeId: string) {
- const employee = await this.prisma.employee.findUnique({ where: { id: employeeId } });
- if (!employee) throw new NotFoundException(`Employee ${employeeId} not found`);
+ const employee = await this.prisma.employee.findUnique({
+ where: { id: employeeId },
+ });
+ if (!employee)
+ throw new NotFoundException(`Employee ${employeeId} not found`);
return employee;
}
}
diff --git a/apps/api/src/app/violations/violations.controller.ts b/apps/api/src/app/violations/violations.controller.ts
index f18e09e..984d8e4 100644
--- a/apps/api/src/app/violations/violations.controller.ts
+++ b/apps/api/src/app/violations/violations.controller.ts
@@ -1,8 +1,13 @@
-import { Controller, Get, Query } from '@nestjs/common';
-import { ApiTags } from '@nestjs/swagger';
+import { Controller, Get, 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 { ViolationsService, type ViolationDto } from './violations.service';
@ApiTags('violations')
+@ApiBearerAuth()
+@UseGuards(JwtAuthGuard)
@Controller('violations')
export class ViolationsController {
constructor(private readonly violations: ViolationsService) {}
@@ -10,11 +15,13 @@ export class ViolationsController {
@Get()
list(
@Query('employeeId') employeeId: string,
+ @CurrentUser() user: JwtUser,
@Query('from') from?: string,
@Query('to') to?: string,
): Promise {
return this.violations.list(
employeeId,
+ user,
from ? new Date(from) : undefined,
to ? new Date(to) : undefined,
);
diff --git a/apps/api/src/app/violations/violations.service.ts b/apps/api/src/app/violations/violations.service.ts
index 112bd56..c6d51e0 100644
--- a/apps/api/src/app/violations/violations.service.ts
+++ b/apps/api/src/app/violations/violations.service.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { detectCoreTimeViolationsForDay } from 'shared';
+import type { JwtUser } from '../auth/jwt.strategy';
import { EmployeesService } from '../employees/employees.service';
import { PrismaService } from '../prisma/prisma.service';
import { WorkSchedulesService } from '../work-schedules/work-schedules.service';
@@ -28,7 +29,13 @@ export class ViolationsService {
private readonly schedules: WorkSchedulesService,
) {}
- async list(employeeId: string, from?: Date, to?: Date): Promise {
+ async list(
+ employeeId: string,
+ actor: JwtUser,
+ from?: Date,
+ to?: Date,
+ ): Promise {
+ await this.employees.assertCanRead(employeeId, actor);
const employee = await this.employees.getById(employeeId);
if (employee.timeModel === 'Vertrauensarbeitszeit') return [];
@@ -84,7 +91,10 @@ export class ViolationsService {
}
}
// Stable ordering: newest day first, then by window start.
- out.sort((a, b) => b.date.localeCompare(a.date) || a.boundary.localeCompare(b.boundary));
+ out.sort(
+ (a, b) =>
+ b.date.localeCompare(a.date) || a.boundary.localeCompare(b.boundary),
+ );
return out;
}
}
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 3cf2bc7..7fc0692 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -81,6 +81,12 @@ export const BUNDESLAND_LABEL: Record = {
export type HolidayCalendar = 'NONE' | `DE-${Bundesland}`;
+export interface EmployeeDirectoryDto {
+ id: string;
+ firstName: string;
+ lastName: string;
+}
+
export interface EmployeeDto {
id: string;
personalNo: string;
@@ -844,6 +850,8 @@ export const api = {
request(
`/api/employees${includeInactive ? '?includeInactive=true' : ''}`,
),
+ employeeDirectory: () =>
+ request('/api/employees/directory'),
createEmployee: (payload: CreateEmployeePayload) =>
request('/api/employees', {
method: 'POST',
diff --git a/apps/web/src/api/generated.ts b/apps/web/src/api/generated.ts
index af9ee0c..ea7a5e3 100644
--- a/apps/web/src/api/generated.ts
+++ b/apps/web/src/api/generated.ts
@@ -324,6 +324,22 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/employees/directory': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations['EmployeesController_directory'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/employees/{id}': {
parameters: {
query?: never;
@@ -1699,6 +1715,12 @@ export interface components {
/** Format: date-time */
utcTimestamp: string;
};
+ EmployeeDirectoryDto: {
+ /** Format: uuid */
+ id: string;
+ firstName: string;
+ lastName: string;
+ };
CreateEmployeeDto: {
/** @example 1001 */
personalNo: string;
@@ -3230,6 +3252,25 @@ export interface operations {
};
};
};
+ EmployeesController_directory: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['EmployeeDirectoryDto'][];
+ };
+ };
+ };
+ };
EmployeesController_get: {
parameters: {
query?: never;
diff --git a/apps/web/src/routes/AdminProjectsPage.spec.tsx b/apps/web/src/routes/AdminProjectsPage.spec.tsx
index 3785f73..4316234 100644
--- a/apps/web/src/routes/AdminProjectsPage.spec.tsx
+++ b/apps/web/src/routes/AdminProjectsPage.spec.tsx
@@ -3,7 +3,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test-utils';
const projectsMock = vi.fn();
-const employeesMock = vi.fn();
+const employeeDirectoryMock = vi.fn();
const projectAssignmentsMock = vi.fn();
const createProjectMock = vi.fn();
const updateProjectMock = vi.fn();
@@ -18,7 +18,7 @@ const projectReportMock = vi.fn();
vi.mock('../api/client', () => ({
api: {
projects: (...args: unknown[]) => projectsMock(...args),
- employees: (...args: unknown[]) => employeesMock(...args),
+ employeeDirectory: (...args: unknown[]) => employeeDirectoryMock(...args),
projectAssignments: (...args: unknown[]) => projectAssignmentsMock(...args),
createProject: (...args: unknown[]) => createProjectMock(...args),
updateProject: (...args: unknown[]) => updateProjectMock(...args),
@@ -90,19 +90,13 @@ const PROJECTS = [
const EMPLOYEES = [
{
id: 'e-1',
- personalNo: '1001',
firstName: 'Anna',
lastName: 'Müller',
- role: 'Employee',
- isActive: true,
},
{
id: 'e-2',
- personalNo: '1002',
firstName: 'Bernd',
lastName: 'Schulz',
- role: 'Employee',
- isActive: true,
},
];
@@ -110,7 +104,7 @@ describe('AdminProjectsPage', () => {
beforeEach(() => {
role = 'HRAdmin';
projectsMock.mockClear().mockResolvedValue(PROJECTS);
- employeesMock.mockClear().mockResolvedValue(EMPLOYEES);
+ employeeDirectoryMock.mockClear().mockResolvedValue(EMPLOYEES);
projectAssignmentsMock
.mockClear()
.mockResolvedValue([{ employeeId: 'e-1', projectId: 'p-1' }]);
@@ -194,28 +188,33 @@ describe('AdminProjectsPage', () => {
});
});
- it('renders the assignment matrix and toggles assignments', async () => {
- const { AdminProjectsPage } = await import('./AdminProjectsPage');
- renderWithProviders( );
- await waitFor(() => {
- expect(screen.getByText('Zuweisungsmatrix')).toBeDefined();
- });
+ it.each(['HRAdmin', 'Manager'])(
+ 'lets %s assign projects using only directory names',
+ async (actorRole) => {
+ role = actorRole;
+ const { AdminProjectsPage } = await import('./AdminProjectsPage');
+ renderWithProviders( );
+ await waitFor(() => {
+ expect(screen.getByText('Zuweisungsmatrix')).toBeDefined();
+ });
- const annaP1 = await screen.findByLabelText('Anna Müller – PRJ-001');
- const berndP2 = screen.getByLabelText('Bernd Schulz – PRJ-002');
- expect((annaP1 as HTMLInputElement).checked).toBe(true);
- expect((berndP2 as HTMLInputElement).checked).toBe(false);
+ const annaP1 = await screen.findByLabelText('Anna Müller – PRJ-001');
+ const berndP2 = screen.getByLabelText('Bernd Schulz – PRJ-002');
+ expect(employeeDirectoryMock).toHaveBeenCalled();
+ expect((annaP1 as HTMLInputElement).checked).toBe(true);
+ expect((berndP2 as HTMLInputElement).checked).toBe(false);
- // Unchecking an assigned cell unassigns; checking an empty one assigns.
- fireEvent.click(annaP1);
- await waitFor(() => {
- expect(unassignProjectMock).toHaveBeenCalledWith('p-1', 'e-1');
- });
- fireEvent.click(berndP2);
- await waitFor(() => {
- expect(assignProjectMock).toHaveBeenCalledWith('p-2', 'e-2');
- });
- });
+ // Unchecking an assigned cell unassigns; checking an empty one assigns.
+ fireEvent.click(annaP1);
+ await waitFor(() => {
+ expect(unassignProjectMock).toHaveBeenCalledWith('p-1', 'e-1');
+ });
+ fireEvent.click(berndP2);
+ await waitFor(() => {
+ expect(assignProjectMock).toHaveBeenCalledWith('p-2', 'e-2');
+ });
+ },
+ );
it('flags overbooked plans in red and shows IST-only without a plan', async () => {
const { AdminProjectsPage } = await import('./AdminProjectsPage');
diff --git a/apps/web/src/routes/AdminProjectsPage.tsx b/apps/web/src/routes/AdminProjectsPage.tsx
index 0c819b4..c1b03c7 100644
--- a/apps/web/src/routes/AdminProjectsPage.tsx
+++ b/apps/web/src/routes/AdminProjectsPage.tsx
@@ -17,6 +17,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
api,
+ type EmployeeDirectoryDto,
type ProjectAssignmentDto,
type ProjectDto,
type ProjectReportDto,
@@ -123,8 +124,8 @@ export function AdminProjectsPage() {
enabled: isAuthorized,
});
const employees = useQuery({
- queryKey: ['employees'],
- queryFn: () => api.employees(),
+ queryKey: ['employee-directory'],
+ queryFn: () => api.employeeDirectory(),
enabled: isAuthorized,
});
const assignments = useQuery({
@@ -149,7 +150,7 @@ export function AdminProjectsPage() {
);
}
- const activeEmployees = (employees.data ?? []).filter((e) => e.isActive);
+ const activeEmployees = employees.data ?? [];
return (
@@ -549,12 +550,7 @@ function ServiceOrderRow({
interface MatrixProps {
projects: ProjectDto[];
- employees: {
- id: string;
- firstName: string;
- lastName: string;
- personalNo: string;
- }[];
+ employees: EmployeeDirectoryDto[];
assignments: ProjectAssignmentDto[];
}
@@ -636,9 +632,6 @@ function AssignmentMatrix({ projects, employees, assignments }: MatrixProps) {
{emp.firstName} {emp.lastName}
-
- ({emp.personalNo})
-
{projects.map((p) => {
const isAssigned = assigned.has(`${emp.id}:${p.id}`);
diff --git a/apps/web/src/routes/RequestsPage.spec.tsx b/apps/web/src/routes/RequestsPage.spec.tsx
new file mode 100644
index 0000000..933cab4
--- /dev/null
+++ b/apps/web/src/routes/RequestsPage.spec.tsx
@@ -0,0 +1,75 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
+import { renderWithProviders } from '../test-utils';
+import { RequestsPage } from './RequestsPage';
+
+const employeeDirectoryMock = vi.fn();
+const employeesMock = vi.fn();
+const createVacationRequestMock = vi.fn();
+
+vi.mock('../api/client', () => ({
+ api: {
+ listRequests: vi.fn().mockResolvedValue([]),
+ employeeDirectory: (...args: unknown[]) => employeeDirectoryMock(...args),
+ employees: (...args: unknown[]) => employeesMock(...args),
+ vacationBalance: vi.fn().mockResolvedValue({
+ remainingDays: 30,
+ totalEntitlement: 30,
+ approvedDays: 0,
+ pendingDays: 0,
+ }),
+ createVacationRequest: (...args: unknown[]) =>
+ createVacationRequestMock(...args),
+ },
+}));
+
+vi.mock('../app/auth', () => ({
+ useCurrentUser: () => ({
+ id: 'requester',
+ firstName: 'Anna',
+ lastName: 'Example',
+ role: 'Employee',
+ }),
+}));
+
+describe('vacation substitute selection', () => {
+ beforeEach(() => {
+ employeesMock.mockClear();
+ employeeDirectoryMock.mockClear().mockResolvedValue([
+ { id: 'requester', firstName: 'Anna', lastName: 'Example' },
+ { id: 'substitute', firstName: 'Bernd', lastName: 'Example' },
+ ]);
+ createVacationRequestMock.mockClear().mockResolvedValue({});
+ });
+
+ it('lets an employee select a colleague and submit without reading personnel records', async () => {
+ renderWithProviders( );
+ fireEvent.click(screen.getByRole('button', { name: 'Neuer Antrag' }));
+
+ const selection = await screen.findByLabelText('Vertretung (optional)');
+ expect(
+ await within(selection).findByRole('option', {
+ name: 'Bernd Example',
+ }),
+ ).toBeDefined();
+ expect(
+ within(selection).queryByRole('option', {
+ name: 'Anna Example',
+ }),
+ ).toBeNull();
+ expect(employeeDirectoryMock).toHaveBeenCalled();
+ expect(employeesMock).not.toHaveBeenCalled();
+
+ fireEvent.change(selection, { target: { value: 'substitute' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Antrag stellen' }));
+
+ await waitFor(() => {
+ expect(createVacationRequestMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ employeeId: 'requester',
+ substituteId: 'substitute',
+ }),
+ );
+ });
+ });
+});
diff --git a/apps/web/src/routes/RequestsPage.tsx b/apps/web/src/routes/RequestsPage.tsx
index e04d95c..ce0534b 100644
--- a/apps/web/src/routes/RequestsPage.tsx
+++ b/apps/web/src/routes/RequestsPage.tsx
@@ -172,8 +172,8 @@ function NewRequestForm({ onClose }: NewRequestFormProps) {
const [error, setError] = useState(null);
const employees = useQuery({
- queryKey: ['employees'],
- queryFn: () => api.employees(),
+ queryKey: ['employee-directory'],
+ queryFn: () => api.employeeDirectory(),
});
const fromDate = useMemo(() => new Date(`${from}T00:00:00.000Z`), [from]);
@@ -319,7 +319,7 @@ function NewRequestForm({ onClose }: NewRequestFormProps) {
.filter((e) => e.id !== employeeId)
.map((e) => (
- {e.firstName} {e.lastName} ({enumLabel(e.role)})
+ {e.firstName} {e.lastName}
))}