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)