diff --git a/apps/api-e2e/src/api/request-revision.e2e.spec.ts b/apps/api-e2e/src/api/request-revision.e2e.spec.ts new file mode 100644 index 0000000..177ea93 --- /dev/null +++ b/apps/api-e2e/src/api/request-revision.e2e.spec.ts @@ -0,0 +1,452 @@ +import { EmployeesService } from '../../../api/src/app/employees/employees.service'; +import { + createTestApp, + login, + seedEmployee, + seedLeaveAllowance, + type TestContext, +} from '../support/test-app'; + +const YEAR = 2026; +const FROM = '2026-05-04T00:00:00.000Z'; +const TO = '2026-05-08T00:00:00.000Z'; + +describe('Returned requests — owner revision and resubmission (#42)', () => { + let ctx: TestContext; + let owner: { id: string; token: string }; + let manager: { id: string; token: string }; + let other: { id: string; token: string }; + let hr: { id: string; token: string }; + + beforeAll(async () => { + ctx = await createTestApp(); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.reset(); + async function person( + personalNo: string, + role: 'Employee' | 'Manager' | 'HRAdmin', + managerId?: string, + ) { + const e = await seedEmployee(ctx.prisma, { + personalNo, + firstName: personalNo, + lastName: 'Example', + email: `${personalNo}@test.local`, + role, + managerId, + }); + return { id: e.id, token: await login(ctx.http, e.email) }; + } + manager = await person('manager', 'Manager'); + owner = await person('owner', 'Employee', manager.id); + other = await person('other', 'Employee', manager.id); + hr = await person('hr', 'HRAdmin'); + await seedLeaveAllowance(ctx.prisma, owner.id, YEAR, 5); + }); + + async function create(type = 'Vacation', substituteId?: string) { + const r = await ctx.http + .post(type === 'Vacation' ? '/api/requests/vacation' : '/api/requests') + .set('Authorization', `Bearer ${owner.token}`) + .send({ + employeeId: owner.id, + type, + from: FROM, + to: TO, + substituteId, + reason: 'Original', + }) + .expect(201); + return r.body; + } + async function returned(type = 'Vacation') { + const r = await create(type); + const response = await ctx.http + .post(`/api/requests/${r.id}/return`) + .set('Authorization', `Bearer ${manager.token}`) + .send({ actorId: manager.id, note: 'Bitte kürzen' }) + .expect(201); + return response.body; + } + function values(r: { updatedAt: string }, extra = {}) { + return { + from: FROM, + to: TO, + reason: 'Korrigiert', + expectedUpdatedAt: r.updatedAt, + ...extra, + }; + } + function patch(id: string, token = owner.token) { + return ctx.http + .patch(`/api/requests/${id}`) + .set('Authorization', `Bearer ${token}`); + } + function submit(id: string, token = owner.token) { + return ctx.http + .post(`/api/requests/${id}/resubmit`) + .set('Authorization', `Bearer ${token}`); + } + + it('manager return → save → resubmit retains ID/history, excludes self, recalculates halves and resets assignments', async () => { + const r = await returned(); + expect(r).toMatchObject({ + workflowState: 'Draft', + status: 'Submitted', + employeeName: 'owner Example', + decisionNote: 'Bitte kürzen', + }); + const saved = await patch(r.id) + .send(values(r, { halfDayStart: true })) + .expect(200); + expect(saved.body).toMatchObject({ + id: r.id, + workflowState: 'Draft', + currentApproverId: null, + approverId: null, + calculatedDays: 4.5, + }); + expect(saved.body.updatedAt).not.toBe(r.updatedAt); + // Old decision data cannot leak into the new approval cycle. + await ctx.prisma.request.update({ + where: { id: r.id }, + data: { + approverId: manager.id, + hrConfirmedAt: new Date(), + decidedAt: new Date(), + cancelledAt: new Date(), + substituteAcceptedAt: new Date(), + }, + }); + const fresh = await ctx.http + .get(`/api/requests/${r.id}`) + .set('Authorization', `Bearer ${owner.token}`) + .expect(200); + const result = await submit(r.id) + .send(values(fresh.body, { halfDayStart: true })) + .expect(201); + expect(result.body).toMatchObject({ + id: r.id, + workflowState: 'PendingManager', + currentApproverId: manager.id, + calculatedDays: 4.5, + approverId: null, + substituteAcceptedAt: null, + hrConfirmedAt: null, + cancelledAt: null, + decidedAt: null, + decisionNote: null, + }); + expect(await ctx.prisma.request.count()).toBe(1); + const events = await ctx.prisma.requestEvent.findMany({ + where: { requestId: r.id }, + orderBy: { occurredAt: 'asc' }, + }); + expect(events.map((e) => e.kind)).toEqual([ + 'Submitted', + 'Returned', + 'Resubmitted', + ]); + expect(events[1].note).toBe('Bitte kürzen'); + const listed = await ctx.http + .get(`/api/requests?currentApproverId=${manager.id}`) + .set('Authorization', `Bearer ${manager.token}`) + .expect(200); + expect(listed.body[0].employeeName).toBe('owner Example'); + }); + + it('initializes a missing allowance inside the request transaction without blocking itself', async () => { + await ctx.prisma.employeeLeaveAllowance.deleteMany(); + const r = await create(); + expect(r.calculatedDays).toBe(5); + expect(await ctx.prisma.employeeLeaveAllowance.count()).toBe(1); + }); + + it('substitute decline → edit → resubmit restarts substitute approval and preserves the decline reason', async () => { + const r = await create('Vacation', other.id); + const declined = await ctx.http + .post(`/api/requests/${r.id}/substitute/decline`) + .set('Authorization', `Bearer ${other.token}`) + .send({ actorId: other.id, note: 'Bin selbst weg' }) + .expect(201); + expect(declined.body).toMatchObject({ + workflowState: 'Draft', + decisionNote: 'Bin selbst weg', + }); + const result = await submit(r.id) + .send(values(declined.body, { substituteId: other.id, halfDayEnd: true })) + .expect(201); + expect(result.body).toMatchObject({ + id: r.id, + workflowState: 'PendingSubstitute', + currentApproverId: null, + substituteId: other.id, + substituteAcceptedAt: null, + calculatedDays: 4.5, + }); + await ctx.http + .post(`/api/requests/${r.id}/substitute/accept`) + .set('Authorization', `Bearer ${other.token}`) + .send({ actorId: other.id }) + .expect(201); + const events = await ctx.prisma.requestEvent.findMany({ + where: { requestId: r.id }, + orderBy: { occurredAt: 'asc' }, + }); + expect(events.map((e) => e.kind)).toEqual([ + 'Submitted', + 'SubstituteDeclined', + 'Resubmitted', + 'SubstituteAccepted', + ]); + }); + + it('forbids other employees, manager and HR; ignores spoofed owner identity', async () => { + const r = await returned(); + for (const actor of [other, manager, hr]) { + await patch(r.id, actor.token) + .send(values(r, { employeeId: owner.id })) + .expect(403); + await submit(r.id, actor.token) + .send(values(r, { employeeId: owner.id })) + .expect(403); + } + await ctx.http.patch(`/api/requests/${r.id}`).send(values(r)).expect(401); + expect(await ctx.prisma.requestEvent.count()).toBe(2); + }); + + it.each([ + 'Submitted', + 'PendingSubstitute', + 'PendingManager', + 'PendingHr', + 'Approved', + 'Rejected', + 'Cancelled', + ] as const)( + 'refuses editing/resubmitting %s even with legacy status Submitted', + async (state) => { + const r = await returned(); + const current = await ctx.prisma.request.update({ + where: { id: r.id }, + data: { workflowState: state, status: 'Submitted' }, + }); + await patch(r.id) + .send(values({ updatedAt: current.updatedAt.toISOString() })) + .expect(409); + await submit(r.id) + .send(values({ updatedAt: current.updatedAt.toISOString() })) + .expect(409); + }, + ); + + it('rechecks vacation balance on both save and resubmit; does not spend the allowance twice', async () => { + const r = await returned(); + await seedLeaveAllowance(ctx.prisma, owner.id, YEAR, 0.5); + await patch(r.id).send(values(r)).expect(409); + await submit(r.id).send(values(r)).expect(409); + const result = await submit(r.id) + .send(values(r, { to: FROM, halfDayStart: true })) + .expect(201); + expect(result.body.calculatedDays).toBe(0.5); + }); + + it('excludes only its own overlap reservation, not another draft/active request', async () => { + const r = await returned(); + await ctx.prisma.request.create({ + data: { + employeeId: owner.id, + type: 'HomeOffice', + from: new Date(FROM), + to: new Date(FROM), + workflowState: 'Draft', + status: 'Submitted', + }, + }); + await patch(r.id).send(values(r)).expect(409); + await submit(r.id).send(values(r)).expect(409); + expect( + (await ctx.prisma.request.findUniqueOrThrow({ where: { id: r.id } })) + .workflowState, + ).toBe('Draft'); + }); + + it('revalidates dates, zero workdays, substitute and required version', async () => { + const r = await returned(); + for (const extra of [ + { from: TO, to: FROM }, + { to: '2027-01-01T00:00:00.000Z' }, + { from: '2026-05-09T00:00:00.000Z', to: '2026-05-10T00:00:00.000Z' }, + { substituteId: owner.id }, + { substituteId: '00000000-0000-0000-0000-000000000001' }, + { from: 'invalid' }, + { expectedUpdatedAt: undefined }, + ]) { + await submit(r.id).send(values(r, extra)).expect(400); + } + await ctx.prisma.employee.update({ + where: { id: other.id }, + data: { isActive: false }, + }); + await submit(r.id) + .send(values(r, { substituteId: other.id })) + .expect(400); + }); + + it('preserves SpecialLeave attachments and immutable type/owner', async () => { + const r = await returned('SpecialLeave'); + const attachment = await ctx.prisma.requestAttachment.create({ + data: { + requestId: r.id, + fileName: 'synthetic.pdf', + mimeType: 'application/pdf', + sizeBytes: 10, + storageKey: 'synthetic-only', + uploadedById: owner.id, + }, + }); + const result = await submit(r.id) + .send(values(r, { type: 'Vacation', employeeId: other.id })) + .expect(201); + expect(result.body).toMatchObject({ + id: r.id, + type: 'SpecialLeave', + employeeId: owner.id, + }); + expect( + await ctx.prisma.requestAttachment.findUnique({ + where: { id: attachment.id }, + }), + ).toMatchObject({ requestId: r.id, storageKey: 'synthetic-only' }); + }); + + it('revalidates TimeAdjustment overlap and off-hours approval', async () => { + const r = await returned('TimeAdjustment'); + const from = '2026-05-04T04:00:00.000Z'; // before the frame in UTC/Berlin + const to = '2026-05-04T08:00:00.000Z'; + await ctx.prisma.timeEntry.create({ + data: { + employeeId: owner.id, + clockIn: new Date(from), + clockOut: new Date(to), + source: 'Manual', + status: 'Approved', + }, + }); + await submit(r.id).send(values(r, { from, to })).expect(409); + await ctx.prisma.timeEntry.deleteMany(); + await submit(r.id) + .send(values(r, { from, to: from })) + .expect(400); + const result = await submit(r.id).send(values(r, { from, to })).expect(201); + expect(result.body.requiresApproval).toBe(true); + }); + + it('rejects stale edits and simultaneous resubmissions with no duplicate event', async () => { + const r = await returned(); + const changed = await patch(r.id) + .send(values(r, { reason: 'First tab' })) + .expect(200); + await patch(r.id) + .send(values(r, { reason: 'Stale tab' })) + .expect(409); + await submit(r.id).send(values(r)).expect(409); + const results = await Promise.all([ + submit(r.id).send(values(changed.body)), + submit(r.id).send(values(changed.body)), + ]); + expect(results.map((res) => res.status).sort()).toEqual([201, 409]); + expect( + await ctx.prisma.requestEvent.count({ + where: { requestId: r.id, kind: 'Resubmitted' }, + }), + ).toBe(1); + }); + + it('does not let an in-flight decision overwrite a returned and resubmitted version (ABA)', async () => { + const r = await create(); + const employees = ctx.app.get(EmployeesService); + const original = employees.getById.bind(employees); + let release!: () => void; + let arrived!: () => void; + const waiting = new Promise((resolve) => { + arrived = resolve; + }); + const barrier = new Promise((resolve) => { + release = resolve; + }); + let delayOnce = true; + const spy = jest + .spyOn(employees, 'getById') + .mockImplementation(async (id) => { + const employee = await original(id); + if (id === manager.id && delayOnce) { + delayOnce = false; + arrived(); + await barrier; + } + return employee; + }); + const oldDecision = ctx.http + .post(`/api/requests/${r.id}/manager-approve`) + .set('Authorization', `Bearer ${manager.token}`) + .send({ actorId: manager.id }) + .then((res) => res); + try { + await waiting; + const revised = await ctx.http + .post(`/api/requests/${r.id}/return`) + .set('Authorization', `Bearer ${hr.token}`) + .send({ actorId: hr.id, note: 'Neue Angaben nötig' }) + .expect(201); + await submit(r.id).send(values(revised.body)).expect(201); + release(); + expect((await oldDecision).status).toBe(409); + expect( + (await ctx.prisma.request.findUniqueOrThrow({ where: { id: r.id } })) + .workflowState, + ).toBe('PendingManager'); + expect( + await ctx.prisma.requestEvent.count({ + where: { requestId: r.id, kind: 'ManagerApproved' }, + }), + ).toBe(0); + } finally { + release(); + spy.mockRestore(); + } + }); + + it('serializes competing drafts so parallel submissions cannot overspend vacation', async () => { + const a = await returned(); + const b = await ctx.prisma.request.create({ + data: { + employeeId: owner.id, + type: 'Vacation', + from: new Date('2026-05-11'), + to: new Date('2026-05-15'), + workflowState: 'Draft', + calculatedDays: 5, + }, + }); + const results = await Promise.all([ + submit(a.id).send(values(a)), + submit(b.id).send( + values( + { updatedAt: b.updatedAt.toISOString() }, + { from: b.from.toISOString(), to: b.to.toISOString() }, + ), + ), + ]); + expect(results.map((res) => res.status).sort()).toEqual([201, 409]); + expect( + await ctx.prisma.request.count({ + where: { workflowState: 'PendingManager' }, + }), + ).toBe(1); + }); +}); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 1a60945..960390a 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -3394,7 +3394,17 @@ ], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RequestDto" + } + } + } + } } }, "security": [ @@ -3419,7 +3429,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3445,7 +3462,55 @@ ], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["requests"] + }, + "patch": { + "operationId": "RequestsController_updateDraft", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDraftRequestDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3498,7 +3563,57 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["requests"] + } + }, + "/api/requests/{id}/resubmit": { + "post": { + "operationId": "RequestsController_resubmit", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDraftRequestDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3534,7 +3649,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3570,7 +3692,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3606,7 +3735,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3642,7 +3778,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3678,7 +3821,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3714,7 +3864,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3750,7 +3907,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3786,7 +3950,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3822,7 +3993,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3858,7 +4036,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -7656,6 +7841,128 @@ }, "required": ["baseDays", "carryOverDays", "adjustmentDays"] }, + "RequestDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "employeeId": { + "type": "string", + "format": "uuid" + }, + "employeeName": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "type": { + "type": "string", + "enum": ["Vacation", "HomeOffice", "SpecialLeave", "TimeAdjustment"] + }, + "status": { + "type": "string" + }, + "workflowState": { + "type": "string" + }, + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + }, + "reason": { + "type": "string", + "nullable": true + }, + "requiresApproval": { + "type": "boolean" + }, + "approverId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "currentApproverId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "substituteId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "substituteAcceptedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "hrConfirmedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "cancelledAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "calculatedDays": { + "type": "number" + }, + "halfDayStart": { + "type": "boolean" + }, + "halfDayEnd": { + "type": "boolean" + }, + "decidedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "decisionNote": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "employeeId", + "employeeName", + "updatedAt", + "type", + "status", + "workflowState", + "from", + "to", + "reason", + "requiresApproval", + "approverId", + "currentApproverId", + "substituteId", + "substituteAcceptedAt", + "hrConfirmedAt", + "cancelledAt", + "calculatedDays", + "halfDayStart", + "halfDayEnd", + "decidedAt", + "decisionNote", + "createdAt" + ] + }, "CreateRequestDto": { "type": "object", "properties": { @@ -7678,7 +7985,7 @@ "example": "2026-08-07T00:00:00.000Z" }, "reason": { - "type": "object", + "type": "string", "nullable": true, "maxLength": 2000 } @@ -7701,12 +8008,12 @@ "format": "date-time" }, "substituteId": { - "type": "object", + "type": "string", "nullable": true, "format": "uuid" }, "reason": { - "type": "object", + "type": "string", "nullable": true, "maxLength": 2000 }, @@ -7723,6 +8030,45 @@ }, "required": ["employeeId", "from", "to"] }, + "UpdateDraftRequestDto": { + "type": "object", + "properties": { + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + }, + "substituteId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "reason": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "halfDayStart": { + "type": "boolean", + "default": false, + "description": "Take the first day as a half-day" + }, + "halfDayEnd": { + "type": "boolean", + "default": false, + "description": "Take the last day as a half-day" + }, + "expectedUpdatedAt": { + "type": "string", + "format": "date-time", + "description": "updatedAt from the version being edited; stale writes return 409" + } + }, + "required": ["from", "to", "expectedUpdatedAt"] + }, "TransitionDto": { "type": "object", "properties": { @@ -7731,7 +8077,7 @@ "format": "uuid" }, "note": { - "type": "object", + "type": "string", "nullable": true, "maxLength": 2000 } @@ -7746,7 +8092,7 @@ "format": "uuid" }, "note": { - "type": "object", + "type": "string", "nullable": true, "maxLength": 2000 }, @@ -7786,7 +8132,7 @@ } }, "note": { - "type": "object", + "type": "string", "nullable": true, "maxLength": 2000 }, diff --git a/apps/api/src/app/accounts/vacation-balance.service.ts b/apps/api/src/app/accounts/vacation-balance.service.ts index 61bc2b7..d50b4fb 100644 --- a/apps/api/src/app/accounts/vacation-balance.service.ts +++ b/apps/api/src/app/accounts/vacation-balance.service.ts @@ -1,3 +1,4 @@ +import type { Prisma } from '@prisma/client'; import { Injectable } from '@nestjs/common'; import { calculateWorkingDays } from 'shared'; import { LeaveAllowancesService } from '../leave-allowances/leave-allowances.service'; @@ -20,16 +21,22 @@ export class VacationBalanceService { private readonly schedules: WorkSchedulesService, ) {} - async compute(employeeId: string, year: number): Promise { - const allowance = await this.allowances.getOrDefault(employeeId, year); + async compute( + employeeId: string, + year: number, + excludeRequestId?: string, + db: Prisma.TransactionClient = this.prisma, + ): Promise { + const allowance = await this.allowances.getOrDefault(employeeId, year, db); const schedule = await this.schedules.resolveForEmployee(employeeId); const yearStart = new Date(Date.UTC(year, 0, 1)); const yearEnd = new Date(Date.UTC(year, 11, 31, 23, 59, 59)); - const requests = await this.prisma.request.findMany({ + const requests = await db.request.findMany({ where: { employeeId, type: 'Vacation', + ...(excludeRequestId ? { id: { not: excludeRequestId } } : {}), from: { lte: yearEnd }, to: { gte: yearStart }, }, diff --git a/apps/api/src/app/leave-allowances/leave-allowances.service.ts b/apps/api/src/app/leave-allowances/leave-allowances.service.ts index 76c1f78..4c4046c 100644 --- a/apps/api/src/app/leave-allowances/leave-allowances.service.ts +++ b/apps/api/src/app/leave-allowances/leave-allowances.service.ts @@ -1,5 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import type { EmployeeLeaveAllowance } from '@prisma/client'; +import type { EmployeeLeaveAllowance, Prisma } from '@prisma/client'; import type { JwtUser } from '../auth/jwt.strategy'; import { EmployeesService } from '../employees/employees.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -29,13 +29,14 @@ export class LeaveAllowancesService { async getOrDefault( employeeId: string, year: number, + db: Prisma.TransactionClient = this.prisma, ): Promise { - const existing = await this.prisma.employeeLeaveAllowance.findUnique({ + const existing = await db.employeeLeaveAllowance.findUnique({ where: { employeeId_year: { employeeId, year } }, }); if (existing) return existing; - const employee = await this.assertEmployee(employeeId); - return this.prisma.employeeLeaveAllowance.create({ + const employee = await this.assertEmployee(employeeId, db); + return db.employeeLeaveAllowance.create({ data: { employeeId, year, @@ -113,8 +114,11 @@ export class LeaveAllowancesService { return { scanned: candidates.length, expired }; } - private async assertEmployee(employeeId: string) { - const employee = await this.prisma.employee.findUnique({ + private async assertEmployee( + employeeId: string, + db: Prisma.TransactionClient = this.prisma, + ) { + const employee = await db.employee.findUnique({ where: { id: employeeId }, }); if (!employee) diff --git a/apps/api/src/app/requests/requests.controller.ts b/apps/api/src/app/requests/requests.controller.ts index ab41a63..40c6910 100644 --- a/apps/api/src/app/requests/requests.controller.ts +++ b/apps/api/src/app/requests/requests.controller.ts @@ -3,12 +3,18 @@ import { Controller, Get, Param, + Patch, ParseUUIDPipe, Post, Query, UseGuards, } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiCreatedResponse, + ApiOkResponse, + ApiTags, +} from '@nestjs/swagger'; import { CurrentUser } from '../auth/current-user.decorator'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import type { JwtUser } from '../auth/jwt.strategy'; @@ -20,9 +26,10 @@ import { CreateVacationDto, ManagerApproveDto, TransitionDto, + UpdateDraftRequestDto, TransitionWithRequiredNoteDto, type BulkResult, - type RequestDto, + RequestDto, type RequestEventDto, } from './requests.dto'; @@ -34,6 +41,7 @@ export class RequestsController { constructor(private readonly service: RequestsService) {} @Get() + @ApiOkResponse({ type: RequestDto, isArray: true }) list( @CurrentUser() user: JwtUser, @Query('employeeId') employeeId?: string, @@ -57,6 +65,7 @@ export class RequestsController { } @Get(':id') + @ApiOkResponse({ type: RequestDto }) get( @Param('id', new ParseUUIDPipe()) id: string, @CurrentUser() user: JwtUser, @@ -73,6 +82,7 @@ export class RequestsController { } @Post() + @ApiCreatedResponse({ type: RequestDto }) create( @Body() dto: CreateRequestDto, @CurrentUser() user: JwtUser, @@ -81,6 +91,7 @@ export class RequestsController { } @Post('vacation') + @ApiCreatedResponse({ type: RequestDto }) createVacation( @Body() dto: CreateVacationDto, @CurrentUser() user: JwtUser, @@ -88,9 +99,30 @@ export class RequestsController { return this.service.createVacation({ ...dto, employeeId: user.id }); } + @Patch(':id') + @ApiOkResponse({ type: RequestDto }) + updateDraft( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: UpdateDraftRequestDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.updateDraft(id, user.id, dto, false); + } + + @Post(':id/resubmit') + @ApiCreatedResponse({ type: RequestDto }) + resubmit( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: UpdateDraftRequestDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.updateDraft(id, user.id, dto, true); + } + // ----- Generic approve / reject (legacy convenience) ----- @Post(':id/approve') + @ApiCreatedResponse({ type: RequestDto }) approve( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto, @@ -100,6 +132,7 @@ export class RequestsController { } @Post(':id/reject') + @ApiCreatedResponse({ type: RequestDto }) reject( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto, @@ -111,6 +144,7 @@ export class RequestsController { // ----- Vacation workflow ----- @Post(':id/manager-approve') + @ApiCreatedResponse({ type: RequestDto }) managerApprove( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: ManagerApproveDto, @@ -125,6 +159,7 @@ export class RequestsController { } @Post(':id/manager-reject') + @ApiCreatedResponse({ type: RequestDto }) managerReject( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto, @@ -134,6 +169,7 @@ export class RequestsController { } @Post(':id/hr-confirm') + @ApiCreatedResponse({ type: RequestDto }) hrConfirm( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto, @@ -143,6 +179,7 @@ export class RequestsController { } @Post(':id/hr-reject') + @ApiCreatedResponse({ type: RequestDto }) hrReject( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionWithRequiredNoteDto, @@ -152,6 +189,7 @@ export class RequestsController { } @Post(':id/substitute/accept') + @ApiCreatedResponse({ type: RequestDto }) substituteAccept( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto, @@ -161,6 +199,7 @@ export class RequestsController { } @Post(':id/substitute/decline') + @ApiCreatedResponse({ type: RequestDto }) substituteDecline( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionWithRequiredNoteDto, @@ -170,6 +209,7 @@ export class RequestsController { } @Post(':id/return') + @ApiCreatedResponse({ type: RequestDto }) returnForRevision( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionWithRequiredNoteDto, @@ -179,6 +219,7 @@ export class RequestsController { } @Post(':id/cancel') + @ApiCreatedResponse({ type: RequestDto }) cancel( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto, diff --git a/apps/api/src/app/requests/requests.dto.ts b/apps/api/src/app/requests/requests.dto.ts index 40e78f5..546ff70 100644 --- a/apps/api/src/app/requests/requests.dto.ts +++ b/apps/api/src/app/requests/requests.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger'; import { ArrayNotEmpty, IsArray, @@ -12,7 +12,12 @@ import { } from 'class-validator'; import type { Request } from '@prisma/client'; -const REQUEST_TYPES = ['Vacation', 'HomeOffice', 'SpecialLeave', 'TimeAdjustment'] as const; +const REQUEST_TYPES = [ + 'Vacation', + 'HomeOffice', + 'SpecialLeave', + 'TimeAdjustment', +] as const; export class CreateRequestDto { @ApiProperty({ format: 'uuid' }) @@ -31,7 +36,7 @@ export class CreateRequestDto { @IsISO8601() to!: string; - @ApiPropertyOptional({ nullable: true, maxLength: 2000 }) + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 }) @IsOptional() @IsString() @MaxLength(2000) @@ -51,34 +56,53 @@ export class CreateVacationDto { @IsISO8601() to!: string; - @ApiPropertyOptional({ nullable: true, format: 'uuid' }) + @ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' }) @IsOptional() @IsUUID() substituteId?: string | null; - @ApiPropertyOptional({ nullable: true, maxLength: 2000 }) + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 }) @IsOptional() @IsString() @MaxLength(2000) reason?: string | null; - @ApiPropertyOptional({ default: false, description: 'Take the first day as a half-day' }) + @ApiPropertyOptional({ + default: false, + description: 'Take the first day as a half-day', + }) @IsOptional() @IsBoolean() halfDayStart?: boolean; - @ApiPropertyOptional({ default: false, description: 'Take the last day as a half-day' }) + @ApiPropertyOptional({ + default: false, + description: 'Take the last day as a half-day', + }) @IsOptional() @IsBoolean() halfDayEnd?: boolean; } +/** Complete editable values. Request type and owner are immutable. */ +export class UpdateDraftRequestDto extends OmitType(CreateVacationDto, [ + 'employeeId', +] as const) { + @ApiProperty({ + format: 'date-time', + description: + 'updatedAt from the version being edited; stale writes return 409', + }) + @IsISO8601() + expectedUpdatedAt!: string; +} + export class TransitionDto { @ApiProperty({ format: 'uuid' }) @IsUUID() actorId!: string; - @ApiPropertyOptional({ nullable: true, maxLength: 2000 }) + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 }) @IsOptional() @IsString() @MaxLength(2000) @@ -114,7 +138,7 @@ export class BulkApproveDto { @IsUUID('all', { each: true }) ids!: string[]; - @ApiPropertyOptional({ nullable: true, maxLength: 2000 }) + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 }) @IsOptional() @IsString() @MaxLength(2000) @@ -151,34 +175,63 @@ export interface BulkResult { error?: string; } -export interface RequestDto { - id: string; - employeeId: string; - type: string; - status: string; - workflowState: string; - from: string; - to: string; - reason: string | null; - requiresApproval: boolean; - approverId: string | null; - currentApproverId: string | null; - substituteId: string | null; - substituteAcceptedAt: string | null; - hrConfirmedAt: string | null; - cancelledAt: string | null; - calculatedDays: number; - halfDayStart: boolean; - halfDayEnd: boolean; - decidedAt: string | null; - decisionNote: string | null; - createdAt: string; +export class RequestDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + @ApiProperty({ format: 'uuid' }) + employeeId!: string; + @ApiProperty() + employeeName!: string; + @ApiProperty({ format: 'date-time' }) + updatedAt!: string; + @ApiProperty({ enum: REQUEST_TYPES }) + type!: string; + @ApiProperty() + status!: string; + @ApiProperty() + workflowState!: string; + @ApiProperty({ format: 'date-time' }) + from!: string; + @ApiProperty({ format: 'date-time' }) + to!: string; + @ApiProperty({ type: String, nullable: true }) + reason!: string | null; + @ApiProperty() + requiresApproval!: boolean; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + approverId!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + currentApproverId!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + substituteId!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + substituteAcceptedAt!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + hrConfirmedAt!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + cancelledAt!: string | null; + @ApiProperty() + calculatedDays!: number; + @ApiProperty() + halfDayStart!: boolean; + @ApiProperty() + halfDayEnd!: boolean; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + decidedAt!: string | null; + @ApiProperty({ type: String, nullable: true }) + decisionNote!: string | null; + @ApiProperty({ format: 'date-time' }) + createdAt!: string; } -export function toRequestDto(r: Request): RequestDto { +export function toRequestDto( + r: Request & { employee: { firstName: string; lastName: string } }, +): RequestDto { return { id: r.id, employeeId: r.employeeId, + employeeName: `${r.employee.firstName} ${r.employee.lastName}`, + updatedAt: r.updatedAt.toISOString(), type: r.type, status: r.status, workflowState: r.workflowState, @@ -189,7 +242,9 @@ export function toRequestDto(r: Request): RequestDto { approverId: r.approverId, currentApproverId: r.currentApproverId, substituteId: r.substituteId, - substituteAcceptedAt: r.substituteAcceptedAt ? r.substituteAcceptedAt.toISOString() : null, + substituteAcceptedAt: r.substituteAcceptedAt + ? r.substituteAcceptedAt.toISOString() + : null, hrConfirmedAt: r.hrConfirmedAt ? r.hrConfirmedAt.toISOString() : null, cancelledAt: r.cancelledAt ? r.cancelledAt.toISOString() : null, calculatedDays: Number(r.calculatedDays), diff --git a/apps/api/src/app/requests/requests.service.ts b/apps/api/src/app/requests/requests.service.ts index 95e562c..2a19d43 100644 --- a/apps/api/src/app/requests/requests.service.ts +++ b/apps/api/src/app/requests/requests.service.ts @@ -10,7 +10,6 @@ import type { Request, RequestEventKind, RequestType, - WorkflowState, } from '@prisma/client'; import { calculateVacationDays, @@ -32,6 +31,7 @@ import { type CreateVacationDto, type RequestDto, type RequestEventDto, + type UpdateDraftRequestDto, } from './requests.dto'; export interface ListRequestsFilter { @@ -43,6 +43,13 @@ export interface ListRequestsFilter { substituteId?: string; } +const employeeInclude = { + employee: { select: { firstName: true, lastName: true } }, +} as const; +type RequestWithEmployee = Prisma.RequestGetPayload<{ + include: typeof employeeInclude; +}>; + @Injectable() export class RequestsService { constructor( @@ -72,6 +79,7 @@ export class RequestsService { where: { AND: [where, await this.visibilityWhere(actorId)] }, orderBy: { createdAt: 'desc' }, take: 200, + include: employeeInclude, }); return rows.map(toRequestDto); } @@ -99,143 +107,234 @@ export class RequestsService { })); } - // ----- Generic create (HomeOffice, SpecialLeave, TimeAdjustment, Vacation-as-fallback) ----- - + // Creation and revision deliberately share validation. Serializing per employee + // keeps simultaneous resubmissions/creations from spending the same balance or + // passing the same overlap check. No request is deleted/recreated on revision. async create(dto: CreateRequestDto): Promise { - if (dto.type === 'Vacation') { - // Vacation has its own validated path with leave balance check. - return this.createVacation({ - employeeId: dto.employeeId, - from: dto.from, - to: dto.to, - substituteId: null, - reason: dto.reason ?? null, - }); - } - const employee = await this.employees.getById(dto.employeeId); - const from = new Date(dto.from); - const to = new Date(dto.to); - 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') { - if (to.getTime() <= from.getTime()) { - throw new BadRequestException( - 'TimeAdjustment "to" must be after "from"', - ); - } - await this.assertNoTimeEntryOverlap(employee.id, from, to); - requires = requiresSpecialApproval(from, to, schedule.frame); - } - const calculatedDays = calculateWorkingDays(from, to, { - workingDays: schedule.workingDays, - holidayProvider: schedule.holidayProvider, - }); + return this.createValidated(dto.type, dto); + } + + async createVacation(dto: CreateVacationDto): Promise { + return this.createValidated('Vacation', dto); + } + + private async lockEmployee( + tx: Prisma.TransactionClient, + employeeId: string, + ): Promise { + await tx.$queryRaw`SELECT "id" FROM "Employee" WHERE "id" = ${employeeId}::uuid FOR NO KEY UPDATE`; + } + + private async createValidated( + type: RequestType, + dto: CreateVacationDto, + ): Promise { const created = await this.prisma.$transaction(async (tx) => { + await this.lockEmployee(tx, dto.employeeId); + const values = await this.validateValues(type, dto, tx); const request = await tx.request.create({ data: { - employeeId: employee.id, - type: dto.type as RequestType, + ...values, + employeeId: dto.employeeId, + type, status: 'Submitted', - workflowState: 'PendingManager', - from, - to, - reason: dto.reason ?? null, - requiresApproval: requires, - calculatedDays, - currentApproverId: employee.managerId, + workflowState: values.substituteId + ? 'PendingSubstitute' + : 'PendingManager', }, + include: employeeInclude, }); await tx.requestEvent.create({ data: { requestId: request.id, kind: 'Submitted', - actorId: employee.id, + actorId: dto.employeeId, }, }); return request; }); - this.notifications.notifyTransitioned(created, 'Submitted', employee.id); + this.notifications.notifyTransitioned(created, 'Submitted', dto.employeeId); return toRequestDto(created); } - // ----- Vacation creation with balance check ----- + async updateDraft( + id: string, + actorId: string, + dto: UpdateDraftRequestDto, + resubmit: boolean, + ): Promise { + const updated = await this.prisma.$transaction(async (tx) => { + await this.lockEmployee(tx, actorId); + const request = await tx.request.findUnique({ where: { id } }); + if (!request) throw new NotFoundException(`Request ${id} not found`); + if (request.employeeId !== actorId) { + throw new ForbiddenException('Only the requester may edit or resubmit'); + } + if ( + request.workflowState !== 'Draft' || + request.updatedAt.toISOString() !== + new Date(dto.expectedUpdatedAt).toISOString() + ) { + throw new ConflictException( + 'Request changed or is no longer a draft; reload before editing', + ); + } + const values = await this.validateValues( + request.type, + { ...dto, employeeId: actorId }, + tx, + id, + ); + const data: Prisma.RequestUncheckedUpdateInput = { + ...values, + // A saved draft is not assigned until it is actually submitted. + currentApproverId: resubmit ? values.currentApproverId : null, + workflowState: resubmit + ? nextState('Draft', 'submit', { + hasSubstitute: !!values.substituteId, + }) + : 'Draft', + status: 'Submitted', // legacy display status; workflowState gates writes + approverId: null, + substituteAcceptedAt: null, + hrConfirmedAt: null, + cancelledAt: null, + decidedAt: null, + // Keep the revision reason visible until submission; history always keeps it. + decisionNote: resubmit ? null : request.decisionNote, + }; + const saved = await this.updateVersion(tx, request, data); + if (resubmit) { + await tx.requestEvent.create({ + data: { requestId: id, kind: 'Resubmitted', actorId }, + }); + } + return saved; + }); + if (resubmit) + this.notifications.notifyTransitioned(updated, 'Resubmitted', actorId); + return toRequestDto(updated); + } - async createVacation(dto: CreateVacationDto): Promise { + private async validateValues( + type: RequestType, + dto: CreateVacationDto, + tx: Prisma.TransactionClient, + excludeRequestId?: string, + ) { const employee = await this.employees.getById(dto.employeeId); const from = new Date(dto.from); const to = new Date(dto.to); - if (to.getTime() < from.getTime()) { - throw new BadRequestException('"to" must be on/after "from"'); + if ( + !Number.isFinite(from.getTime()) || + !Number.isFinite(to.getTime()) || + to < from + ) { + throw new BadRequestException('"to" must be on/after a valid "from"'); } - if (from.getUTCFullYear() !== to.getUTCFullYear()) { + const vacation = type === 'Vacation'; + if (vacation && from.getUTCFullYear() !== to.getUTCFullYear()) { 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'); + if (!vacation && (dto.substituteId || dto.halfDayStart || dto.halfDayEnd)) { + throw new BadRequestException( + 'Substitute and half-days are only supported for Vacation', + ); + } + const substituteId = vacation ? (dto.substituteId ?? null) : null; + if (substituteId) { + const substitute = await tx.employee.findUnique({ + where: { id: substituteId }, + }); + if (substituteId === employee.id || !substitute?.isActive) { + throw new BadRequestException( + 'Substitute must be a different active employee', + ); + } } const schedule = await this.schedules.resolveForEmployee(employee.id); - const halfDayStart = !!dto.halfDayStart; - const halfDayEnd = !!dto.halfDayEnd; - const calculatedDays = calculateVacationDays(from, to, { + const halfDayStart = vacation && !!dto.halfDayStart; + const halfDayEnd = vacation && !!dto.halfDayEnd; + const options = { workingDays: schedule.workingDays, holidayProvider: schedule.holidayProvider, halfDayStart, halfDayEnd, - }); - if (calculatedDays <= 0) { + }; + const calculatedDays = vacation + ? calculateVacationDays(from, to, options) + : calculateWorkingDays(from, to, options); + if (vacation && calculatedDays <= 0) throw new BadRequestException('Vacation range covers no working days'); - } - await this.assertNoActiveOverlap(employee.id, from, to); - - const balance = await this.vacationBalance.compute( + await this.assertNoActiveOverlap( employee.id, - from.getUTCFullYear(), + from, + to, + tx, + excludeRequestId, ); - if (balance.remainingDays < calculatedDays) { - throw new ConflictException( - `Not enough vacation days remaining (${balance.remainingDays} < ${calculatedDays})`, + let requiresApproval = false; + if (type === 'TimeAdjustment') { + if (to <= from) + throw new BadRequestException( + 'TimeAdjustment "to" must be after "from"', + ); + await this.assertNoTimeEntryOverlap(employee.id, from, to, tx); + requiresApproval = requiresSpecialApproval(from, to, schedule.frame); + } + if (vacation) { + const balance = await this.vacationBalance.compute( + employee.id, + from.getUTCFullYear(), + excludeRequestId, + tx, ); + if (balance.remainingDays < calculatedDays) { + throw new ConflictException( + `Not enough vacation days remaining (${balance.remainingDays} < ${calculatedDays})`, + ); + } } + return { + from, + to, + reason: dto.reason ?? null, + calculatedDays, + halfDayStart, + halfDayEnd, + substituteId, + requiresApproval, + currentApproverId: substituteId ? null : employee.managerId, + }; + } - const hasSubstitute = !!dto.substituteId; - const initialState: WorkflowState = hasSubstitute - ? 'PendingSubstitute' - : 'PendingManager'; - - const created = await this.prisma.$transaction(async (tx) => { - const request = await tx.request.create({ - data: { - employeeId: employee.id, - type: 'Vacation', - status: 'Submitted', - workflowState: initialState, - from, - to, - reason: dto.reason ?? null, - calculatedDays, - halfDayStart, - halfDayEnd, - substituteId: dto.substituteId ?? null, - currentApproverId: hasSubstitute ? null : employee.managerId, - }, - }); - await tx.requestEvent.create({ - data: { - requestId: request.id, - kind: 'Submitted', - actorId: employee.id, - }, - }); - return request; + /** Compare-and-swap protects transitions too, including Draft → pending → Draft ABA races. */ + private async updateVersion( + tx: Prisma.TransactionClient, + request: Request, + data: Prisma.RequestUncheckedUpdateInput, + ): Promise { + const changed = await tx.request.updateMany({ + where: { + id: request.id, + workflowState: request.workflowState, + updatedAt: request.updatedAt, + }, + data: { + ...data, + updatedAt: new Date( + Math.max(Date.now(), request.updatedAt.getTime() + 1), + ), + }, + }); + if (changed.count !== 1) + throw new ConflictException('Request changed; reload before continuing'); + return tx.request.findUniqueOrThrow({ + where: { id: request.id }, + include: employeeInclude, }); - this.notifications.notifyTransitioned(created, 'Submitted', employee.id); - return toRequestDto(created); } // ----- Generic approve / reject (legacy path for non-Vacation) ----- @@ -263,23 +362,21 @@ export class RequestsService { ); } await this.assertApprover(request, actorId); - const alreadyApproved = request.workflowState === 'Approved'; + if (request.workflowState !== 'PendingManager') + throw new ConflictException('Request is not pending manager approval'); const updated = await this.prisma.$transaction(async (tx) => { - const updated = await tx.request.update({ - where: { id: request.id }, - data: { - status: 'Approved', - workflowState: 'Approved', - approverId: actorId, - currentApproverId: null, - decidedAt: new Date(), - decisionNote: note, - }, + const updated = await this.updateVersion(tx, request, { + status: 'Approved', + workflowState: 'Approved', + approverId: actorId, + currentApproverId: null, + decidedAt: new Date(), + decisionNote: note, }); await tx.requestEvent.create({ data: { requestId: id, kind: 'ManagerApproved', actorId, note }, }); - if (request.type === 'TimeAdjustment' && !alreadyApproved) { + if (request.type === 'TimeAdjustment') { await this.materializeTimeAdjustment(tx, request); } return updated; @@ -299,17 +396,16 @@ export class RequestsService { return this.transitionVacation(request, 'manager_reject', actorId, note); } await this.assertApprover(request, actorId); + if (request.workflowState !== 'PendingManager') + throw new ConflictException('Request is not pending manager approval'); const updated = await this.prisma.$transaction(async (tx) => { - const updated = await tx.request.update({ - where: { id: request.id }, - data: { - status: 'Rejected', - workflowState: 'Rejected', - approverId: actorId, - currentApproverId: null, - decidedAt: new Date(), - decisionNote: note, - }, + const updated = await this.updateVersion(tx, request, { + status: 'Rejected', + workflowState: 'Rejected', + approverId: actorId, + currentApproverId: null, + decidedAt: new Date(), + decisionNote: note, }); await tx.requestEvent.create({ data: { requestId: id, kind: 'ManagerRejected', actorId, note }, @@ -578,7 +674,7 @@ export class RequestsService { }); const status = deriveStatus(target); - const data: Prisma.RequestUpdateInput = { + const data: Prisma.RequestUncheckedUpdateInput = { workflowState: target, status, }; @@ -588,48 +684,47 @@ export class RequestsService { // Side effects per transition. if (event === 'submit') { - // No-op here — submit is done at create time. + // Creation and resubmission have their own validated paths. } if (event === 'substitute_accept') { data.substituteAcceptedAt = new Date(); // Move approver to manager. const employee = await this.employees.getById(employeeId); - data.currentApprover = employee.managerId - ? { connect: { id: employee.managerId } } - : { disconnect: true }; + data.currentApproverId = employee.managerId; } if (event === 'substitute_decline') { - data.currentApprover = { disconnect: true }; + data.decisionNote = note; + data.currentApproverId = null; } if (event === 'manager_approve' || event === 'manager_approve_with_hr') { - data.approver = { connect: { id: actorId } }; + data.approverId = actorId; data.decidedAt = event === 'manager_approve' ? new Date() : null; data.decisionNote = event === 'manager_approve' ? note : null; if (event === 'manager_approve_with_hr') { - data.currentApprover = { disconnect: true }; + data.currentApproverId = null; } else { - data.currentApprover = { disconnect: true }; + data.currentApproverId = null; } } if (event === 'manager_reject' || event === 'manager_return') { - data.approver = { connect: { id: actorId } }; + data.approverId = actorId; data.decidedAt = event === 'manager_reject' ? new Date() : null; data.decisionNote = note; - data.currentApprover = { disconnect: true }; + data.currentApproverId = null; } if (event === 'hr_confirm' || event === 'hr_reject') { data.hrConfirmedAt = event === 'hr_confirm' ? new Date() : null; data.decidedAt = new Date(); data.decisionNote = note; - data.currentApprover = { disconnect: true }; + data.currentApproverId = null; } if (event === 'cancel') { data.cancelledAt = new Date(); - data.currentApprover = { disconnect: true }; + data.currentApproverId = null; } const updated = await this.prisma.$transaction(async (tx) => { - const u = await tx.request.update({ where: { id: request.id }, data }); + const u = await this.updateVersion(tx, request, data); await tx.requestEvent.create({ data: { requestId: request.id, kind: eventKind, actorId, note }, }); @@ -672,8 +767,11 @@ export class RequestsService { }); } - private async assertRequest(id: string): Promise { - const request = await this.prisma.request.findUnique({ where: { id } }); + private async assertRequest(id: string): Promise { + const request = await this.prisma.request.findUnique({ + where: { id }, + include: employeeInclude, + }); if (!request) throw new NotFoundException(`Request ${id} not found`); return request; } @@ -737,11 +835,14 @@ export class RequestsService { employeeId: string, from: Date, to: Date, + db: Prisma.TransactionClient = this.prisma, + excludeRequestId?: string, ): Promise { - const overlap = await this.prisma.request.findFirst({ + const overlap = await db.request.findFirst({ where: { employeeId, status: { notIn: ['Rejected', 'Cancelled'] }, + ...(excludeRequestId ? { id: { not: excludeRequestId } } : {}), from: { lte: to }, to: { gte: from }, }, @@ -762,8 +863,9 @@ export class RequestsService { employeeId: string, from: Date, to: Date, + db: Prisma.TransactionClient = this.prisma, ): Promise { - const overlap = await this.prisma.timeEntry.findFirst({ + const overlap = await db.timeEntry.findFirst({ where: { employeeId, status: { not: 'Rejected' }, diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 037c1a3..d47a9e0 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -367,6 +367,8 @@ export interface AccountDto { export interface RequestDto { id: string; employeeId: string; + employeeName: string; + updatedAt: string; type: RequestType; status: RequestStatus; workflowState: WorkflowState; @@ -546,6 +548,13 @@ export interface CreateVacationPayload { halfDayEnd?: boolean; } +export interface UpdateDraftRequestPayload extends Omit< + CreateVacationPayload, + 'employeeId' +> { + expectedUpdatedAt: string; +} + export interface UpsertLeaveAllowancePayload { baseDays: number; carryOverDays: number; @@ -1055,6 +1064,16 @@ export const api = { method: 'POST', body: JSON.stringify(payload), }), + updateDraftRequest: (id: string, payload: UpdateDraftRequestPayload) => + request(`/api/requests/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }), + resubmitRequest: (id: string, payload: UpdateDraftRequestPayload) => + request(`/api/requests/${id}/resubmit`, { + method: 'POST', + body: JSON.stringify(payload), + }), approveRequest: (id: string, actorId: string, note?: string) => request(`/api/requests/${id}/approve`, { method: 'POST', diff --git a/apps/web/src/api/generated.ts b/apps/web/src/api/generated.ts index 4f7538e..41d8884 100644 --- a/apps/web/src/api/generated.ts +++ b/apps/web/src/api/generated.ts @@ -1281,7 +1281,7 @@ export interface paths { delete?: never; options?: never; head?: never; - patch?: never; + patch: operations['RequestsController_updateDraft']; trace?: never; }; '/api/requests/{id}/events': { @@ -1316,6 +1316,22 @@ export interface paths { patch?: never; trace?: never; }; + '/api/requests/{id}/resubmit': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['RequestsController_resubmit']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/requests/{id}/approve': { parameters: { query?: never; @@ -2813,6 +2829,45 @@ export interface components { adjustmentDays: number; adjustmentReason?: Record | null; }; + RequestDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + employeeId: string; + employeeName: string; + /** Format: date-time */ + updatedAt: string; + /** @enum {string} */ + type: 'Vacation' | 'HomeOffice' | 'SpecialLeave' | 'TimeAdjustment'; + status: string; + workflowState: string; + /** Format: date-time */ + from: string; + /** Format: date-time */ + to: string; + reason: string | null; + requiresApproval: boolean; + /** Format: uuid */ + approverId: string | null; + /** Format: uuid */ + currentApproverId: string | null; + /** Format: uuid */ + substituteId: string | null; + /** Format: date-time */ + substituteAcceptedAt: string | null; + /** Format: date-time */ + hrConfirmedAt: string | null; + /** Format: date-time */ + cancelledAt: string | null; + calculatedDays: number; + halfDayStart: boolean; + halfDayEnd: boolean; + /** Format: date-time */ + decidedAt: string | null; + decisionNote: string | null; + /** Format: date-time */ + createdAt: string; + }; CreateRequestDto: { /** Format: uuid */ employeeId: string; @@ -2828,7 +2883,7 @@ export interface components { * @example 2026-08-07T00:00:00.000Z */ to: string; - reason?: Record | null; + reason?: string | null; }; CreateVacationDto: { /** Format: uuid */ @@ -2838,8 +2893,8 @@ export interface components { /** Format: date-time */ to: string; /** Format: uuid */ - substituteId?: Record | null; - reason?: Record | null; + substituteId?: string | null; + reason?: string | null; /** * @description Take the first day as a half-day * @default false @@ -2851,15 +2906,39 @@ export interface components { */ halfDayEnd: boolean; }; + UpdateDraftRequestDto: { + /** Format: date-time */ + from: string; + /** Format: date-time */ + to: string; + /** Format: uuid */ + substituteId?: string | null; + reason?: string | null; + /** + * @description Take the first day as a half-day + * @default false + */ + halfDayStart: boolean; + /** + * @description Take the last day as a half-day + * @default false + */ + halfDayEnd: boolean; + /** + * Format: date-time + * @description updatedAt from the version being edited; stale writes return 409 + */ + expectedUpdatedAt: string; + }; TransitionDto: { /** Format: uuid */ actorId: string; - note?: Record | null; + note?: string | null; }; ManagerApproveDto: { /** Format: uuid */ actorId: string; - note?: Record | null; + note?: string | null; /** @default false */ requiresHrConfirmation: boolean; }; @@ -2872,7 +2951,7 @@ export interface components { /** Format: uuid */ actorId: string; ids: string[]; - note?: Record | null; + note?: string | null; /** @default false */ requiresHrConfirmation: boolean; }; @@ -5059,7 +5138,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto'][]; + }; }; }; }; @@ -5080,7 +5161,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5099,7 +5182,34 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; + }; + }; + }; + RequestsController_updateDraft: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateDraftRequestDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5139,7 +5249,34 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; + }; + }; + }; + RequestsController_resubmit: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateDraftRequestDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5162,7 +5299,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5185,7 +5324,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5208,7 +5349,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5231,7 +5374,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5254,7 +5399,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5277,7 +5424,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5300,7 +5449,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5323,7 +5474,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5346,7 +5499,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5369,7 +5524,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; diff --git a/apps/web/src/app/i18n.tsx b/apps/web/src/app/i18n.tsx index bb83a16..2d67e1c 100644 --- a/apps/web/src/app/i18n.tsx +++ b/apps/web/src/app/i18n.tsx @@ -347,6 +347,22 @@ const de: Catalog = { 'Für den ausgewählten Tag wurde bereits ein Tagesblock gebucht.', 'requests.title': 'Anträge', 'requests.description': 'Urlaub, Home-Office, Sonderurlaub, Zeitkorrekturen', + 'requests.edit': 'Antrag bearbeiten', + 'requests.resubmit': 'Erneut einreichen', + 'enum.Resubmitted': 'Erneut eingereicht', + 'requests.saveDraft': 'Entwurf speichern', + 'requests.employee': 'Mitarbeiter/in', + 'requests.revisionReason': 'Rückgabegrund', + 'requests.notDraft': + 'Dieser Antrag ist kein Entwurf mehr. Bitte Übersicht neu laden.', + 'requests.loadFailed': 'Antrag konnte nicht geladen werden.', + 'requests.ambiguousTime': + 'Diese Ortszeit kommt bei der Zeitumstellung in {zone} zweimal vor. Bitte das gewünschte Vorkommen wählen; unveränderte Werte behalten das gespeicherte Vorkommen.', + 'requests.timeOccurrence': '{field} – Vorkommen', + 'requests.chooseOccurrence': 'Vorkommen wählen', + 'requests.timeOccurrenceOption': '{number}. Vorkommen ({offset})', + 'requests.nonexistentTime': + 'Bitte eine gültige Ortszeit eingeben. Zeiten in der Lücke der Zeitumstellung existieren nicht.', 'requests.new': 'Neuer Antrag', 'requests.own': 'Eigene Anträge', 'requests.none': 'Noch keine Anträge.', @@ -1018,6 +1034,22 @@ const en: Catalog = { 'requests.title': 'Requests', 'requests.description': 'Vacation, home office, special leave, time adjustments', + 'requests.edit': 'Edit request', + 'requests.resubmit': 'Resubmit', + 'enum.Resubmitted': 'Resubmitted', + 'requests.saveDraft': 'Save draft', + 'requests.employee': 'Employee', + 'requests.revisionReason': 'Reason for revision', + 'requests.notDraft': + 'This request is no longer a draft. Please reload the list.', + 'requests.loadFailed': 'Could not load request.', + 'requests.ambiguousTime': + 'This local time occurs twice when the clocks change in {zone}. Choose the intended occurrence; unchanged values keep the saved occurrence.', + 'requests.timeOccurrence': '{field} – occurrence', + 'requests.chooseOccurrence': 'Choose occurrence', + 'requests.timeOccurrenceOption': 'Occurrence {number} ({offset})', + 'requests.nonexistentTime': + 'Enter a valid local time. Times in the clock-change gap do not exist.', 'requests.new': 'New request', 'requests.own': 'My requests', 'requests.none': 'No requests yet.', diff --git a/apps/web/src/lib/local-date-time.spec.ts b/apps/web/src/lib/local-date-time.spec.ts new file mode 100644 index 0000000..3d97805 --- /dev/null +++ b/apps/web/src/lib/local-date-time.spec.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + formatLocalDateTime, + localDateTimeCandidates, + utcOffsetLabel, +} from './local-date-time'; + +describe('local date/time resolution', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('preserves seconds and enumerates both Berlin autumn occurrences chronologically', () => { + vi.stubEnv('TZ', 'Europe/Berlin'); + expect(formatLocalDateTime('2026-10-25T01:30:12.345Z')).toBe( + '2026-10-25T02:30:12.345', + ); + expect(localDateTimeCandidates('2026-10-25T02:30:12.345')).toEqual([ + '2026-10-25T00:30:12.345Z', + '2026-10-25T01:30:12.345Z', + ]); + expect(utcOffsetLabel('2026-10-25T00:30:00.000Z')).toBe('UTC+02:00'); + expect(utcOffsetLabel('2026-10-25T01:30:00.000Z')).toBe('UTC+01:00'); + }); + + it.each(['2026-03-29T02:30', '2026-02-30T12:00', '', 'not a date'])( + 'rejects nonexistent or invalid local time %s', + (value) => { + vi.stubEnv('TZ', 'Europe/Berlin'); + expect(localDateTimeCandidates(value)).toEqual([]); + }, + ); + + it('accepts minute, second and fractional precision without rounding', () => { + vi.stubEnv('TZ', 'Europe/Berlin'); + expect(localDateTimeCandidates('2026-06-15T08:10')).toEqual([ + '2026-06-15T06:10:00.000Z', + ]); + expect(localDateTimeCandidates('2026-06-15T08:10:23')).toEqual([ + '2026-06-15T06:10:23.000Z', + ]); + expect(localDateTimeCandidates('2026-06-15T08:10:23.4')).toEqual([ + '2026-06-15T06:10:23.400Z', + ]); + }); + + it('also resolves non-hour folds and gaps', () => { + vi.stubEnv('TZ', 'Australia/Lord_Howe'); + expect(localDateTimeCandidates('2026-04-05T01:45')).toEqual([ + '2026-04-04T14:45:00.000Z', + '2026-04-04T15:15:00.000Z', + ]); + expect(localDateTimeCandidates('2026-10-04T02:15')).toEqual([]); + }); + + it('does not invent ambiguity in UTC and supports negative UTC offsets', () => { + vi.stubEnv('TZ', 'UTC'); + expect(localDateTimeCandidates('2026-10-25T02:30')).toEqual([ + '2026-10-25T02:30:00.000Z', + ]); + vi.stubEnv('TZ', 'America/New_York'); + expect(localDateTimeCandidates('2026-11-01T01:30')).toEqual([ + '2026-11-01T05:30:00.000Z', + '2026-11-01T06:30:00.000Z', + ]); + expect(utcOffsetLabel('2026-11-01T06:30:00.000Z')).toBe('UTC-05:00'); + }); +}); diff --git a/apps/web/src/lib/local-date-time.ts b/apps/web/src/lib/local-date-time.ts new file mode 100644 index 0000000..58b10e8 --- /dev/null +++ b/apps/web/src/lib/local-date-time.ts @@ -0,0 +1,45 @@ +/** datetime-local has no UTC offset. Keep seconds/milliseconds visible and + * enumerate matching instants rather than silently accepting Date's earlier + * occurrence (or its normalization of a nonexistent spring-forward time). */ +export function formatLocalDateTime(iso: string): string { + const d = new Date(iso); + const pad = (n: number, digits = 2) => String(n).padStart(digits, '0'); + return `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; +} + +export function localDateTimeCandidates(value: string): string[] { + const parts = + /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/.exec( + value, + ); + if (!parts) return []; + const normalized = `${parts[1]}:${parts[2] ?? '00'}.${(parts[3] ?? '').padEnd(3, '0')}`; + const parsed = new Date(value); + if ( + !Number.isFinite(parsed.getTime()) || + formatLocalDateTime(parsed.toISOString()) !== normalized + ) + return []; + + // The offsets on the surrounding days include both sides of a clock + // change, including non-hour changes such as Australia/Lord_Howe. + const offsets = new Set( + [-1, 0, 1].map((day) => + new Date(parsed.getTime() + day * 86_400_000).getTimezoneOffset(), + ), + ); + return [...offsets] + .map((offset) => + new Date( + parsed.getTime() + (offset - parsed.getTimezoneOffset()) * 60_000, + ).toISOString(), + ) + .filter((iso) => formatLocalDateTime(iso) === normalized) + .sort(); +} + +export function utcOffsetLabel(iso: string): string { + const minutes = -new Date(iso).getTimezoneOffset(); + const absolute = Math.abs(minutes); + return `UTC${minutes < 0 ? '-' : '+'}${String(Math.floor(absolute / 60)).padStart(2, '0')}:${String(absolute % 60).padStart(2, '0')}`; +} diff --git a/apps/web/src/routes/AdminRequestsPage.spec.tsx b/apps/web/src/routes/AdminRequestsPage.spec.tsx new file mode 100644 index 0000000..30274bf --- /dev/null +++ b/apps/web/src/routes/AdminRequestsPage.spec.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, screen } from '@testing-library/react'; +import { renderWithProviders } from '../test-utils'; +import { AdminRequestsPage } from './AdminRequestsPage'; + +const request = { + id: 'request-42', + employeeId: 'owner', + employeeName: 'Anna Example', + type: 'Vacation', + workflowState: 'PendingManager', + status: 'Submitted', + currentApproverId: 'manager', + from: '2026-05-04T00:00:00.000Z', + to: '2026-05-08T00:00:00.000Z', + calculatedDays: 5, +}; +vi.mock('../api/client', () => ({ + api: { + listRequests: vi + .fn() + .mockImplementation(async (filter) => + filter.currentApproverId ? [request] : [], + ), + getRequest: vi.fn().mockImplementation(async () => request), + getRequestEvents: vi + .fn() + .mockResolvedValue([ + { id: 'event', kind: 'Resubmitted', at: '2026-05-01T12:00:00.000Z' }, + ]), + }, +})); +vi.mock('../app/auth', () => ({ + useCurrentUser: () => ({ + id: 'manager', + firstName: 'Marc', + lastName: 'Example', + role: 'Manager', + }), +})); + +describe('request identity in approval context (#42)', () => { + it('shows the employee name in the inbox and retained audit trail', async () => { + renderWithProviders(); + expect(await screen.findByText(/Anna Example · Urlaub/)).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Verlauf' })); + expect( + await screen.findByText(/Anna Example · Urlaub · 4.5.2026/), + ).toBeDefined(); + expect(await screen.findByText('Erneut eingereicht')).toBeDefined(); + }); +}); diff --git a/apps/web/src/routes/AdminRequestsPage.tsx b/apps/web/src/routes/AdminRequestsPage.tsx index d8fc2df..e7ef36f 100644 --- a/apps/web/src/routes/AdminRequestsPage.tsx +++ b/apps/web/src/routes/AdminRequestsPage.tsx @@ -502,7 +502,7 @@ function RequestRow({ )}

- {enumLabel(request.type)} ·{' '} + {request.employeeName} · {enumLabel(request.type)} ·{' '} {isTimeAdjustment ? `${formatDateTime(request.from)} – ${formatDateTime(request.to)}` : `${formatDate(request.from.slice(0, 10))} – ${formatDate(request.to.slice(0, 10))}`} @@ -624,7 +624,7 @@ function AuditTrail({ requestId }: { requestId: string }) { {t('requests.auditHistory')} {requestQuery.data - ? `${enumLabel(requestQuery.data.type)} · ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.from : requestQuery.data.from.slice(0, 10))} – ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.to : requestQuery.data.to.slice(0, 10))}` + ? `${requestQuery.data.employeeName} · ${enumLabel(requestQuery.data.type)} · ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.from : requestQuery.data.from.slice(0, 10))} – ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.to : requestQuery.data.to.slice(0, 10))}` : '…'} diff --git a/apps/web/src/routes/RequestsPage.spec.tsx b/apps/web/src/routes/RequestsPage.spec.tsx index 933cab4..57004ea 100644 --- a/apps/web/src/routes/RequestsPage.spec.tsx +++ b/apps/web/src/routes/RequestsPage.spec.tsx @@ -1,15 +1,33 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; import { fireEvent, screen, waitFor, within } from '@testing-library/react'; import { renderWithProviders } from '../test-utils'; import { RequestsPage } from './RequestsPage'; +const listRequestsMock = vi.fn(); +const getRequestMock = vi.fn(); +const getRequestEventsMock = vi.fn(); +const updateDraftRequestMock = vi.fn(); +const resubmitRequestMock = vi.fn(); const employeeDirectoryMock = vi.fn(); const employeesMock = vi.fn(); const createVacationRequestMock = vi.fn(); vi.mock('../api/client', () => ({ api: { - listRequests: vi.fn().mockResolvedValue([]), + listRequests: (...args: unknown[]) => listRequestsMock(...args), + getRequest: (...args: unknown[]) => getRequestMock(...args), + getRequestEvents: (...args: unknown[]) => getRequestEventsMock(...args), + updateDraftRequest: (...args: unknown[]) => updateDraftRequestMock(...args), + resubmitRequest: (...args: unknown[]) => resubmitRequestMock(...args), employeeDirectory: (...args: unknown[]) => employeeDirectoryMock(...args), employees: (...args: unknown[]) => employeesMock(...args), vacationBalance: vi.fn().mockResolvedValue({ @@ -32,8 +50,32 @@ vi.mock('../app/auth', () => ({ }), })); -describe('vacation substitute selection', () => { +describe('request creation and draft revision', () => { + const originalTimezone = process.env.TZ; + beforeAll(() => { + process.env.TZ = 'Europe/Berlin'; + }); + afterAll(() => { + if (originalTimezone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimezone; + }); + afterEach(() => vi.restoreAllMocks()); beforeEach(() => { + // Persisted locale wins over navigator.languages. Isolate it on Node 20 + // (jsdom storage) as well as newer Node versions (native localStorage). + vi.spyOn(window, 'localStorage', 'get').mockReturnValue({ + length: 0, + clear: vi.fn(), + key: () => null, + removeItem: vi.fn(), + getItem: () => null, + setItem: vi.fn(), + }); + listRequestsMock.mockReset().mockResolvedValue([]); + getRequestMock.mockReset(); + getRequestEventsMock.mockReset().mockResolvedValue([]); + updateDraftRequestMock.mockReset().mockResolvedValue({}); + resubmitRequestMock.mockReset().mockResolvedValue({}); employeesMock.mockClear(); employeeDirectoryMock.mockClear().mockResolvedValue([ { id: 'requester', firstName: 'Anna', lastName: 'Example' }, @@ -72,4 +114,375 @@ describe('vacation substitute selection', () => { ); }); }); + const draft = { + id: 'same-request', + employeeId: 'requester', + employeeName: 'Anna Example', + type: 'Vacation', + workflowState: 'Draft', + status: 'Submitted', + from: '2026-05-04T00:00:00.000Z', + to: '2026-05-08T00:00:00.000Z', + reason: 'Original reason', + decisionNote: 'Bitte kürzen', + substituteId: 'substitute', + halfDayStart: true, + halfDayEnd: false, + calculatedDays: 4.5, + updatedAt: '2026-05-01T12:00:00.001Z', + }; + + it('opens a draft by keyboard-accessible action with current values/name/reason and resubmits the same ID', async () => { + listRequestsMock.mockResolvedValue([draft]); + getRequestMock.mockResolvedValue(draft); + renderWithProviders(); + fireEvent.click( + await screen.findByRole('button', { + name: 'Entwurf · Antrag bearbeiten', + }), + ); + expect( + await screen.findByText('Rückgabegrund: Bitte kürzen'), + ).toBeDefined(); + expect(screen.getByText('Mitarbeiter/in: Anna Example')).toBeDefined(); + expect((screen.getByLabelText('Von') as HTMLInputElement).value).toBe( + '2026-05-04', + ); + expect((screen.getByLabelText('Bis') as HTMLInputElement).value).toBe( + '2026-05-08', + ); + expect( + ( + (await screen.findByLabelText( + 'Vertretung (optional)', + )) as HTMLSelectElement + ).value, + ).toBe('substitute'); + expect( + (screen.getByLabelText('Erster Tag halb') as HTMLInputElement).checked, + ).toBe(true); + fireEvent.change(screen.getByLabelText('Bis'), { + target: { value: '2026-05-07' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Erneut einreichen' })); + await waitFor(() => + expect(resubmitRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ + expectedUpdatedAt: draft.updatedAt, + reason: 'Original reason', + to: '2026-05-07T00:00:00.000Z', + substituteId: 'substitute', + halfDayStart: true, + }), + ), + ); + expect(createVacationRequestMock).not.toHaveBeenCalled(); + }); + + it('keeps stale/failed edits visible and never creates a replacement request', async () => { + listRequestsMock.mockResolvedValue([draft]); + getRequestMock.mockResolvedValue(draft); + resubmitRequestMock.mockRejectedValue( + new Error('Request changed; reload before continuing'), + ); + renderWithProviders(); + fireEvent.click( + await screen.findByRole('button', { + name: 'Entwurf · Antrag bearbeiten', + }), + ); + await screen.findByText('Rückgabegrund: Bitte kürzen'); + fireEvent.click(screen.getByRole('button', { name: 'Erneut einreichen' })); + expect( + await screen.findByText('Request changed; reload before continuing'), + ).toBeDefined(); + expect(screen.getByRole('dialog')).toBeDefined(); + expect(createVacationRequestMock).not.toHaveBeenCalled(); + }); + + it('can save a draft without submission and reads legacy decline reasons from the retained history', async () => { + listRequestsMock.mockResolvedValue([draft]); + getRequestMock.mockResolvedValue({ ...draft, decisionNote: null }); + getRequestEventsMock.mockResolvedValue([ + { kind: 'SubstituteDeclined', note: 'Bin selbst weg' }, + ]); + renderWithProviders(); + fireEvent.click( + await screen.findByRole('button', { + name: 'Entwurf · Antrag bearbeiten', + }), + ); + expect( + await screen.findByText('Rückgabegrund: Bin selbst weg'), + ).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Entwurf speichern' })); + await waitFor(() => + expect(updateDraftRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ expectedUpdatedAt: draft.updatedAt }), + ), + ); + expect(resubmitRequestMock).not.toHaveBeenCalled(); + }); + + it('does not offer editing for pending requests', async () => { + listRequestsMock.mockResolvedValue([ + { ...draft, workflowState: 'PendingManager' }, + ]); + renderWithProviders(); + await screen.findByText(/Anna Example · Urlaub/); + expect( + screen.queryByRole('button', { name: /Antrag bearbeiten/ }), + ).toBeNull(); + }); + async function openTimeDraft(from: string, to: string) { + const correction = { ...draft, type: 'TimeAdjustment', from, to }; + listRequestsMock.mockResolvedValue([correction]); + getRequestMock.mockResolvedValue(correction); + renderWithProviders(); + fireEvent.click( + await screen.findByRole('button', { + name: 'Entwurf · Antrag bearbeiten', + }), + ); + await screen.findByLabelText('Von'); + return correction; + } + + it.each([ + ['summer seconds', '2026-06-15T06:10:23.456Z', '2026-06-15T07:20:45.789Z'], + [ + 'first autumn occurrence', + '2026-10-25T00:10:23.456Z', + '2026-10-25T00:30:45.789Z', + ], + [ + 'second autumn occurrence', + '2026-10-25T01:10:23.456Z', + '2026-10-25T01:30:45.789Z', + ], + [ + 'fold crossing with locally earlier end', + '2026-10-25T00:50:23.456Z', + '2026-10-25T01:10:45.789Z', + ], + ])( + 'preserves exact instants on reason-only save and resubmit: %s', + async (_name, from, to) => { + const correction = await openTimeDraft(from, to); + fireEvent.change(screen.getByLabelText('Begründung (optional)'), { + target: { value: 'Reason only' }, + }); + const saved = { + ...correction, + reason: 'Reason only', + updatedAt: '2026-05-01T12:00:00.002Z', + }; + updateDraftRequestMock.mockResolvedValue(saved); + getRequestMock.mockResolvedValue(saved); + const save = screen.getByRole('button', { name: 'Entwurf speichern' }); + expect((save as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(save); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(updateDraftRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ from, to, reason: 'Reason only' }), + ); + fireEvent.click( + screen.getByRole('button', { name: 'Entwurf · Antrag bearbeiten' }), + ); + await screen.findByLabelText('Von'); + const submit = screen.getByRole('button', { name: 'Erneut einreichen' }); + expect((submit as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(submit); + await waitFor(() => + expect(resubmitRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ + from, + to, + reason: 'Reason only', + expectedUpdatedAt: saved.updatedAt, + }), + ), + ); + }, + ); + + it('shows seconds and the saved occurrence, and changing one field preserves the other', async () => { + await openTimeDraft('2026-10-25T01:10:23.456Z', '2026-10-25T02:30:45.789Z'); + expect((screen.getByLabelText('Von') as HTMLInputElement).value).toBe( + '2026-10-25T02:10:23.456', + ); + expect( + (screen.getByLabelText('Von – Vorkommen') as HTMLSelectElement).value, + ).toBe('2026-10-25T01:10:23.456Z'); + fireEvent.change(screen.getByLabelText('Bis'), { + target: { value: '2026-10-25T03:45' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Erneut einreichen' })); + await waitFor(() => + expect(resubmitRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ + from: '2026-10-25T01:10:23.456Z', + to: '2026-10-25T02:45:00.000Z', + }), + ), + ); + }); + + it.each([ + ['2026-10-25T00:30:00.000Z', '1. Vorkommen (UTC+02:00)'], + ['2026-10-25T01:30:00.000Z', '2. Vorkommen (UTC+01:00)'], + ])( + 'requires an explicit occurrence for a changed ambiguous time: %s', + async (from, label) => { + await openTimeDraft( + '2026-10-25T00:10:23.456Z', + '2026-10-25T02:30:45.789Z', + ); + fireEvent.change(screen.getByLabelText('Von'), { + target: { value: '2026-10-25T02:30' }, + }); + const select = screen.getByLabelText('Von – Vorkommen'); + expect((select as HTMLSelectElement).value).toBe(''); + expect( + screen.getByText( + /Diese Ortszeit kommt bei der Zeitumstellung in Europe\/Berlin/, + ), + ).toBeDefined(); + for (const name of ['Entwurf speichern', 'Erneut einreichen']) { + const button = screen.getByRole('button', { name }); + expect((button as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(button); + } + expect(updateDraftRequestMock).not.toHaveBeenCalled(); + expect(resubmitRequestMock).not.toHaveBeenCalled(); + expect(within(select).getByRole('option', { name: label })).toBeDefined(); + fireEvent.change(select, { target: { value: from } }); + fireEvent.click( + screen.getByRole('button', { name: 'Erneut einreichen' }), + ); + await waitFor(() => + expect(resubmitRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ from, to: '2026-10-25T02:30:45.789Z' }), + ), + ); + }, + ); + + it('validates the chosen instants, allows a fold crossing and resets a stale occurrence after another edit', async () => { + await openTimeDraft('2026-10-25T00:30:00.000Z', '2026-10-25T01:40:00.000Z'); + fireEvent.change(screen.getByLabelText('Von'), { + target: { value: '2026-10-25T02:50' }, + }); + fireEvent.change(screen.getByLabelText('Von – Vorkommen'), { + target: { value: '2026-10-25T01:50:00.000Z' }, + }); + const submit = screen.getByRole('button', { name: 'Erneut einreichen' }); + expect((submit as HTMLButtonElement).disabled).toBe(true); + // End 02:40 CET is after start 02:50 CEST, although the wall clock is earlier. + fireEvent.change(screen.getByLabelText('Von – Vorkommen'), { + target: { value: '2026-10-25T00:50:00.000Z' }, + }); + expect((submit as HTMLButtonElement).disabled).toBe(false); + fireEvent.change(screen.getByLabelText('Von'), { + target: { value: '2026-10-25T02:55' }, + }); + expect( + (screen.getByLabelText('Von – Vorkommen') as HTMLSelectElement).value, + ).toBe(''); + expect((submit as HTMLButtonElement).disabled).toBe(true); + fireEvent.change(screen.getByLabelText('Von – Vorkommen'), { + target: { value: '2026-10-25T00:55:00.000Z' }, + }); + fireEvent.click(submit); + await waitFor(() => + expect(resubmitRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ + from: '2026-10-25T00:55:00.000Z', + to: '2026-10-25T01:40:00.000Z', + }), + ), + ); + }); + + it('rejects nonexistent spring-forward and empty times without a request', async () => { + await openTimeDraft('2026-03-29T00:10:23.456Z', '2026-03-29T02:30:45.789Z'); + for (const value of ['2026-03-29T02:30', '']) { + fireEvent.change(screen.getByLabelText('Bis'), { target: { value } }); + expect( + screen.getByText(/Bitte eine gültige Ortszeit eingeben/), + ).toBeDefined(); + const save = screen.getByRole('button', { name: 'Entwurf speichern' }); + const submit = screen.getByRole('button', { name: 'Erneut einreichen' }); + expect((save as HTMLButtonElement).disabled).toBe(true); + expect((submit as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(save); + fireEvent.click(submit); + } + expect(updateDraftRequestMock).not.toHaveBeenCalled(); + expect(resubmitRequestMock).not.toHaveBeenCalled(); + }); + it('offers an accessible English occurrence choice for a changed end time and saves the chosen instant', async () => { + const languageSpy = vi + .spyOn(navigator, 'languages', 'get') + .mockReturnValue(['en-GB']); + try { + const correction = { + ...draft, + type: 'TimeAdjustment', + from: '2026-10-25T00:50:23.456Z', + to: '2026-10-25T02:30:45.789Z', + }; + listRequestsMock.mockResolvedValue([correction]); + getRequestMock.mockResolvedValue(correction); + renderWithProviders(); + fireEvent.click( + await screen.findByRole('button', { name: 'Draft · Edit request' }), + ); + fireEvent.change(await screen.findByLabelText('To'), { + target: { value: '2026-10-25T02:10' }, + }); + expect(screen.getAllByText(/This local time occurs twice/)).toHaveLength( + 2, + ); + const select = screen.getByRole('combobox', { name: 'To – occurrence' }); + expect( + within(select).getByRole('option', { name: 'Choose occurrence' }), + ).toBeDefined(); + expect( + within(select).getByRole('option', { + name: 'Occurrence 1 (UTC+02:00)', + }), + ).toBeDefined(); + expect( + within(select).getByRole('option', { + name: 'Occurrence 2 (UTC+01:00)', + }), + ).toBeDefined(); + const save = screen.getByRole('button', { name: 'Save draft' }); + expect((save as HTMLButtonElement).disabled).toBe(true); + fireEvent.change(select, { + target: { value: '2026-10-25T01:10:00.000Z' }, + }); + expect((save as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(save); + await waitFor(() => + expect(updateDraftRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ + from: correction.from, + to: '2026-10-25T01:10:00.000Z', + }), + ), + ); + } finally { + languageSpy.mockRestore(); + } + }); }); diff --git a/apps/web/src/routes/RequestsPage.tsx b/apps/web/src/routes/RequestsPage.tsx index ce0534b..2a26a39 100644 --- a/apps/web/src/routes/RequestsPage.tsx +++ b/apps/web/src/routes/RequestsPage.tsx @@ -15,9 +15,14 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { api, type RequestType } from '../api/client'; +import { api, type RequestDto, type RequestType } from '../api/client'; import { useCurrentUser } from '../app/auth'; import { useI18n } from '../app/i18n'; +import { + formatLocalDateTime, + localDateTimeCandidates, + utcOffsetLabel, +} from '../lib/local-date-time'; const TYPES: RequestType[] = [ 'Vacation', @@ -55,6 +60,7 @@ export function RequestsPage() { const employeeId = user.id; const qc = useQueryClient(); const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); const requests = useQuery({ queryKey: ['requests', { employeeId }], @@ -76,17 +82,30 @@ export function RequestsPage() { - + { setDialogOpen(false); qc.invalidateQueries({ queryKey: ['requests'] }); + qc.invalidateQueries({ queryKey: ['vacation-balance'] }); }} />

+ {editingId && ( + { + setEditingId(null); + qc.invalidateQueries({ queryKey: ['requests'] }); + qc.invalidateQueries({ queryKey: ['request'] }); + qc.invalidateQueries({ queryKey: ['request-events'] }); + qc.invalidateQueries({ queryKey: ['vacation-balance'] }); + }} + /> + )} {t('requests.own')} @@ -101,6 +120,7 @@ export function RequestsPage() { >

+ {r.employeeName || `${user.firstName} ${user.lastName}`} ·{' '} {enumLabel(r.type)} ·{' '} {formatRange( r.type as RequestType, @@ -122,9 +142,19 @@ export function RequestsPage() { {t('requests.specialApproval')} )} - - {enumLabel(r.workflowState)} - + {r.workflowState === 'Draft' ? ( + + ) : ( + + {enumLabel(r.workflowState)} + + )}

{r.type === 'SpecialLeave' && ( @@ -143,31 +173,160 @@ export function RequestsPage() { ); } +function EditRequestDialog({ + requestId, + onClose, +}: { + requestId: string; + onClose: () => void; +}) { + const { t } = useI18n(); + const request = useQuery({ + queryKey: ['request', requestId], + queryFn: () => api.getRequest(requestId), + staleTime: 0, + refetchOnWindowFocus: false, + }); + return ( + !open && onClose()}> + + {request.data && !request.isFetching ? ( + + ) : ( + + {t('requests.edit')} + + {request.isError ? t('requests.loadFailed') : t('common.loading')} + + + )} + + + ); +} + interface NewRequestFormProps { onClose: () => void; + request?: RequestDto; } -function isoDateTimeLocalNow(): string { - const d = new Date(); - // datetime-local needs YYYY-MM-DDTHH:mm in *local* time - const pad = (n: number) => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +function useLocalDateTime(originalIso?: string) { + const [value, setValue] = useState(() => { + const now = new Date(); + now.setSeconds(0, 0); + return formatLocalDateTime(originalIso ?? now.toISOString()); + }); + const [chosenIso, setChosenIso] = useState(originalIso ?? ''); + const candidates = useMemo(() => localDateTimeCandidates(value), [value]); + const chosen = candidates.find( + (iso) => new Date(iso).getTime() === new Date(chosenIso).getTime(), + ); + // Preserve the exact persisted value until this field is changed, not just + // its minute-level local representation. Validation and payload share iso. + const iso = chosen + ? chosenIso + : candidates.length === 1 + ? candidates[0] + : null; + return { + value, + candidates, + iso, + setChosenIso, + change: (next: string) => { + setValue(next); + setChosenIso(''); + }, + }; +} + +function LocalDateTimeField({ + id, + label, + time, +}: { + id: string; + label: string; + time: ReturnType; +}) { + const { t } = useI18n(); + const ambiguous = time.candidates.length > 1; + const invalid = !time.iso; + return ( + <> + time.change(e.target.value)} + aria-invalid={invalid} + aria-describedby={ambiguous || invalid ? `${id}-time-help` : undefined} + /> + {ambiguous ? ( +
+

+ {t('requests.ambiguousTime', { + zone: Intl.DateTimeFormat().resolvedOptions().timeZone, + })} +

+ + +
+ ) : invalid ? ( +

+ {t('requests.nonexistentTime')} +

+ ) : null} + + ); } -function NewRequestForm({ onClose }: NewRequestFormProps) { +function NewRequestForm({ onClose, request }: NewRequestFormProps) { const user = useCurrentUser(); const { t, enumLabel } = useI18n(); const employeeId = user.id; const today = isoDateOnly(new Date()); - const [type, setType] = useState('Vacation'); - const [from, setFrom] = useState(today); - const [to, setTo] = useState(today); - const [fromDt, setFromDt] = useState(isoDateTimeLocalNow); - const [toDt, setToDt] = useState(isoDateTimeLocalNow); - const [reason, setReason] = useState(''); - const [substituteId, setSubstituteId] = useState(''); - const [halfDayStart, setHalfDayStart] = useState(false); - const [halfDayEnd, setHalfDayEnd] = useState(false); + const [type, setType] = useState(request?.type ?? 'Vacation'); + const [from, setFrom] = useState(request?.from.slice(0, 10) ?? today); + const [to, setTo] = useState(request?.to.slice(0, 10) ?? today); + const fromTime = useLocalDateTime( + request?.type === 'TimeAdjustment' ? request.from : undefined, + ); + const toTime = useLocalDateTime( + request?.type === 'TimeAdjustment' ? request.to : undefined, + ); + const [reason, setReason] = useState(request?.reason ?? ''); + const [substituteId, setSubstituteId] = useState( + request?.substituteId ?? '', + ); + const [halfDayStart, setHalfDayStart] = useState( + request?.halfDayStart ?? false, + ); + const [halfDayEnd, setHalfDayEnd] = useState(request?.halfDayEnd ?? false); const [attachment, setAttachment] = useState(null); const [error, setError] = useState(null); @@ -176,6 +335,21 @@ function NewRequestForm({ onClose }: NewRequestFormProps) { queryFn: () => api.employeeDirectory(), }); + const events = useQuery({ + queryKey: ['request-events', request?.id], + queryFn: () => + request ? api.getRequestEvents(request.id) : Promise.resolve([]), + enabled: !!request, + }); + const revisionNote = + request?.decisionNote ?? + events.data + ?.filter( + (event) => + event.kind === 'Returned' || event.kind === 'SubstituteDeclined', + ) + .at(-1)?.note; + const fromDate = useMemo(() => new Date(`${from}T00:00:00.000Z`), [from]); const year = fromDate.getUTCFullYear(); const balance = useQuery({ @@ -186,31 +360,55 @@ function NewRequestForm({ onClose }: NewRequestFormProps) { const isTimeAdjustment = type === 'TimeAdjustment'; - const invalidRange = useMemo(() => { - if (isTimeAdjustment) - return new Date(toDt).getTime() <= new Date(fromDt).getTime(); - return new Date(to).getTime() < new Date(from).getTime(); - }, [isTimeAdjustment, from, to, fromDt, toDt]); + const unresolvedTime = isTimeAdjustment && (!fromTime.iso || !toTime.iso); + const invalidRange = isTimeAdjustment + ? !fromTime.iso || + !toTime.iso || + new Date(toTime.iso).getTime() <= new Date(fromTime.iso).getTime() + : new Date(to).getTime() < new Date(from).getTime(); const create = useMutation({ - mutationFn: async () => { + mutationFn: async (saveOnly: boolean) => { + const fromIso = isTimeAdjustment + ? fromTime.iso + : new Date(`${from}T00:00:00.000Z`).toISOString(); + const toIso = isTimeAdjustment + ? toTime.iso + : new Date(`${to}T00:00:00.000Z`).toISOString(); + if (!fromIso || !toIso || invalidRange) { + throw new Error( + t( + isTimeAdjustment + ? 'requests.invalidTimeRange' + : 'requests.invalidDateRange', + ), + ); + } + if (request) { + const payload = { + expectedUpdatedAt: request.updatedAt, + from: fromIso, + to: toIso, + reason: reason || null, + substituteId: type === 'Vacation' ? substituteId || null : null, + halfDayStart: type === 'Vacation' && halfDayStart, + halfDayEnd: type === 'Vacation' && halfDayEnd, + }; + return saveOnly + ? api.updateDraftRequest(request.id, payload) + : api.resubmitRequest(request.id, payload); + } if (type === 'Vacation') { return api.createVacationRequest({ employeeId, - from: new Date(`${from}T00:00:00.000Z`).toISOString(), - to: new Date(`${to}T00:00:00.000Z`).toISOString(), + from: fromIso, + to: toIso, substituteId: substituteId || null, reason: reason || null, halfDayStart, halfDayEnd, }); } - const fromIso = isTimeAdjustment - ? new Date(fromDt).toISOString() - : new Date(`${from}T00:00:00.000Z`).toISOString(); - const toIso = isTimeAdjustment - ? new Date(toDt).toISOString() - : new Date(`${to}T00:00:00.000Z`).toISOString(); const created = await api.createRequest({ employeeId, type, @@ -233,19 +431,41 @@ function NewRequestForm({ onClose }: NewRequestFormProps) { const insufficientVacation = type === 'Vacation' && balance.data !== undefined && - balance.data.remainingDays < 1; + balance.data.remainingDays < 0.5; return ( <> - {t('requests.new')} + + {t(request ? 'requests.edit' : 'requests.new')} + {t('requests.chooseTypeRange')}
+

+ {t('requests.employee')}:{' '} + {request?.employeeName || `${user.firstName} ${user.lastName}`} +

+ {request && revisionNote && ( + + + {t('requests.revisionReason')}: {revisionNote} + + + )} + {request && request.workflowState !== 'Draft' && ( + + {t('requests.notDraft')} + + )} + {request?.type === 'SpecialLeave' && ( + + )}
setFromDt(e.target.value)} + label={t('common.from')} + time={fromTime} /> ) : ( {isTimeAdjustment ? ( - setToDt(e.target.value)} + label={t('common.to')} + time={toTime} /> ) : ( )} - {type === 'SpecialLeave' && ( + {type === 'SpecialLeave' && !request && (
)} - {invalidRange && ( + {invalidRange && !unresolvedTime && ( {isTimeAdjustment @@ -407,14 +625,37 @@ function NewRequestForm({ onClose }: NewRequestFormProps) { + {request && ( + + )} diff --git a/docs/ISSUE_FIXES_MANUAL_TEST.de.md b/docs/ISSUE_FIXES_MANUAL_TEST.de.md new file mode 100644 index 0000000..073b92d --- /dev/null +++ b/docs/ISSUE_FIXES_MANUAL_TEST.de.md @@ -0,0 +1,304 @@ +# Nachtest der Issue-Pakete + +## Paket #42 – zurückgegebene Anträge bearbeiten + +- Issue: +- Branch: `fix/42-returned-request-edit`, Basis: `9cfbc989cce97bde362821d1e2922951b2abb498`. +- Implementierungs-Worktree: `/Users/patrickschiller/openclockwork-issue-42`. +- Keine Releases, Tags, Versionsänderungen, Merges oder Deployments. Keine neue + Migration erforderlich: Die vorhandenen Felder `updatedAt` und `workflowState` + sichern Schreibzugriffe ab; das vorhandene Ereignis `Resubmitted` wird verwendet. + +### Isoliertes lokales Setup + +Die Grundlage ist [LOCAL_DOCKER_TEST.de.md](LOCAL_DOCKER_TEST.de.md). +Für diesen Nachtest **nicht** die bestehenden Solo-/Team-Projekte wiederverwenden. +Eigene Projekt-, Image-Namen und Port vermeiden Änderungen an anderen Teststacks. +Nur synthetische Daten verwenden; weder Produktivdaten importieren noch eine +vorhandene `.env` laden. + +Vom Implementierungs-Worktree aus arbeiten, oder einen weiteren Test-Worktree +anlegen (der Hauptcheckout mit lokalen Dateien bleibt unberührt): + +```bash +git fetch origin +git worktree add ../openclockwork-42-manual origin/fix/42-returned-request-edit +cd ../openclockwork-42-manual +``` + +Docker und Compose müssen laufen. Der lokale Override liegt außerhalb des Repos: + +```bash +cat > /tmp/openclockwork-42-compose.yml <<'YAML' +services: + api: + image: openclockwork-api:issue42-local + web: + image: openclockwork-web:issue42-local +YAML + +TEST_WEB_PORT=18042 docker compose --env-file /dev/null \ + -p openclockwork-issue42 -f docker-compose.test.yml \ + -f /tmp/openclockwork-42-compose.yml build + +TEST_WEB_PORT=18042 docker compose --env-file /dev/null \ + -p openclockwork-issue42 -f docker-compose.test.yml \ + -f /tmp/openclockwork-42-compose.yml up -d --wait + +TEST_WEB_PORT=18042 docker compose --env-file /dev/null \ + -p openclockwork-issue42 -f docker-compose.test.yml \ + -f /tmp/openclockwork-42-compose.yml exec -T \ + -e NODE_ENV=development \ + -e OPENCLOCKWORK_SEED_CONFIRM_DATABASE=openclockwork_test \ + api node --import tsx prisma/seed.ts +``` + +Seed nur einmal auf dieser leeren Team-Testdatenbank ausführen. Oberfläche: +, Healthcheck: . + +| Rolle | Synthetisches Konto | Verwendung | +| ----------------------- | --------------------------------- | ---------------------------------- | +| Beschäftigte | `anna.mueller@openclockwork.test` | Antragstellerin | +| Führungskraft | `marc.becker@openclockwork.test` | Rückgabe/Genehmigung | +| Vertretung/Fremdzugriff | `bernd.schulz@openclockwork.test` | Ablehnen/Annehmen, kein Eigentümer | +| HR | `hannah.roth@openclockwork.test` | Urlaubskonto und Fremdzugriff | + +Das initiale lokale Seed-Passwort ist `openclockwork`, siehe Basisanleitung. +Separate Browserprofile/private Fenster je Rolle verwenden. Einen freien +Mo–Fr-Zeitraum desselben Jahres ohne Feiertage wählen; vorhandene Seed-Anträge und +Zeitbuchungen nicht überlappen. Für exakte 5/4,5-Tage-Erwartungen müssen die +Arbeitszeitregelung und das Urlaubskonto zu diesem Zeitraum passen. + +### A. Führungskraft gibt zurück → bearbeiten → erneut einreichen + +1. Als Anna unter **Anträge → Neuer Antrag** Urlaub ohne Vertretung für fünf + Arbeitstage beantragen, Grund „Nachtest #42“ eintragen. Annas Name steht in der + Maske und der Liste. In den Browser-Netzwerkdetails die Antwort-ID festhalten. +2. Als Marc unter **Genehmigungen** denselben Antrag prüfen: Annas Name steht im + Eintrag und im **Verlauf**. Notiz „Bitte letzten Tag kürzen“ eingeben und + **Zur Korrektur** wählen. +3. Als Anna Übersicht aktualisieren. **Entwurf · Antrag bearbeiten** mit Maus + oder Tab/Enter öffnen. Erwartet: ursprüngliche Daten, Grund, Halbtagswerte und + Vertretung (falls vorhanden) sind vorbelegt; Annas Name und der Rückgabegrund + erscheinen. Der Antragstyp ist unveränderlich. +4. Letzten Tag oder Halbtagsauswahl ändern. **Entwurf speichern** wählen. + Erwartet: weiterhin Entwurf, kein neuer Antrag, noch kein Genehmigungseingang. + Erneut öffnen: geänderte Werte und Rückgabegrund bleiben vorhanden. +5. **Erneut einreichen** wählen. Erwartet: dieselbe ID wie in Schritt 1; + `workflowState=PendingManager`, aktueller Manager neu zugeordnet; Tageszahl + erneut berechnet (ein halber erster Tag bei fünf Arbeitstagen: 4,5 Tage). + Alte Freigabe-, Entscheidungs-, HR- und Vertretungsannahmefelder sind leer. +6. Als Marc aktualisieren: genau ein Antrag mit neuen Werten. Im **Verlauf** + bleiben Einreichung und Rückgabenotiz erhalten, zusätzlich „Erneut eingereicht“. + Normal genehmigen; Annas Urlaubskonto darf den Antrag nicht doppelt zählen. + +### B. Vertretung lehnt ab → bearbeiten → erneut einreichen + +1. Als Anna neuen Urlaub in einem anderen freien Zeitraum mit Bernd als Vertretung + beantragen. Als Bernd unter **Vertretungen** mit Notiz „Bin selbst weg“ ablehnen. +2. Als Anna den Entwurf öffnen: Werte und Ablehnungsgrund müssen sichtbar sein. + Grund/Zeitraum/Halbtage ändern, Bernd erneut auswählen und erneut einreichen. +3. Erwartet: gleiche ID, `PendingSubstitute`, `substituteAcceptedAt=null`, noch + kein aktueller Manager-Freigabeschritt. Bernd muss erneut ausdrücklich annehmen; + danach folgt Marc. Alternativ Vertretung entfernen: direkt `PendingManager`. +4. Bei einem zurückgegebenen **Sonderurlaub** mit synthetischem PDF dasselbe + durchführen: vorhandener Beleg muss vor/nach der Bearbeitung herunterladbar + bleiben; Historie und Beleg-ID dürfen nicht verloren gehen. +5. Bei **Zeitkorrektur** Uhrzeiten prüfen: gespeicherte lokale Uhrzeiten werden + vorbelegt. Neue Buchungsüberschneidung wird abgewiesen; eine Änderung außerhalb + der Rahmenzeit erfordert weiterhin Sondergenehmigung und HR-Bestätigung. +6. Sprache auf Englisch umstellen: Bearbeiten/Speichern/Erneut einreichen, + Mitarbeiterbeschriftung, Rückgabegrund und Wiedereinreichungsereignis sind + übersetzt, keine neuen rohen i18n-Schlüssel. + +### C. Negative und parallele Fälle + +Die API-Aufrufe lassen sich über die Browser-Netzwerkdetails **des isolierten +Teststacks** wiederholen. Keine echten Bearer-Token in Dokumente/Issues kopieren. + +`GET /api/requests/:id` liefert `updatedAt`. Bearbeiten: +`PATCH /api/requests/:id`; erneut einreichen: `POST /api/requests/:id/resubmit`. +Beide erwarten vollständige editierbare Werte, z. B.: + +```json +{ + "expectedUpdatedAt": "", + "from": "2026-05-04T00:00:00.000Z", + "to": "2026-05-08T00:00:00.000Z", + "reason": "Nachtest", + "substituteId": null, + "halfDayStart": true, + "halfDayEnd": false +} +``` + +Die Beispieldaten an den freien Testzeitraum anpassen. + +| Fall | Erwartung | +| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Bernd, Marc oder HR ändern/reichen Annas Entwurf ein | 403; Eigentümer wird ausschließlich aus Bearer-Identität geprüft, nicht aus Body-Feldern | +| Kein Login | 401 | +| Noch ausstehend, genehmigt, abgelehnt oder storniert statt `Draft` | 409; `status=Submitted` allein berechtigt nicht | +| Enddatum vor Anfang, Urlaub über Jahresgrenze, nur Wochenende, ungültige Daten | 400; unveränderter Entwurf | +| Selbstvertretung, nicht vorhandene oder deaktivierte Vertretung | 400 | +| HR reduziert Annas Urlaub unter den neu berechneten Bedarf | 409 bei Speichern und Einreichen; 0,5 verfügbar erlaubt einen einzelnen halben Arbeitstag | +| Überschneidung mit anderem aktivem Antrag oder anderem Entwurf | 409; ausschließlich die eigene ID ist aus der Prüfung ausgenommen | +| Unveränderten zurückgegebenen Urlaub bei exakt ausreichendem Konto einreichen | Erfolg; eigene Reservierung wird weder bei Überschneidung noch beim Saldo doppelt gezählt | +| Entwurf in zwei Tabs öffnen, in A speichern, in B mit altem `updatedAt` speichern/einreichen | 409; kein stilles Überschreiben, Fehler bleibt in der Maske, neu laden und erneut bearbeiten | +| Zweimal gleichzeitig denselben Entwurf einreichen | genau ein Erfolg, ein 409, nur ein `Resubmitted`-Ereignis | +| Zwei verschiedene Entwürfe parallel einreichen, Urlaub reicht nur für einen | genau einer erfolgreich; keine Überbuchung des Kontos | +| Bereits laufende alte Managerentscheidung während Rückgabe + Wiedereinreichung | alte Entscheidung scheitert mit 409, neue Werte/neuer Freigabezyklus bleiben bestehen | + +### D. Zeitkorrekturen: Sekunden und Herbst-Zeitumstellung (#42-Nachkorrektur) + +Den Browser bzw. die Browser-Testumgebung ausdrücklich auf **Europe/Berlin** +stellen (z. B. DevTools → Sensors → Location/Timezone). Die Maske verwendet wie +bisher die lokale Browser-Zeitzone. Nur den isolierten synthetischen Teststack +nutzen, keine vorhandenen Zeitbuchungen überlappen und die Szenarien einzeln +ausführen; anschließend den jeweiligen Testantrag stornieren oder getrennte +synthetische Beschäftigte verwenden. + +Für präzise Ausgangswerte als Anna über die Netzwerkdetails einen synthetischen +`POST /api/requests` mit Annas `employeeId`, `type=TimeAdjustment`, einem +Testgrund und den unten angegebenen `from`-/`to`-Werten anlegen. Der Server +erwartet ISO-Zeitpunkte, nicht offsetlose Ortszeiten. Als Marc zur Korrektur +zurückgeben (nicht genehmigen; die Sondergenehmigungs-/HR-Regeln gelten weiter). + +| Ausgangsfall | ISO-Anfang → ISO-Ende | Lokale Anzeige Europe/Berlin | +| --- | --- | --- | +| Normale Sekunden/Millisekunden | `2026-06-15T06:10:23.456Z` → `2026-06-15T07:20:45.789Z` | 08:10:23.456 → 09:20:45.789 | +| Erstes Herbst-Vorkommen | `2026-10-25T00:10:23.456Z` → `2026-10-25T00:30:45.789Z` | 02:10:23.456 → 02:30:45.789, jeweils UTC+02:00 | +| Zweites Herbst-Vorkommen | `2026-10-25T01:10:23.456Z` → `2026-10-25T01:30:45.789Z` | dieselben Ortszeiten, jeweils UTC+01:00 | +| Über die Rückstellung | `2026-10-25T00:50:23.456Z` → `2026-10-25T01:10:45.789Z` | 02:50:23.456 UTC+02:00 → 02:10:45.789 UTC+01:00 | + +1. Je Ausgangsfall Entwurf öffnen, **nur die Begründung ändern**, speichern, + wieder öffnen und erneut einreichen. Netzwerk-Payload und Antwort für + `PATCH /api/requests/:id` und `POST /api/requests/:id/resubmit` prüfen: + `from`/`to` bleiben **exakt** wie in der Tabelle, einschließlich Sekunden, + Millisekunden und Vorkommen; gleiche ID. Das rückstellungsübergreifende + Intervall bleibt zulässig, obwohl die lokale Enduhrzeit kleiner ist. +2. Zum Vergleich den konkreten Fehlerfall mit + `2026-10-25T01:30:00.000Z` als Anfang und z. B. + `2026-10-25T02:30:00.000Z` als Ende prüfen: Bei einer reinen + Begründungsänderung darf der Anfang **nicht** nach + `2026-10-25T00:30:00.000Z` wechseln. +3. In einem zurückgegebenen Zeitkorrektur-Entwurf den Anfang auf + **25.10.2026 02:30** ändern (Ende z. B. 03:30). Erwartet: verständlicher + Zeitzonenhinweis und beschriftetes Feld **Von – Vorkommen**; kein stiller + Standardwert. Speichern und Wiedereinreichen bleiben gesperrt, bis explizit + gewählt wurde. **1. Vorkommen (UTC+02:00)** sendet + `2026-10-25T00:30:00.000Z`, **2. Vorkommen (UTC+01:00)** sendet + `2026-10-25T01:30:00.000Z`. Das unveränderte Ende bleibt exakt erhalten. +4. Dasselbe am Ende prüfen. Anfang 02:50 UTC+02:00, Ende 02:10 UTC+01:00: + zulässig. Anfang auf das zweite Vorkommen umstellen: Ende liegt davor, + deshalb gesperrt. Erneute Änderung des Ortszeitfelds verwirft eine vorherige + Vorkommenswahl und verlangt bei Mehrdeutigkeit eine neue Auswahl. +5. Ortszeit **29.03.2026 02:30** oder ein leeres Zeitfeld eingeben: + verständlicher Fehler; kein API-Aufruf und keine automatische Verschiebung + auf 03:30. Ein normaler gültiger Zeitpunkt hebt die Sperre wieder auf, + sofern das Ende nach dem Anfang liegt. +6. Auf Englisch wiederholen: **From/To – occurrence**, **Choose occurrence**, + **Occurrence 1 (UTC+02:00)** / **Occurrence 2 (UTC+01:00)** und Erklärung sind + übersetzt. Auswahl mit Tab und Pfeiltasten bedienen; Zeitfeld und Auswahl + sind mit ihren jeweiligen Labels/Hinweisen verknüpft. + +### Automatisch ausgeführte Gates + +Stand: 07.11.2026. Tests verwenden eine eigene synthetische PostgreSQL-16-Instanz +(`openclockwork_test_42`, Loopback-Port 15442); keine vorhandene Datenbank wird +zurückgesetzt. Die UI-Regressionsprüfungen sind React-Testing-Library-Tests; +der oben beschriebene manuelle Docker-/Browser-Nachtest wurde nicht als bestanden +behauptet. + +Ausgeführt mit Node `v26.12.1`, pnpm `9.15.9` (für diesen Worktree über +`/tmp/openclockwork-42-tools/node_modules/.bin` im `PATH`). + +| Gate | Ergebnis | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `pnpm nx e2e api-e2e --testPathPatterns='request-revision\|vacation-workflow\|half-day-vacation\|attachments\|bulk\|carry-over-expiry\|employee-account-access\|work-schedules'` | **75 Tests, 8 Suites bestanden**, einschließlich 20 neuer Revisionsfälle | +| `pnpm nx run-many -t test -p web,shared --run` | **Web: 190 Tests / 33 Dateien**, **Shared: 88 Tests / 7 Dateien bestanden** | +| API-, Web- und Shared-Builds im Lauf `pnpm nx run-many -t build,typecheck,lint -p api,web,shared,api-e2e` | **Alle vorhandenen Build-Targets erfolgreich** | +| Lint im selben Lauf für API, Web, Shared und API-E2E | **0 Fehler**; 13 bestehende Warnungen in unveränderten Dateien | +| `pnpm nx run web:typecheck --force --skip-nx-cache`, danach `pnpm nx run-many -t typecheck -p api,web,shared,api-e2e` | **Alle vier Typechecks erfolgreich** | +| `pnpm verify:api` mit der isolierten Test-`DATABASE_URL` | **Erfolgreich**, OpenAPI und generierter Client identisch zur gestagten Fassung | +| `git diff --check` | **Erfolgreich** | + +#### Nachkorrektur des unabhängigen P2-Befunds: Zeit-Roundtrip + +Ergänzt am 07.11.2026, gleicher Branch/Worktree. **18 zusätzliche Regressionen** +(10 UI-Fälle und 8 Zeitauflösungsfälle). Die betroffenen Tests setzen ihre +Zeitzonen selbst und stellen sie wieder her; die Web-Läufe wurden zusätzlich +mit `TZ=UTC` gestartet, damit die Berlin-Fälle nicht zufällig von der +Host-Zeitzone abhängen. + +| Gate | Ergebnis | +| --- | --- | +| Vier neue reine Begründungs-Roundtrips gegen die unveränderte `RequestsPage.tsx` aus `230dbb0` | **4/4 erwartungsgemäß fehlgeschlagen**: Sekundenverlust, zweites Vorkommen um eine Stunde verschoben bzw. gültiges rückstellungsübergreifendes Intervall gesperrt; danach korrigierte Datei wiederhergestellt | +| `TZ=UTC pnpm nx test web --run src/routes/RequestsPage.spec.tsx src/lib/local-date-time.spec.ts src/app/i18n.spec.tsx` | **29/29 bestanden** | +| `TZ=UTC pnpm nx test web --run` (finaler Code) | **208/208 Tests, 34 Dateien bestanden** | +| `TZ=UTC pnpm nx run-many -t test -p web,shared --run` | Web erfolgreich; **Shared 88/88 aus unverändertem Nx-Cache** | +| `pnpm nx run-many -t typecheck,lint -p web` | **Erfolgreich; 0 Fehler, nur 2 bestehende Warnungen** in `ThemeProvider.tsx` und `components/ui/alert.tsx`, keine neue Warnung | +| `pnpm nx build web` (nach beendetem Typecheck) | **Erfolgreich**, einschließlich PWA-Build | +| `git diff --check` | **Erfolgreich** | + +Abgedeckt: erfolgreicher Ablauf Speichern → Wiederöffnen → Wiedereinreichen mit +neuem Versionswert, exakte Sekunden/Millisekunden, beide Herbst-Vorkommen, +unverändertes Gegenfeld, explizite DE/EN-Auswahl beider UTC-Offsets, identische +Validierungs-/Payload-Zeitpunkte, lokal früheres aber tatsächlich späteres Ende, +veraltete Auswahl nach weiterer Zeitänderung, leere Eingabe und Frühjahrslücke. +Die Hilfsfunktion wird zusätzlich für UTC, New York und eine halbstündige +Lord-Howe-Zeitumstellung geprüft. + +Der erste CI-Nachlauf bestand 207/208 Web-Tests: Der neue Englisch-Test +übernahm unter Node 20 die in jsdom gespeicherte deutsche Sprachpräferenz, die +absichtlich Vorrang vor der Browsersprache hat. Die Testfixture isoliert den +Storage jetzt explizit wie die vorhandenen i18n-Tests; kein Anwendungscode +musste dafür geändert werden. Der folgende lokale Nachlauf bestand mit +Node **20.20.2** (nur per temporärem Tool-PATH, keine Repository-Konfiguration +geändert): `TZ=UTC pnpm nx test web --run --skip-nx-cache` — +**208/208 Tests in 34 Dateien**. Auch unter Node 26 wurden danach erneut +208/208 bestanden; Lint ist unverändert grün, Typecheck aus unverändertem Cache. + +Nur Web und Anleitung geändert: keine API-, OpenAPI-, Client-Vertrags- oder +Datenbankänderung. Die bisherigen API-Gates bleiben oben dokumentiert; die +reguläre PR-CI prüft auch auf diesem Head erneut API-Synchronität und API-E2E. +Der manuelle Browser-Durchlauf aus Abschnitt D ist eine Anleitung, **kein** +behauptetes Testergebnis. + +Beim kombinierten parallelen Build-/Typecheck-Zwischenlauf entfernte Vite das +von TypeScript ebenfalls verwendete Web-`dist`-Verzeichnis (`TS6305`). Der +anschließende erzwungene, separat ausgeführte Web-Typecheck und der separate +Vier-Projekt-Typecheck bestanden. Deshalb Build und Typecheck für einen Nachlauf +nacheinander starten. Es wurde dafür keine Projektkonfiguration geändert. +Weitere nicht blockierende Ausgaben: React-Router-Future-Hinweise, +Node-localStorage-Hinweis und veraltete Browserslist-Daten. + +Reproduzierbare API-Testumgebung (ausschließlich neue synthetische Testinstanz): + +```bash +docker run --name openclockwork-issue-42-db \ + -e POSTGRES_USER=openclockwork -e POSTGRES_PASSWORD=openclockwork \ + -e POSTGRES_DB=openclockwork_test_42 \ + -p 127.0.0.1:15442:5432 -d postgres:16-alpine + +E2E_DATABASE_URL='postgresql://openclockwork:openclockwork@localhost:15442/openclockwork_test_42?schema=public' \ +TZ=Europe/Berlin pnpm nx e2e api-e2e \ + --testPathPatterns='request-revision|vacation-workflow|half-day-vacation|attachments|bulk|carry-over-expiry|employee-account-access|work-schedules' +``` + +Wenn diese konkrete Testinstanz bereits existiert, nicht ein zweites Mal anlegen. +Die Test-Suite prüft den Testdatenbanknamen vor Migration/Reset. Ein vorhandenes +`DATABASE_URL` muss entfernt oder bewusst auf dieselbe isolierte Instanz gesetzt +werden; widersprüchliche Ziele werden abgewiesen. + +### Stoppen, Daten erhalten + +```bash +TEST_WEB_PORT=18042 docker compose --env-file /dev/null \ + -p openclockwork-issue42 -f docker-compose.test.yml \ + -f /tmp/openclockwork-42-compose.yml stop +``` + +Zum Fortsetzen denselben `up -d --wait`-Befehl verwenden, **nicht** erneut seeden. +Kein `down -v`: Das würde die Nachtestdaten löschen. Folgepakete #41, #43, #45 +und #44 ergänzen diese Anleitung erst bei ihrer jeweiligen Implementierung.