From 330f42e338f81ac2c4ce34f6e5b5bc14396dc33d Mon Sep 17 00:00:00 2001
From: Patrick Schiller
Date: Thu, 18 Jun 2026 13:08:01 +0200
Subject: [PATCH] Strengthen ER model integrity
Signed-off-by: Patrick Schiller
---
apps/api-e2e/jest.config.js | 4 +
apps/api-e2e/src/api/absences.e2e.spec.ts | 71 ++++-
.../src/api/time-entry-projects.e2e.spec.ts | 165 ++++++++++--
.../src/api/vacation-workflow.e2e.spec.ts | 82 +++++-
.../src/api/work-schedules.e2e.spec.ts | 47 +++-
apps/api/src/app/absences/absences.service.ts | 67 ++++-
apps/api/src/app/requests/requests.service.ts | 255 ++++++++++++++----
.../work-schedules/work-schedules.service.ts | 88 ++++--
.../migration.sql | 154 +++++++++++
prisma/schema.prisma | 33 +--
10 files changed, 825 insertions(+), 141 deletions(-)
create mode 100644 prisma/migrations/20260618120000_er_model_integrity_constraints/migration.sql
diff --git a/apps/api-e2e/jest.config.js b/apps/api-e2e/jest.config.js
index 3f8de86..01d596d 100644
--- a/apps/api-e2e/jest.config.js
+++ b/apps/api-e2e/jest.config.js
@@ -15,6 +15,10 @@ module.exports = {
transform: {
'^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
},
+ moduleNameMapper: {
+ '^shared$': '/../../libs/shared/src/index.ts',
+ '^(\\.{1,2}/.*)\\.js$': '$1',
+ },
moduleFileExtensions: ['ts', 'js', 'html'],
testTimeout: 30000,
coverageDirectory: 'test-output/jest/coverage',
diff --git a/apps/api-e2e/src/api/absences.e2e.spec.ts b/apps/api-e2e/src/api/absences.e2e.spec.ts
index d628b70..3940490 100644
--- a/apps/api-e2e/src/api/absences.e2e.spec.ts
+++ b/apps/api-e2e/src/api/absences.e2e.spec.ts
@@ -1,4 +1,9 @@
-import { createTestApp, login, seedEmployee, type TestContext } from '../support/test-app';
+import {
+ createTestApp,
+ login,
+ seedEmployee,
+ type TestContext,
+} from '../support/test-app';
describe('Absences — Sickness / Training / Flextime', () => {
let ctx: TestContext;
@@ -23,11 +28,21 @@ describe('Absences — Sickness / Training / Flextime', () => {
const today = new Date().toISOString();
const tomorrow = new Date(Date.now() + 86_400_000).toISOString();
+ const dayAfterTomorrow = new Date(
+ Date.now() + 2 * 86_400_000,
+ ).toISOString();
+ const threeDaysOut = new Date(Date.now() + 3 * 86_400_000).toISOString();
const sickness = await ctx.http
.post('/api/absences')
.set('Authorization', `Bearer ${token}`)
- .send({ employeeId: anna.id, kind: 'Sickness', from: today, to: today, certified: true })
+ .send({
+ employeeId: anna.id,
+ kind: 'Sickness',
+ from: today,
+ to: today,
+ certified: true,
+ })
.expect(201);
expect(sickness.body).toMatchObject({ kind: 'Sickness', certified: true });
@@ -37,17 +52,25 @@ describe('Absences — Sickness / Training / Flextime', () => {
.send({
employeeId: anna.id,
kind: 'Training',
- from: today,
- to: tomorrow,
+ from: tomorrow,
+ to: dayAfterTomorrow,
note: 'NestJS Schulung',
})
.expect(201);
- expect(training.body).toMatchObject({ kind: 'Training', note: 'NestJS Schulung' });
+ expect(training.body).toMatchObject({
+ kind: 'Training',
+ note: 'NestJS Schulung',
+ });
const flextime = await ctx.http
.post('/api/absences')
.set('Authorization', `Bearer ${token}`)
- .send({ employeeId: anna.id, kind: 'Flextime', from: tomorrow, to: tomorrow })
+ .send({
+ employeeId: anna.id,
+ kind: 'Flextime',
+ from: threeDaysOut,
+ to: threeDaysOut,
+ })
.expect(201);
expect(flextime.body).toMatchObject({ kind: 'Flextime' });
@@ -55,7 +78,9 @@ describe('Absences — Sickness / Training / Flextime', () => {
.get(`/api/absences?employeeId=${anna.id}`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
- const kinds = (list.body as Array<{ kind: string }>).map((a) => a.kind).sort();
+ const kinds = (list.body as Array<{ kind: string }>)
+ .map((a) => a.kind)
+ .sort();
expect(kinds).toEqual(['Flextime', 'Sickness', 'Training']);
});
@@ -79,4 +104,36 @@ describe('Absences — Sickness / Training / Flextime', () => {
})
.expect(400);
});
+
+ it('rejects overlapping absences for the same employee', async () => {
+ const anna = await seedEmployee(ctx.prisma, {
+ personalNo: '1001',
+ firstName: 'Anna',
+ lastName: 'Mueller',
+ email: 'anna@test.local',
+ });
+ const token = await login(ctx.http, 'anna@test.local');
+ const first = {
+ employeeId: anna.id,
+ kind: 'Sickness',
+ from: '2026-09-07T00:00:00.000Z',
+ to: '2026-09-09T00:00:00.000Z',
+ };
+
+ await ctx.http
+ .post('/api/absences')
+ .set('Authorization', `Bearer ${token}`)
+ .send(first)
+ .expect(201);
+ await ctx.http
+ .post('/api/absences')
+ .set('Authorization', `Bearer ${token}`)
+ .send({
+ employeeId: anna.id,
+ kind: 'Training',
+ from: '2026-09-09T00:00:00.000Z',
+ to: '2026-09-10T00:00:00.000Z',
+ })
+ .expect(409);
+ });
});
diff --git a/apps/api-e2e/src/api/time-entry-projects.e2e.spec.ts b/apps/api-e2e/src/api/time-entry-projects.e2e.spec.ts
index 9e981a1..583c57d 100644
--- a/apps/api-e2e/src/api/time-entry-projects.e2e.spec.ts
+++ b/apps/api-e2e/src/api/time-entry-projects.e2e.spec.ts
@@ -11,6 +11,7 @@ import {
// 07:00–23:00 default frame in both CET and CEST.
describe('TimeEntries × Projects — clock-in, retroactive assignment, split', () => {
let ctx: TestContext;
+ let closedEntryDayOffset = 0;
beforeAll(async () => {
ctx = await createTestApp();
});
@@ -18,6 +19,7 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
await ctx.close();
});
beforeEach(async () => {
+ closedEntryDayOffset = 0;
await ctx.reset();
});
@@ -65,10 +67,19 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
{ orderNo: 'SA-2', title: 'Altauftrag', isActive: false },
],
});
- return { worker, other, manager, assigned, second, inactive, foreign, ordered };
+ return {
+ worker,
+ other,
+ manager,
+ assigned,
+ second,
+ inactive,
+ foreign,
+ ordered,
+ };
}
- /** Closed mid-day entry (always inside the 07:00–23:00 frame): 09:00Z–15:00Z today. */
+ /** Closed mid-day entry (always inside the 07:00–23:00 frame). */
async function seedClosedEntry(
employeeId: string,
opts: {
@@ -79,11 +90,26 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
} = {},
) {
const now = new Date();
+ const day = closedEntryDayOffset++;
const clockIn = new Date(
- Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 9, 0, 0),
+ Date.UTC(
+ now.getUTCFullYear(),
+ now.getUTCMonth(),
+ now.getUTCDate() + day,
+ 9,
+ 0,
+ 0,
+ ),
);
const clockOut = new Date(
- Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 15, 0, 0),
+ Date.UTC(
+ now.getUTCFullYear(),
+ now.getUTCMonth(),
+ now.getUTCDate() + day,
+ 15,
+ 0,
+ 0,
+ ),
);
return ctx.prisma.timeEntry.create({
data: {
@@ -232,8 +258,12 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
it('splits a closed entry into two seamless segments; omitted projectId inherits', async () => {
const { worker, assigned } = await fixture();
const token = await login(ctx.http, worker.email);
- const entry = await seedClosedEntry(worker.id, { projectId: assigned.id });
- const at = new Date(entry.clockIn.getTime() + 2 * 60 * 60 * 1000).toISOString();
+ const entry = await seedClosedEntry(worker.id, {
+ projectId: assigned.id,
+ });
+ const at = new Date(
+ entry.clockIn.getTime() + 2 * 60 * 60 * 1000,
+ ).toISOString();
const res = await ctx.http
.post(`/api/timeentries/${entry.id}/split`)
@@ -287,10 +317,24 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
data: {
employeeId: worker.id,
clockIn: new Date(
- Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 9, 0, 0),
+ Date.UTC(
+ now.getUTCFullYear(),
+ now.getUTCMonth(),
+ now.getUTCDate(),
+ 9,
+ 0,
+ 0,
+ ),
),
clockOut: new Date(
- Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 15, 0, 0),
+ Date.UTC(
+ now.getUTCFullYear(),
+ now.getUTCMonth(),
+ now.getUTCDate(),
+ 15,
+ 0,
+ 0,
+ ),
),
status: 'Pending',
latitude: 50.94,
@@ -298,7 +342,9 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
accuracyMeters: 12.5,
},
});
- const at = new Date(entry.clockIn.getTime() + 60 * 60 * 1000).toISOString();
+ const at = new Date(
+ entry.clockIn.getTime() + 60 * 60 * 1000,
+ ).toISOString();
const res = await ctx.http
.post(`/api/timeentries/${entry.id}/split`)
.set('Authorization', `Bearer ${token}`)
@@ -341,7 +387,9 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
// Approved entries can be split as well (lock removed with Epic 5.1).
const approved = await seedClosedEntry(worker.id, { status: 'Approved' });
- const at = new Date(approved.clockIn.getTime() + 60 * 60 * 1000).toISOString();
+ const at = new Date(
+ approved.clockIn.getTime() + 60 * 60 * 1000,
+ ).toISOString();
await ctx.http
.post(`/api/timeentries/${approved.id}/split`)
.set('Authorization', `Bearer ${token}`)
@@ -350,7 +398,9 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
// Unassigned project for the second segment is rejected.
const { foreign } = await (async () => ({
- foreign: await ctx.prisma.project.create({ data: { code: 'P-LATE', name: 'P-LATE' } }),
+ foreign: await ctx.prisma.project.create({
+ data: { code: 'P-LATE', name: 'P-LATE' },
+ }),
}))();
const entry2 = await seedClosedEntry(worker.id);
await ctx.http
@@ -369,11 +419,25 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
const now = new Date();
// 04:00Z = 06:00 CEST / 05:00 CET — before the 07:00 frame start either way.
const clockIn = new Date(
- Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 4, 0, 0),
+ Date.UTC(
+ now.getUTCFullYear(),
+ now.getUTCMonth(),
+ now.getUTCDate(),
+ 4,
+ 0,
+ 0,
+ ),
);
// 14:00Z = 16:00 CEST / 15:00 CET — inside the frame.
const clockOut = new Date(
- Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 14, 0, 0),
+ Date.UTC(
+ now.getUTCFullYear(),
+ now.getUTCMonth(),
+ now.getUTCDate(),
+ 14,
+ 0,
+ 0,
+ ),
);
const entry = await ctx.prisma.timeEntry.create({
data: {
@@ -386,7 +450,14 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
});
// Split at 08:00Z = 10:00 CEST / 09:00 CET — after the frame start.
const at = new Date(
- Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 8, 0, 0),
+ Date.UTC(
+ now.getUTCFullYear(),
+ now.getUTCMonth(),
+ now.getUTCDate(),
+ 8,
+ 0,
+ 0,
+ ),
).toISOString();
const res = await ctx.http
@@ -404,7 +475,9 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
const { worker } = await fixture();
const token = await login(ctx.http, worker.email);
const entry = await seedClosedEntry(worker.id, { status: 'Rejected' });
- const at = new Date(entry.clockIn.getTime() + 60 * 60 * 1000).toISOString();
+ const at = new Date(
+ entry.clockIn.getTime() + 60 * 60 * 1000,
+ ).toISOString();
const res = await ctx.http
.post(`/api/timeentries/${entry.id}/split`)
.set('Authorization', `Bearer ${token}`)
@@ -419,7 +492,9 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
const otherToken = await login(ctx.http, other.email);
const managerToken = await login(ctx.http, manager.email);
const entry = await seedClosedEntry(worker.id);
- const at = new Date(entry.clockIn.getTime() + 60 * 60 * 1000).toISOString();
+ const at = new Date(
+ entry.clockIn.getTime() + 60 * 60 * 1000,
+ ).toISOString();
await ctx.http
.post(`/api/timeentries/${entry.id}/split`)
@@ -448,12 +523,20 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
// Inactive orders are not bookable.
await ctx.http
.post('/api/timeentries/clock-in')
- .send({ employeeId: worker.id, projectId: ordered.id, serviceOrderId: inactiveOrder?.id })
+ .send({
+ employeeId: worker.id,
+ projectId: ordered.id,
+ serviceOrderId: inactiveOrder?.id,
+ })
.expect(400);
// Orders of another project are rejected.
await ctx.http
.post('/api/timeentries/clock-in')
- .send({ employeeId: worker.id, projectId: assigned.id, serviceOrderId: activeOrder?.id })
+ .send({
+ employeeId: worker.id,
+ projectId: assigned.id,
+ serviceOrderId: activeOrder?.id,
+ })
.expect(404);
// serviceOrderId without a project makes no sense.
await ctx.http
@@ -475,12 +558,37 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
expect(ok.body.activity).toBe('Designsystem überarbeitet');
});
+ it('database rejects a service order from a different project', async () => {
+ const { worker, assigned, ordered } = await fixture();
+ const activeOrder = ordered.serviceOrders.find((o) => o.isActive);
+
+ await expect(
+ seedClosedEntry(worker.id, {
+ projectId: assigned.id,
+ serviceOrderId: activeOrder?.id,
+ }),
+ ).rejects.toMatchObject({ code: 'P2003' });
+ });
+
+ it('database restricts hard deletion of employees with booked time', async () => {
+ const { worker } = await fixture();
+ await seedClosedEntry(worker.id);
+
+ await expect(
+ ctx.prisma.employee.delete({ where: { id: worker.id } }),
+ ).rejects.toMatchObject({
+ code: 'P2003',
+ });
+ });
+
it('PATCH edits the activity alone without re-validating legacy bookings', async () => {
const { worker, ordered } = await fixture();
const token = await login(ctx.http, worker.email);
// Legacy entry: booked on an ordered project WITHOUT an order (predates
// the rule). Editing only the activity must not trigger validation.
- const legacy = await seedClosedEntry(worker.id, { projectId: ordered.id });
+ const legacy = await seedClosedEntry(worker.id, {
+ projectId: ordered.id,
+ });
const res = await ctx.http
.patch(`/api/timeentries/${legacy.id}`)
@@ -496,7 +604,9 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
const { worker, ordered, assigned } = await fixture();
const token = await login(ctx.http, worker.email);
const activeOrder = ordered.serviceOrders.find((o) => o.isActive);
- const entry = await seedClosedEntry(worker.id, { projectId: assigned.id });
+ const entry = await seedClosedEntry(worker.id, {
+ projectId: assigned.id,
+ });
// Switching to the ordered project without an order → 400.
await ctx.http
@@ -536,7 +646,9 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
serviceOrderId: activeOrder?.id,
activity: 'Konzeptphase',
});
- const at = new Date(entry.clockIn.getTime() + 60 * 60 * 1000).toISOString();
+ const at = new Date(
+ entry.clockIn.getTime() + 60 * 60 * 1000,
+ ).toISOString();
const res = await ctx.http
.post(`/api/timeentries/${entry.id}/split`)
@@ -552,7 +664,9 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
const token = await login(ctx.http, worker.email);
const activeOrder = ordered.serviceOrders.find((o) => o.isActive);
const entry = await seedClosedEntry(worker.id, { activity: 'Alt' });
- const at = new Date(entry.clockIn.getTime() + 60 * 60 * 1000).toISOString();
+ const at = new Date(
+ entry.clockIn.getTime() + 60 * 60 * 1000,
+ ).toISOString();
await ctx.http
.post(`/api/timeentries/${entry.id}/split`)
@@ -562,7 +676,12 @@ describe('TimeEntries × Projects — clock-in, retroactive assignment, split',
const res = await ctx.http
.post(`/api/timeentries/${entry.id}/split`)
.set('Authorization', `Bearer ${token}`)
- .send({ at, projectId: ordered.id, serviceOrderId: activeOrder?.id, activity: 'Neu' })
+ .send({
+ at,
+ projectId: ordered.id,
+ serviceOrderId: activeOrder?.id,
+ activity: 'Neu',
+ })
.expect(201);
expect(res.body.first.activity).toBe('Alt');
expect(res.body.second.activity).toBe('Neu');
diff --git a/apps/api-e2e/src/api/vacation-workflow.e2e.spec.ts b/apps/api-e2e/src/api/vacation-workflow.e2e.spec.ts
index 35c611c..4701903 100644
--- a/apps/api-e2e/src/api/vacation-workflow.e2e.spec.ts
+++ b/apps/api-e2e/src/api/vacation-workflow.e2e.spec.ts
@@ -8,7 +8,7 @@ import {
const YEAR = new Date().getUTCFullYear();
const FROM = `${YEAR}-08-03T00:00:00.000Z`; // Mon
-const TO = `${YEAR}-08-07T00:00:00.000Z`; // Fri (5 working days)
+const TO = `${YEAR}-08-07T00:00:00.000Z`; // Fri (5 working days)
interface Cast {
hannah: { id: string; token: string };
@@ -51,7 +51,10 @@ async function setupOrgChart(ctx: TestContext): Promise {
await seedLeaveAllowance(ctx.prisma, erik.id, YEAR, 30);
return {
- hannah: { id: hannah.id, token: await login(ctx.http, 'hannah@test.local') },
+ hannah: {
+ id: hannah.id,
+ token: await login(ctx.http, 'hannah@test.local'),
+ },
marc: { id: marc.id, token: await login(ctx.http, 'marc@test.local') },
anna: { id: anna.id, token: await login(ctx.http, 'anna@test.local') },
erik: { id: erik.id, token: await login(ctx.http, 'erik@test.local') },
@@ -96,21 +99,35 @@ describe('Vacation workflow — POST /api/requests/vacation + transitions', () =
const approved = await ctx.http
.post(`/api/requests/${created.body.id}/manager-approve`)
.set('Authorization', `Bearer ${cast.marc.token}`)
- .send({ actorId: cast.marc.id, note: 'OK', requiresHrConfirmation: false })
+ .send({
+ actorId: cast.marc.id,
+ note: 'OK',
+ requiresHrConfirmation: false,
+ })
.expect(201);
- expect(approved.body).toMatchObject({ workflowState: 'Approved', status: 'Approved' });
+ expect(approved.body).toMatchObject({
+ workflowState: 'Approved',
+ status: 'Approved',
+ });
const events = await ctx.http
.get(`/api/requests/${created.body.id}/events`)
.set('Authorization', `Bearer ${cast.marc.token}`)
.expect(200);
- expect(events.body.map((e: { kind: string }) => e.kind)).toEqual(['Submitted', 'ManagerApproved']);
+ expect(events.body.map((e: { kind: string }) => e.kind)).toEqual([
+ 'Submitted',
+ 'ManagerApproved',
+ ]);
const balance = await ctx.http
.get(`/api/accounts/${cast.anna.id}/vacation`)
.set('Authorization', `Bearer ${cast.anna.token}`)
.expect(200);
- expect(balance.body).toMatchObject({ approvedDays: 5, pendingDays: 0, remainingDays: 25 });
+ expect(balance.body).toMatchObject({
+ approvedDays: 5,
+ pendingDays: 0,
+ remainingDays: 25,
+ });
});
it('with substitute: PendingSubstitute → PendingManager → PendingHr → Approved', async () => {
@@ -155,7 +172,10 @@ describe('Vacation workflow — POST /api/requests/vacation + transitions', () =
.set('Authorization', `Bearer ${cast.hannah.token}`)
.send({ actorId: cast.hannah.id, note: 'final OK' })
.expect(201);
- expect(hrConfirmed.body).toMatchObject({ workflowState: 'Approved', status: 'Approved' });
+ expect(hrConfirmed.body).toMatchObject({
+ workflowState: 'Approved',
+ status: 'Approved',
+ });
const events = await ctx.http
.get(`/api/requests/${created.body.id}/events`)
@@ -174,7 +194,12 @@ describe('Vacation workflow — POST /api/requests/vacation + transitions', () =
const r = await ctx.http
.post('/api/requests/vacation')
.set('Authorization', `Bearer ${cast.anna.token}`)
- .send({ employeeId: cast.anna.id, from: FROM, to: TO, substituteId: cast.erik.id })
+ .send({
+ employeeId: cast.anna.id,
+ from: FROM,
+ to: TO,
+ substituteId: cast.erik.id,
+ })
.expect(201);
const declined = await ctx.http
@@ -196,11 +221,42 @@ describe('Vacation workflow — POST /api/requests/vacation + transitions', () =
const res = await ctx.http
.post('/api/requests/vacation')
.set('Authorization', `Bearer ${cast.anna.token}`)
- .send({ employeeId: cast.anna.id, from: FROM, to: TO, substituteId: null })
+ .send({
+ employeeId: cast.anna.id,
+ from: FROM,
+ to: TO,
+ substituteId: null,
+ })
.expect(409);
expect(res.body.message).toMatch(/Not enough vacation/i);
});
+ it('rejects overlapping active requests for the same employee', async () => {
+ const cast = await setupOrgChart(ctx);
+ await ctx.http
+ .post('/api/requests/vacation')
+ .set('Authorization', `Bearer ${cast.anna.token}`)
+ .send({
+ employeeId: cast.anna.id,
+ from: FROM,
+ to: TO,
+ substituteId: null,
+ })
+ .expect(201);
+
+ const overlapping = await ctx.http
+ .post('/api/requests')
+ .set('Authorization', `Bearer ${cast.anna.token}`)
+ .send({
+ employeeId: cast.anna.id,
+ type: 'HomeOffice',
+ from: `${YEAR}-08-05T00:00:00.000Z`,
+ to: `${YEAR}-08-05T00:00:00.000Z`,
+ })
+ .expect(409);
+ expect(overlapping.body.message).toMatch(/overlaps an active request/i);
+ });
+
it('non-vacation TimeAdjustment routes generically (single-stage when in-frame)', async () => {
const cast = await setupOrgChart(ctx);
// 09:00 → 11:00 same day, well inside 07–23 default frame
@@ -236,7 +292,9 @@ describe('Vacation workflow — POST /api/requests/vacation + transitions', () =
// No booked time for that day yet.
const before = await ctx.http
- .get(`/api/timeentries?employeeId=${cast.anna.id}&from=${YEAR}-08-03T00:00:00.000Z&to=${YEAR}-08-03T23:59:59.000Z`)
+ .get(
+ `/api/timeentries?employeeId=${cast.anna.id}&from=${YEAR}-08-03T00:00:00.000Z&to=${YEAR}-08-03T23:59:59.000Z`,
+ )
.set('Authorization', `Bearer ${cast.anna.token}`)
.expect(200);
expect(before.body).toEqual([]);
@@ -262,7 +320,9 @@ describe('Vacation workflow — POST /api/requests/vacation + transitions', () =
// The approval must have created a closed, Approved TimeEntry that
// carries the corrected clock-in / clock-out.
const after = await ctx.http
- .get(`/api/timeentries?employeeId=${cast.anna.id}&from=${YEAR}-08-03T00:00:00.000Z&to=${YEAR}-08-03T23:59:59.000Z`)
+ .get(
+ `/api/timeentries?employeeId=${cast.anna.id}&from=${YEAR}-08-03T00:00:00.000Z&to=${YEAR}-08-03T23:59:59.000Z`,
+ )
.set('Authorization', `Bearer ${cast.anna.token}`)
.expect(200);
expect(after.body).toHaveLength(1);
diff --git a/apps/api-e2e/src/api/work-schedules.e2e.spec.ts b/apps/api-e2e/src/api/work-schedules.e2e.spec.ts
index 2fbf503..c7d1e3a 100644
--- a/apps/api-e2e/src/api/work-schedules.e2e.spec.ts
+++ b/apps/api-e2e/src/api/work-schedules.e2e.spec.ts
@@ -1,4 +1,9 @@
-import { createTestApp, login, seedEmployee, type TestContext } from '../support/test-app';
+import {
+ createTestApp,
+ login,
+ seedEmployee,
+ type TestContext,
+} from '../support/test-app';
describe('WorkSchedules — CRUD + assignment', () => {
let ctx: TestContext;
@@ -62,10 +67,10 @@ describe('WorkSchedules — CRUD + assignment', () => {
expect(created.body.coreTimes.length).toBe(2);
expect(created.body.workingDays).toBe(31);
- const list = await ctx.http
- .get('/api/work-schedules')
- .expect(200);
- expect(list.body.some((s: { id: string }) => s.id === created.body.id)).toBe(true);
+ const list = await ctx.http.get('/api/work-schedules').expect(200);
+ expect(
+ list.body.some((s: { id: string }) => s.id === created.body.id),
+ ).toBe(true);
const got = await ctx.http
.get(`/api/work-schedules/${created.body.id}`)
@@ -83,9 +88,33 @@ describe('WorkSchedules — CRUD + assignment', () => {
.delete(`/api/work-schedules/${created.body.id}`)
.set('Authorization', `Bearer ${token}`)
.expect(204);
+ await ctx.http.get(`/api/work-schedules/${created.body.id}`).expect(404);
+ });
+
+ it('rejects schedules whose frame or core windows do not increase', async () => {
+ const token = await setupHR();
await ctx.http
- .get(`/api/work-schedules/${created.body.id}`)
- .expect(404);
+ .post('/api/work-schedules')
+ .set('Authorization', `Bearer ${token}`)
+ .send({
+ ...samplePayload,
+ name: 'Bad Frame',
+ frameStart: '17:00',
+ frameEnd: '09:00',
+ })
+ .expect(400);
+
+ await ctx.http
+ .post('/api/work-schedules')
+ .set('Authorization', `Bearer ${token}`)
+ .send({
+ ...samplePayload,
+ name: 'Bad Core',
+ coreTimes: [
+ { label: 'Kernzeit', start: '11:00', end: '10:00', weekdays: 31 },
+ ],
+ })
+ .expect(400);
});
it('marking a schedule as default unsets the previous default', async () => {
@@ -125,7 +154,9 @@ describe('WorkSchedules — CRUD + assignment', () => {
.set('Authorization', `Bearer ${token}`)
.send({ employeeId: e.id })
.expect(201);
- const updatedEmployee = await ctx.prisma.employee.findUnique({ where: { id: e.id } });
+ const updatedEmployee = await ctx.prisma.employee.findUnique({
+ where: { id: e.id },
+ });
expect(updatedEmployee?.workScheduleId).toBe(created.body.id);
});
});
diff --git a/apps/api/src/app/absences/absences.service.ts b/apps/api/src/app/absences/absences.service.ts
index 08d9be7..5f1fa1e 100644
--- a/apps/api/src/app/absences/absences.service.ts
+++ b/apps/api/src/app/absences/absences.service.ts
@@ -1,4 +1,10 @@
-import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
+import {
+ BadRequestException,
+ ConflictException,
+ ForbiddenException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
import type { Absence, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { JwtUser } from '../auth/jwt.strategy';
@@ -13,7 +19,11 @@ import {
export class AbsencesService {
constructor(private readonly prisma: PrismaService) {}
- async list(employeeId: string | undefined, from?: Date, to?: Date): Promise {
+ async list(
+ employeeId: string | undefined,
+ from?: Date,
+ to?: Date,
+ ): Promise {
const where: Prisma.AbsenceWhereInput = {};
if (employeeId) where.employeeId = employeeId;
if (from || to) {
@@ -40,6 +50,7 @@ export class AbsencesService {
if (to.getTime() < from.getTime()) {
throw new BadRequestException('"to" must be on/after "from"');
}
+ await this.assertNoOverlap(dto.employeeId, from, to);
const created = await this.prisma.absence.create({
data: {
employeeId: dto.employeeId,
@@ -53,23 +64,26 @@ export class AbsencesService {
return toAbsenceDto(created);
}
- async update(actor: JwtUser, id: string, dto: UpdateAbsenceDto): Promise {
+ async update(
+ actor: JwtUser,
+ id: string,
+ dto: UpdateAbsenceDto,
+ ): Promise {
const existing = await this.assertExists(id);
await this.assertWritePermission(actor, existing.employeeId);
+ const nextFrom = dto.from ? new Date(dto.from) : existing.from;
+ const nextTo = dto.to ? new Date(dto.to) : existing.to;
+ if (nextTo.getTime() < nextFrom.getTime()) {
+ throw new BadRequestException('"to" must be on/after "from"');
+ }
+ await this.assertNoOverlap(existing.employeeId, nextFrom, nextTo, id);
+
const data: Prisma.AbsenceUpdateInput = {};
- if (dto.from) data.from = new Date(dto.from);
- if (dto.to) data.to = new Date(dto.to);
+ if (dto.from) data.from = nextFrom;
+ if (dto.to) data.to = nextTo;
if (dto.certified !== undefined) data.certified = dto.certified;
if (dto.note !== undefined) data.note = dto.note;
const next = await this.prisma.absence.update({ where: { id }, data });
- if (next.to.getTime() < next.from.getTime()) {
- // Revert and reject — Prisma allowed the update because we only set one half.
- await this.prisma.absence.update({
- where: { id },
- data: { from: existing.from, to: existing.to },
- });
- throw new BadRequestException('"to" must be on/after "from"');
- }
return toAbsenceDto(next);
}
@@ -85,7 +99,10 @@ export class AbsencesService {
return row;
}
- private async assertWritePermission(actor: JwtUser, targetEmployeeId: string): Promise {
+ private async assertWritePermission(
+ actor: JwtUser,
+ targetEmployeeId: string,
+ ): Promise {
if (actor.role === 'HRAdmin') return;
if (actor.id === targetEmployeeId) return;
if (actor.role === 'Manager') {
@@ -99,4 +116,26 @@ export class AbsencesService {
'Only the affected employee, their manager, or an HRAdmin may write this absence',
);
}
+
+ private async assertNoOverlap(
+ employeeId: string,
+ from: Date,
+ to: Date,
+ excludeId?: string,
+ ): Promise {
+ const overlap = await this.prisma.absence.findFirst({
+ where: {
+ employeeId,
+ ...(excludeId ? { NOT: { id: excludeId } } : {}),
+ from: { lte: to },
+ to: { gte: from },
+ },
+ select: { id: true },
+ });
+ if (overlap) {
+ throw new ConflictException(
+ 'Absence overlaps an existing absence for this employee',
+ );
+ }
+ }
}
diff --git a/apps/api/src/app/requests/requests.service.ts b/apps/api/src/app/requests/requests.service.ts
index f36873e..0c8e0c9 100644
--- a/apps/api/src/app/requests/requests.service.ts
+++ b/apps/api/src/app/requests/requests.service.ts
@@ -5,7 +5,13 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
-import type { Prisma, Request, RequestEventKind, RequestType, WorkflowState } from '@prisma/client';
+import type {
+ Prisma,
+ Request,
+ RequestEventKind,
+ RequestType,
+ WorkflowState,
+} from '@prisma/client';
import {
calculateVacationDays,
calculateWorkingDays,
@@ -50,10 +56,14 @@ export class RequestsService {
async list(filter: ListRequestsFilter): Promise {
const where: Prisma.RequestWhereInput = {};
if (filter.employeeId) where.employeeId = filter.employeeId;
- if (filter.status) where.status = filter.status as Prisma.RequestWhereInput['status'];
- if (filter.workflowState) where.workflowState = filter.workflowState as Prisma.RequestWhereInput['workflowState'];
+ if (filter.status)
+ where.status = filter.status as Prisma.RequestWhereInput['status'];
+ if (filter.workflowState)
+ where.workflowState =
+ filter.workflowState as Prisma.RequestWhereInput['workflowState'];
if (filter.approverId) where.approverId = filter.approverId;
- if (filter.currentApproverId) where.currentApproverId = filter.currentApproverId;
+ if (filter.currentApproverId)
+ where.currentApproverId = filter.currentApproverId;
if (filter.substituteId) where.substituteId = filter.substituteId;
const rows = await this.prisma.request.findMany({
where,
@@ -102,6 +112,7 @@ export class RequestsService {
if (to.getTime() < from.getTime()) {
throw new BadRequestException('"to" must be on/after "from"');
}
+ await this.assertNoActiveOverlap(employee.id, from, to);
const schedule = await this.schedules.resolveForEmployee(employee.id);
let requires = false;
if (dto.type === 'TimeAdjustment') {
@@ -127,7 +138,11 @@ export class RequestsService {
},
});
await tx.requestEvent.create({
- data: { requestId: request.id, kind: 'Submitted', actorId: employee.id },
+ data: {
+ requestId: request.id,
+ kind: 'Submitted',
+ actorId: employee.id,
+ },
});
return request;
});
@@ -145,7 +160,9 @@ export class RequestsService {
throw new BadRequestException('"to" must be on/after "from"');
}
if (from.getUTCFullYear() !== to.getUTCFullYear()) {
- throw new BadRequestException('Vacation must lie within a single calendar year — split into two requests');
+ throw new BadRequestException(
+ 'Vacation must lie within a single calendar year — split into two requests',
+ );
}
if (dto.substituteId && dto.substituteId === employee.id) {
throw new BadRequestException('Substitute must be a different employee');
@@ -162,8 +179,12 @@ export class RequestsService {
if (calculatedDays <= 0) {
throw new BadRequestException('Vacation range covers no working days');
}
+ await this.assertNoActiveOverlap(employee.id, from, to);
- const balance = await this.vacationBalance.compute(employee.id, from.getUTCFullYear());
+ const balance = await this.vacationBalance.compute(
+ employee.id,
+ from.getUTCFullYear(),
+ );
if (balance.remainingDays < calculatedDays) {
throw new ConflictException(
`Not enough vacation days remaining (${balance.remainingDays} < ${calculatedDays})`,
@@ -171,7 +192,9 @@ export class RequestsService {
}
const hasSubstitute = !!dto.substituteId;
- const initialState: WorkflowState = hasSubstitute ? 'PendingSubstitute' : 'PendingManager';
+ const initialState: WorkflowState = hasSubstitute
+ ? 'PendingSubstitute'
+ : 'PendingManager';
const created = await this.prisma.$transaction(async (tx) => {
const request = await tx.request.create({
@@ -191,7 +214,11 @@ export class RequestsService {
},
});
await tx.requestEvent.create({
- data: { requestId: request.id, kind: 'Submitted', actorId: employee.id },
+ data: {
+ requestId: request.id,
+ kind: 'Submitted',
+ actorId: employee.id,
+ },
});
return request;
});
@@ -201,7 +228,11 @@ export class RequestsService {
// ----- Generic approve / reject (legacy path for non-Vacation) -----
- async approve(id: string, actorId: string, note: string | null): Promise {
+ async approve(
+ id: string,
+ actorId: string,
+ note: string | null,
+ ): Promise {
const request = await this.assertRequest(id);
if (request.type === 'Vacation') {
// Vacation must use the multi-stage workflow.
@@ -211,7 +242,12 @@ export class RequestsService {
// Off-hours TimeAdjustment: manager approves the off-hours allowance,
// then HR finalises the actual time correction.
await this.assertApproverRole(actorId);
- return this.transitionVacation(request, 'manager_approve_with_hr', actorId, note);
+ return this.transitionVacation(
+ request,
+ 'manager_approve_with_hr',
+ actorId,
+ note,
+ );
}
await this.assertApproverRole(actorId);
const alreadyApproved = request.workflowState === 'Approved';
@@ -239,7 +275,11 @@ export class RequestsService {
return toRequestDto(updated);
}
- async reject(id: string, actorId: string, note: string | null): Promise {
+ async reject(
+ id: string,
+ actorId: string,
+ note: string | null,
+ ): Promise {
const request = await this.assertRequest(id);
if (request.type === 'Vacation') {
return this.transitionVacation(request, 'manager_reject', actorId, note);
@@ -281,29 +321,47 @@ export class RequestsService {
// then the actual time correction.
const forced = requiresTwoStageApproval(request);
const event: WorkflowEvent =
- requiresHrConfirmation || forced ? 'manager_approve_with_hr' : 'manager_approve';
+ requiresHrConfirmation || forced
+ ? 'manager_approve_with_hr'
+ : 'manager_approve';
return this.transitionVacation(request, event, actorId, note);
}
- async managerReject(id: string, actorId: string, note: string | null): Promise {
+ async managerReject(
+ id: string,
+ actorId: string,
+ note: string | null,
+ ): Promise {
const request = await this.assertRequest(id);
await this.assertApproverRole(actorId);
return this.transitionVacation(request, 'manager_reject', actorId, note);
}
- async hrConfirm(id: string, actorId: string, note: string | null): Promise {
+ async hrConfirm(
+ id: string,
+ actorId: string,
+ note: string | null,
+ ): Promise {
const request = await this.assertRequest(id);
await this.assertHrAdminRole(actorId);
return this.transitionVacation(request, 'hr_confirm', actorId, note);
}
- async hrReject(id: string, actorId: string, note: string): Promise {
+ async hrReject(
+ id: string,
+ actorId: string,
+ note: string,
+ ): Promise {
const request = await this.assertRequest(id);
await this.assertHrAdminRole(actorId);
return this.transitionVacation(request, 'hr_reject', actorId, note);
}
- async substituteAccept(id: string, actorId: string, note: string | null): Promise {
+ async substituteAccept(
+ id: string,
+ actorId: string,
+ note: string | null,
+ ): Promise {
const request = await this.assertRequest(id);
if (request.substituteId !== actorId) {
throw new ForbiddenException('Only the chosen substitute can accept');
@@ -311,15 +369,28 @@ export class RequestsService {
return this.transitionVacation(request, 'substitute_accept', actorId, note);
}
- async substituteDecline(id: string, actorId: string, note: string): Promise {
+ async substituteDecline(
+ id: string,
+ actorId: string,
+ note: string,
+ ): Promise {
const request = await this.assertRequest(id);
if (request.substituteId !== actorId) {
throw new ForbiddenException('Only the chosen substitute can decline');
}
- return this.transitionVacation(request, 'substitute_decline', actorId, note);
+ return this.transitionVacation(
+ request,
+ 'substitute_decline',
+ actorId,
+ note,
+ );
}
- async returnForRevision(id: string, actorId: string, note: string): Promise {
+ async returnForRevision(
+ id: string,
+ actorId: string,
+ note: string,
+ ): Promise {
const request = await this.assertRequest(id);
await this.assertApproverRole(actorId);
return this.transitionVacation(request, 'manager_return', actorId, note);
@@ -350,13 +421,35 @@ export class RequestsService {
await this.assertApproverRole(actorId);
const forced = requiresTwoStageApproval(request);
const event: WorkflowEvent =
- requiresHrConfirmation || forced ? 'manager_approve_with_hr' : 'manager_approve';
- const updated = await this.transitionVacation(request, event, actorId, note);
- out.push({ id, ok: true, workflowState: updated.workflowState, status: updated.status });
+ requiresHrConfirmation || forced
+ ? 'manager_approve_with_hr'
+ : 'manager_approve';
+ const updated = await this.transitionVacation(
+ request,
+ event,
+ actorId,
+ note,
+ );
+ out.push({
+ id,
+ ok: true,
+ workflowState: updated.workflowState,
+ status: updated.status,
+ });
} else if (request.workflowState === 'PendingHr') {
await this.assertHrAdminRole(actorId);
- const updated = await this.transitionVacation(request, 'hr_confirm', actorId, note);
- out.push({ id, ok: true, workflowState: updated.workflowState, status: updated.status });
+ const updated = await this.transitionVacation(
+ request,
+ 'hr_confirm',
+ actorId,
+ note,
+ );
+ out.push({
+ id,
+ ok: true,
+ workflowState: updated.workflowState,
+ status: updated.status,
+ });
} else {
out.push({
id,
@@ -365,7 +458,11 @@ export class RequestsService {
});
}
} catch (err) {
- out.push({ id, ok: false, error: err instanceof Error ? err.message : 'unknown error' });
+ out.push({
+ id,
+ ok: false,
+ error: err instanceof Error ? err.message : 'unknown error',
+ });
}
}
return out;
@@ -376,19 +473,43 @@ export class RequestsService {
* - `PendingManager` → manager_reject
* - `PendingHr` → hr_reject
*/
- async bulkReject(actorId: string, ids: string[], note: string): Promise {
+ async bulkReject(
+ actorId: string,
+ ids: string[],
+ note: string,
+ ): Promise {
const out: BulkResult[] = [];
for (const id of ids) {
try {
const request = await this.assertRequest(id);
if (request.workflowState === 'PendingManager') {
await this.assertApproverRole(actorId);
- const updated = await this.transitionVacation(request, 'manager_reject', actorId, note);
- out.push({ id, ok: true, workflowState: updated.workflowState, status: updated.status });
+ const updated = await this.transitionVacation(
+ request,
+ 'manager_reject',
+ actorId,
+ note,
+ );
+ out.push({
+ id,
+ ok: true,
+ workflowState: updated.workflowState,
+ status: updated.status,
+ });
} else if (request.workflowState === 'PendingHr') {
await this.assertHrAdminRole(actorId);
- const updated = await this.transitionVacation(request, 'hr_reject', actorId, note);
- out.push({ id, ok: true, workflowState: updated.workflowState, status: updated.status });
+ const updated = await this.transitionVacation(
+ request,
+ 'hr_reject',
+ actorId,
+ note,
+ );
+ out.push({
+ id,
+ ok: true,
+ workflowState: updated.workflowState,
+ status: updated.status,
+ });
} else {
out.push({
id,
@@ -397,19 +518,29 @@ export class RequestsService {
});
}
} catch (err) {
- out.push({ id, ok: false, error: err instanceof Error ? err.message : 'unknown error' });
+ out.push({
+ id,
+ ok: false,
+ error: err instanceof Error ? err.message : 'unknown error',
+ });
}
}
return out;
}
- async cancel(id: string, actorId: string, note: string | null): Promise {
+ async cancel(
+ id: string,
+ actorId: string,
+ note: string | null,
+ ): Promise {
const request = await this.assertRequest(id);
if (request.employeeId !== actorId) {
// HR/Manager may also cancel — check role.
const actor = await this.employees.getById(actorId);
if (actor.role !== 'Manager' && actor.role !== 'HRAdmin') {
- throw new ForbiddenException('Only the requester or a Manager/HRAdmin may cancel');
+ throw new ForbiddenException(
+ 'Only the requester or a Manager/HRAdmin may cancel',
+ );
}
}
return this.transitionVacation(request, 'cancel', actorId, note);
@@ -452,7 +583,7 @@ export class RequestsService {
data.currentApprover = { disconnect: true };
}
if (event === 'manager_approve' || event === 'manager_approve_with_hr') {
- data.approverId = actorId;
+ data.approver = { connect: { id: actorId } };
data.decidedAt = event === 'manager_approve' ? new Date() : null;
data.decisionNote = event === 'manager_approve' ? note : null;
if (event === 'manager_approve_with_hr') {
@@ -462,7 +593,7 @@ export class RequestsService {
}
}
if (event === 'manager_reject' || event === 'manager_return') {
- data.approverId = actorId;
+ data.approver = { connect: { id: actorId } };
data.decidedAt = event === 'manager_reject' ? new Date() : null;
data.decisionNote = note;
data.currentApprover = { disconnect: true };
@@ -527,7 +658,9 @@ export class RequestsService {
private async assertApproverRole(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');
+ throw new ForbiddenException(
+ 'Only Manager or HRAdmin may approve/reject',
+ );
}
}
@@ -537,6 +670,27 @@ export class RequestsService {
throw new ForbiddenException('Only HRAdmin may HR-confirm/-reject');
}
}
+
+ private async assertNoActiveOverlap(
+ employeeId: string,
+ from: Date,
+ to: Date,
+ ): Promise {
+ const overlap = await this.prisma.request.findFirst({
+ where: {
+ employeeId,
+ status: { notIn: ['Rejected', 'Cancelled'] },
+ from: { lte: to },
+ to: { gte: from },
+ },
+ select: { id: true },
+ });
+ if (overlap) {
+ throw new ConflictException(
+ 'Request overlaps an active request for this employee',
+ );
+ }
+ }
}
function requiresTwoStageApproval(request: Request): boolean {
@@ -548,15 +702,24 @@ function requiresTwoStageApproval(request: Request): boolean {
function mapEventToKind(event: WorkflowEvent): RequestEventKind {
switch (event) {
- case 'submit': return 'Submitted';
- case 'substitute_accept': return 'SubstituteAccepted';
- case 'substitute_decline': return 'SubstituteDeclined';
+ case 'submit':
+ return 'Submitted';
+ case 'substitute_accept':
+ return 'SubstituteAccepted';
+ case 'substitute_decline':
+ return 'SubstituteDeclined';
case 'manager_approve':
- case 'manager_approve_with_hr': return 'ManagerApproved';
- case 'manager_reject': return 'ManagerRejected';
- case 'manager_return': return 'Returned';
- case 'hr_confirm': return 'HrConfirmed';
- case 'hr_reject': return 'HrRejected';
- case 'cancel': return 'Cancelled';
+ case 'manager_approve_with_hr':
+ return 'ManagerApproved';
+ case 'manager_reject':
+ return 'ManagerRejected';
+ case 'manager_return':
+ return 'Returned';
+ case 'hr_confirm':
+ return 'HrConfirmed';
+ case 'hr_reject':
+ return 'HrRejected';
+ case 'cancel':
+ return 'Cancelled';
}
}
diff --git a/apps/api/src/app/work-schedules/work-schedules.service.ts b/apps/api/src/app/work-schedules/work-schedules.service.ts
index 9e60f8d..63f2c86 100644
--- a/apps/api/src/app/work-schedules/work-schedules.service.ts
+++ b/apps/api/src/app/work-schedules/work-schedules.service.ts
@@ -1,5 +1,14 @@
-import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
-import type { TimeModel, WorkSchedule, WorkScheduleCoreTime } from '@prisma/client';
+import {
+ BadRequestException,
+ ConflictException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import type {
+ TimeModel,
+ WorkSchedule,
+ WorkScheduleCoreTime,
+} from '@prisma/client';
import {
BUNDESLAENDER,
DEFAULT_FRAME,
@@ -39,7 +48,12 @@ function parseHm(value: string): { hour: number; minute: number } {
export function toFrameRule(start: string, end: string): FrameTimeRule {
const s = parseHm(start);
const e = parseHm(end);
- return { startHour: s.hour, startMinute: s.minute, endHour: e.hour, endMinute: e.minute };
+ return {
+ startHour: s.hour,
+ startMinute: s.minute,
+ endHour: e.hour,
+ endMinute: e.minute,
+ };
}
export function toCoreWindow(row: WorkScheduleCoreTime): CoreTimeWindow {
@@ -72,11 +86,14 @@ export class WorkSchedulesService {
async getById(id: string): Promise {
const row = await this.findOrThrow(id);
- const count = await this.prisma.employee.count({ where: { workScheduleId: id } });
+ const count = await this.prisma.employee.count({
+ where: { workScheduleId: id },
+ });
return toScheduleResponse(row, count);
}
async create(dto: UpsertWorkScheduleDto): Promise {
+ assertValidScheduleTimes(dto);
return this.prisma.$transaction(async (tx) => {
if (dto.isDefault) {
await tx.workSchedule.updateMany({
@@ -107,15 +124,21 @@ export class WorkSchedulesService {
return toScheduleResponse(created, 0);
} catch (err) {
if (isUniqueViolation(err)) {
- throw new ConflictException(`A schedule named "${dto.name}" already exists`);
+ throw new ConflictException(
+ `A schedule named "${dto.name}" already exists`,
+ );
}
throw err;
}
});
}
- async update(id: string, dto: UpsertWorkScheduleDto): Promise {
+ async update(
+ id: string,
+ dto: UpsertWorkScheduleDto,
+ ): Promise {
await this.findOrThrow(id);
+ assertValidScheduleTimes(dto);
return this.prisma.$transaction(async (tx) => {
if (dto.isDefault) {
await tx.workSchedule.updateMany({
@@ -145,11 +168,15 @@ export class WorkSchedulesService {
},
include: { coreTimes: { orderBy: { start: 'asc' } } },
});
- const count = await tx.employee.count({ where: { workScheduleId: id } });
+ const count = await tx.employee.count({
+ where: { workScheduleId: id },
+ });
return toScheduleResponse(updated, count);
} catch (err) {
if (isUniqueViolation(err)) {
- throw new ConflictException(`A schedule named "${dto.name}" already exists`);
+ throw new ConflictException(
+ `A schedule named "${dto.name}" already exists`,
+ );
}
throw err;
}
@@ -161,10 +188,16 @@ export class WorkSchedulesService {
await this.prisma.workSchedule.delete({ where: { id } });
}
- async assignToEmployee(scheduleId: string, employeeId: string): Promise {
+ async assignToEmployee(
+ scheduleId: string,
+ employeeId: string,
+ ): Promise {
await this.findOrThrow(scheduleId);
- 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`);
await this.prisma.employee.update({
where: { id: employeeId },
data: { workScheduleId: scheduleId },
@@ -172,8 +205,11 @@ export class WorkSchedulesService {
}
async unassignFromEmployee(employeeId: string): Promise {
- 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`);
await this.prisma.employee.update({
where: { id: employeeId },
data: { workScheduleId: null },
@@ -216,10 +252,11 @@ export class WorkSchedulesService {
workSchedule: { include: { coreTimes: { orderBy: { start: 'asc' } } } },
},
});
- if (!employee) throw new NotFoundException(`Employee ${employeeId} not found`);
- const bundesland: Bundesland = (BUNDESLAENDER as readonly string[]).includes(
- employee.bundesland,
- )
+ if (!employee)
+ throw new NotFoundException(`Employee ${employeeId} not found`);
+ const bundesland: Bundesland = (
+ BUNDESLAENDER as readonly string[]
+ ).includes(employee.bundesland)
? (employee.bundesland as Bundesland)
: 'NW';
const holidayProvider = holidayProviderFor(bundesland);
@@ -270,5 +307,22 @@ function isUniqueViolation(err: unknown): boolean {
);
}
+function minutes(value: string): number {
+ const [hour, minute] = value.split(':').map(Number);
+ return hour * 60 + minute;
+}
+
+function assertValidScheduleTimes(dto: UpsertWorkScheduleDto): void {
+ if (minutes(dto.frameStart) >= minutes(dto.frameEnd)) {
+ throw new BadRequestException('frameStart must be before frameEnd');
+ }
+ const invalidCore = dto.coreTimes.find(
+ (c) => minutes(c.start) >= minutes(c.end),
+ );
+ if (invalidCore) {
+ throw new BadRequestException('core time start must be before end');
+ }
+}
+
// Re-export for downstream services that need the Prisma row → domain conversions.
export type { WorkSchedule };
diff --git a/prisma/migrations/20260618120000_er_model_integrity_constraints/migration.sql b/prisma/migrations/20260618120000_er_model_integrity_constraints/migration.sql
new file mode 100644
index 0000000..ba182ec
--- /dev/null
+++ b/prisma/migrations/20260618120000_er_model_integrity_constraints/migration.sql
@@ -0,0 +1,154 @@
+-- Strengthen ER-model integrity around approvals, booking targets, schedules,
+-- overlaps, and historical employee data.
+
+CREATE EXTENSION IF NOT EXISTS btree_gist;
+
+-- Existing databases may have been populated before these invariants existed.
+UPDATE "Request" r
+SET "approverId" = NULL
+WHERE "approverId" IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM "Employee" e WHERE e."id" = r."approverId"
+ );
+
+UPDATE "TimeEntry" te
+SET "serviceOrderId" = NULL
+WHERE te."serviceOrderId" IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1
+ FROM "ServiceOrder" so
+ WHERE so."id" = te."serviceOrderId"
+ AND so."projectId" = te."projectId"
+ );
+
+WITH ranked_defaults AS (
+ SELECT
+ "id",
+ row_number() OVER (ORDER BY "updatedAt" DESC, "createdAt" DESC, "id") AS rn
+ FROM "WorkSchedule"
+ WHERE "isDefault" = true
+)
+UPDATE "WorkSchedule" ws
+SET "isDefault" = false
+FROM ranked_defaults rd
+WHERE ws."id" = rd."id"
+ AND rd.rn > 1;
+
+-- Prisma model alignment: these are still strings at the application boundary,
+-- but PostgreSQL now stores the intended fixed HH:mm shape.
+ALTER TABLE "WorkSchedule"
+ ALTER COLUMN "frameStart" TYPE VARCHAR(5),
+ ALTER COLUMN "frameEnd" TYPE VARCHAR(5);
+
+ALTER TABLE "WorkScheduleCoreTime"
+ ALTER COLUMN "start" TYPE VARCHAR(5),
+ ALTER COLUMN "end" TYPE VARCHAR(5);
+
+-- Request.approverId is the final manager/HR decision actor.
+CREATE INDEX "Request_approverId_idx" ON "Request"("approverId");
+ALTER TABLE "Request"
+ ADD CONSTRAINT "Request_approverId_fkey"
+ FOREIGN KEY ("approverId") REFERENCES "Employee"("id")
+ ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- A service order must belong to the same project as the time entry.
+ALTER TABLE "TimeEntry" DROP CONSTRAINT "TimeEntry_serviceOrderId_fkey";
+CREATE UNIQUE INDEX "ServiceOrder_projectId_id_key" ON "ServiceOrder"("projectId", "id");
+ALTER TABLE "TimeEntry"
+ ADD CONSTRAINT "TimeEntry_serviceOrder_requires_project_chk"
+ CHECK ("serviceOrderId" IS NULL OR "projectId" IS NOT NULL),
+ ADD CONSTRAINT "TimeEntry_projectId_serviceOrderId_fkey"
+ FOREIGN KEY ("projectId", "serviceOrderId") REFERENCES "ServiceOrder"("projectId", "id")
+ ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- Times, ordering, and bitmasks.
+ALTER TABLE "WorkSchedule"
+ ADD CONSTRAINT "WorkSchedule_frameStart_hhmm_chk"
+ CHECK ("frameStart" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'),
+ ADD CONSTRAINT "WorkSchedule_frameEnd_hhmm_chk"
+ CHECK ("frameEnd" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'),
+ ADD CONSTRAINT "WorkSchedule_frame_order_chk"
+ CHECK ("frameStart" < "frameEnd"),
+ ADD CONSTRAINT "WorkSchedule_workingDays_range_chk"
+ CHECK ("workingDays" BETWEEN 0 AND 127);
+
+ALTER TABLE "WorkScheduleCoreTime"
+ ADD CONSTRAINT "WorkScheduleCoreTime_start_hhmm_chk"
+ CHECK ("start" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'),
+ ADD CONSTRAINT "WorkScheduleCoreTime_end_hhmm_chk"
+ CHECK ("end" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'),
+ ADD CONSTRAINT "WorkScheduleCoreTime_order_chk"
+ CHECK ("start" < "end"),
+ ADD CONSTRAINT "WorkScheduleCoreTime_weekdays_range_chk"
+ CHECK ("weekdays" BETWEEN 0 AND 127);
+
+ALTER TABLE "TimeEntry"
+ ADD CONSTRAINT "TimeEntry_clock_order_chk"
+ CHECK ("clockOut" IS NULL OR "clockOut" > "clockIn");
+
+ALTER TABLE "Absence"
+ ADD CONSTRAINT "Absence_date_order_chk"
+ CHECK ("to" >= "from");
+
+ALTER TABLE "Request"
+ ADD CONSTRAINT "Request_period_order_chk"
+ CHECK ("to" >= "from");
+
+-- Exactly one persisted default schedule at most. The built-in fallback still
+-- applies if no row is flagged as default.
+CREATE UNIQUE INDEX "WorkSchedule_single_default_idx"
+ ON "WorkSchedule"("isDefault")
+ WHERE "isDefault" = true;
+
+-- No overlapping closed, non-rejected attendance intervals per employee.
+CREATE UNIQUE INDEX "TimeEntry_one_open_per_employee_idx"
+ ON "TimeEntry"("employeeId")
+ WHERE "clockOut" IS NULL;
+
+ALTER TABLE "TimeEntry"
+ ADD CONSTRAINT "TimeEntry_no_employee_overlap_excl"
+ EXCLUDE USING gist (
+ "employeeId" WITH =,
+ tsrange("clockIn", "clockOut", '[)') WITH &&
+ )
+ WHERE ("clockOut" IS NOT NULL AND "status" <> 'Rejected');
+
+-- Absence rows are date ranges with an inclusive user-facing end date.
+ALTER TABLE "Absence"
+ ADD CONSTRAINT "Absence_no_employee_overlap_excl"
+ EXCLUDE USING gist (
+ "employeeId" WITH =,
+ daterange("from", "to" + 1, '[)') WITH &&
+ );
+
+-- Productive employee data is deactivated, not hard-deleted. Restrict deletion
+-- once historical/movement rows exist.
+ALTER TABLE "TimeEntry" DROP CONSTRAINT "TimeEntry_employeeId_fkey";
+ALTER TABLE "TimeEntry"
+ ADD CONSTRAINT "TimeEntry_employeeId_fkey"
+ FOREIGN KEY ("employeeId") REFERENCES "Employee"("id")
+ ON DELETE RESTRICT ON UPDATE CASCADE;
+
+ALTER TABLE "Request" DROP CONSTRAINT "Request_employeeId_fkey";
+ALTER TABLE "Request"
+ ADD CONSTRAINT "Request_employeeId_fkey"
+ FOREIGN KEY ("employeeId") REFERENCES "Employee"("id")
+ ON DELETE RESTRICT ON UPDATE CASCADE;
+
+ALTER TABLE "Absence" DROP CONSTRAINT "Absence_employeeId_fkey";
+ALTER TABLE "Absence"
+ ADD CONSTRAINT "Absence_employeeId_fkey"
+ FOREIGN KEY ("employeeId") REFERENCES "Employee"("id")
+ ON DELETE RESTRICT ON UPDATE CASCADE;
+
+ALTER TABLE "ProjectAssignment" DROP CONSTRAINT "ProjectAssignment_employeeId_fkey";
+ALTER TABLE "ProjectAssignment"
+ ADD CONSTRAINT "ProjectAssignment_employeeId_fkey"
+ FOREIGN KEY ("employeeId") REFERENCES "Employee"("id")
+ ON DELETE RESTRICT ON UPDATE CASCADE;
+
+ALTER TABLE "EmployeeLeaveAllowance" DROP CONSTRAINT "EmployeeLeaveAllowance_employeeId_fkey";
+ALTER TABLE "EmployeeLeaveAllowance"
+ ADD CONSTRAINT "EmployeeLeaveAllowance_employeeId_fkey"
+ FOREIGN KEY ("employeeId") REFERENCES "Employee"("id")
+ ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 60798ec..9ef4c5a 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -82,9 +82,6 @@ enum RequestEventKind {
Cancelled
}
-// Absences are recorded facts (Krankheit / Schulung / Gleittage etc.), not
-// approval workflows. The enum is open so additional kinds can be added in
-// future without changing the model.
enum ThemePreference {
Light
Dark
@@ -92,6 +89,8 @@ enum ThemePreference {
}
enum AbsenceKind {
+ // Absence kinds are intentionally limited. Adding kinds requires a migration
+ // so API/UI/reporting stay explicit.
Sickness
Training
Flextime
@@ -127,6 +126,7 @@ model Employee {
timeEntries TimeEntry[]
requests Request[] @relation("RequestEmployee")
approverRequests Request[] @relation("RequestApprover")
+ decidedRequests Request[] @relation("RequestDecider")
substituteRequests Request[] @relation("RequestSubstitute")
leaveAllowances EmployeeLeaveAllowance[]
events RequestEvent[] @relation("EventActor")
@@ -143,14 +143,14 @@ model Employee {
// Frame and core working hours. A schedule is assigned per Employee
// (Employee.workScheduleId). Multiple core-time windows per schedule are
-// supported (e.g. 10:00–11:00 and 14:00–15:00). Times are stored as
-// "HH:mm" strings; weekdays as a 7-bit mask (Mon=1, Tue=2, ..., Sun=64).
+// supported (e.g. 10:00–11:00 and 14:00–15:00). Times are stored as checked
+// "HH:mm" strings; weekdays as a checked 7-bit mask (Mon=1, Tue=2, ..., Sun=64).
model WorkSchedule {
id String @id @default(uuid()) @db.Uuid
name String @unique
description String?
- frameStart String // "HH:mm" — start of permitted booking window
- frameEnd String // "HH:mm" — end of permitted booking window
+ frameStart String @db.VarChar(5) // "HH:mm" — start of permitted booking window
+ frameEnd String @db.VarChar(5) // "HH:mm" — end of permitted booking window
isDefault Boolean @default(false)
/// Working-days bitmask (Mon=1, Tue=2, ..., Sun=64). Default 31 = Mo–Fr.
/// Days outside the mask are non-working for Soll/Vacation accounting.
@@ -166,8 +166,8 @@ model WorkScheduleCoreTime {
scheduleId String @db.Uuid
schedule WorkSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
label String?
- start String // "HH:mm"
- end String // "HH:mm"
+ start String @db.VarChar(5) // "HH:mm"
+ end String @db.VarChar(5) // "HH:mm"
weekdays Int @default(31) // bitmask Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64
@@index([scheduleId])
@@ -176,7 +176,7 @@ model WorkScheduleCoreTime {
model TimeEntry {
id String @id @default(uuid()) @db.Uuid
employeeId String @db.Uuid
- employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
+ employee Employee @relation(fields: [employeeId], references: [id], onDelete: Restrict)
clockIn DateTime
clockOut DateTime?
source EntrySource @default(Manual)
@@ -189,7 +189,7 @@ model TimeEntry {
projectId String? @db.Uuid
project Project? @relation(fields: [projectId], references: [id], onDelete: Restrict)
serviceOrderId String? @db.Uuid
- serviceOrder ServiceOrder? @relation(fields: [serviceOrderId], references: [id], onDelete: Restrict)
+ serviceOrder ServiceOrder? @relation(fields: [projectId, serviceOrderId], references: [projectId, id], onDelete: Restrict)
/// Customer-facing description of the work performed (Tätigkeit).
activity String?
createdAt DateTime @default(now())
@@ -204,7 +204,7 @@ model TimeEntry {
model Request {
id String @id @default(uuid()) @db.Uuid
employeeId String @db.Uuid
- employee Employee @relation("RequestEmployee", fields: [employeeId], references: [id], onDelete: Cascade)
+ employee Employee @relation("RequestEmployee", fields: [employeeId], references: [id], onDelete: Restrict)
type RequestType
status RequestStatus @default(Submitted)
workflowState WorkflowState @default(Submitted)
@@ -221,6 +221,7 @@ model Request {
currentApproverId String? @db.Uuid
currentApprover Employee? @relation("RequestApprover", fields: [currentApproverId], references: [id], onDelete: SetNull)
approverId String? @db.Uuid
+ approver Employee? @relation("RequestDecider", fields: [approverId], references: [id], onDelete: SetNull)
hrConfirmedAt DateTime?
cancelledAt DateTime?
decidedAt DateTime?
@@ -232,6 +233,7 @@ model Request {
@@index([employeeId, workflowState])
@@index([currentApproverId])
+ @@index([approverId])
@@index([substituteId])
}
@@ -270,7 +272,7 @@ model RequestEvent {
model Absence {
id String @id @default(uuid()) @db.Uuid
employeeId String @db.Uuid
- employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
+ employee Employee @relation(fields: [employeeId], references: [id], onDelete: Restrict)
kind AbsenceKind @default(Sickness)
from DateTime @db.Date
to DateTime @db.Date
@@ -321,6 +323,7 @@ model ServiceOrder {
updatedAt DateTime @updatedAt
@@unique([projectId, orderNo])
+ @@unique([projectId, id])
@@index([projectId])
}
@@ -330,7 +333,7 @@ model ServiceOrder {
model ProjectAssignment {
id String @id @default(uuid()) @db.Uuid
employeeId String @db.Uuid
- employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
+ employee Employee @relation(fields: [employeeId], references: [id], onDelete: Restrict)
projectId String @db.Uuid
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@ -342,7 +345,7 @@ model ProjectAssignment {
model EmployeeLeaveAllowance {
id String @id @default(uuid()) @db.Uuid
employeeId String @db.Uuid
- employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
+ employee Employee @relation(fields: [employeeId], references: [id], onDelete: Restrict)
year Int
baseDays Decimal @db.Decimal(5, 2)
carryOverDays Decimal @default(0) @db.Decimal(5, 2)