refactor(api): extract vacation management support

This commit is contained in:
2026-03-31 14:27:54 +02:00
parent 609804a334
commit 73cfc9341b
3 changed files with 273 additions and 54 deletions
@@ -0,0 +1,126 @@
import { VacationStatus } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import { describe, expect, it } from "vitest";
import {
approvableVacationStatuses,
assertVacationApprovable,
assertVacationCancelable,
assertVacationRejectable,
buildApprovedVacationUpdateData,
buildRejectedVacationUpdateData,
buildVacationStatusUpdateData,
canActorCancelVacation,
isVacationManagerRole,
} from "../router/vacation-management-support.js";
describe("vacation management support", () => {
it("exposes approvable statuses and guards invalid transitions", () => {
expect(approvableVacationStatuses).toEqual([
VacationStatus.PENDING,
VacationStatus.CANCELLED,
VacationStatus.REJECTED,
]);
expect(() => assertVacationApprovable(VacationStatus.PENDING)).not.toThrow();
expect(() => assertVacationRejectable(VacationStatus.PENDING)).not.toThrow();
expect(() => assertVacationCancelable(VacationStatus.PENDING)).not.toThrow();
expect(() => assertVacationApprovable(VacationStatus.APPROVED)).toThrowError(
new TRPCError({
code: "BAD_REQUEST",
message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved",
}),
);
expect(() => assertVacationRejectable(VacationStatus.APPROVED)).toThrowError(
new TRPCError({
code: "BAD_REQUEST",
message: "Only PENDING vacations can be rejected",
}),
);
expect(() => assertVacationCancelable(VacationStatus.CANCELLED)).toThrowError(
new TRPCError({
code: "BAD_REQUEST",
message: "Already cancelled",
}),
);
});
it("builds approval, rejection, and generic status payloads", () => {
const approvedAt = new Date("2026-02-01T10:00:00.000Z");
expect(buildApprovedVacationUpdateData({
deductionSnapshotWriteData: { deductedDays: 3, deductionSnapshot: { reason: "calendar" } },
approvedById: "user_mgr",
approvedAt,
})).toEqual({
status: VacationStatus.APPROVED,
rejectionReason: null,
deductedDays: 3,
deductionSnapshot: { reason: "calendar" },
approvedById: "user_mgr",
approvedAt,
});
expect(buildRejectedVacationUpdateData({
rejectionReason: "Capacity conflict",
})).toEqual({
status: VacationStatus.REJECTED,
rejectionReason: "Capacity conflict",
});
expect(buildVacationStatusUpdateData({
status: "APPROVED",
note: "Reviewed",
approvedById: "user_mgr",
approvedAt,
})).toEqual({
status: "APPROVED",
approvedById: "user_mgr",
approvedAt,
rejectionReason: null,
note: "Reviewed",
});
expect(buildVacationStatusUpdateData({
status: "CANCELLED",
approvedById: "user_mgr",
approvedAt,
})).toEqual({
status: "CANCELLED",
});
});
it("captures manager bypass and ownership-based cancellation access", () => {
expect(isVacationManagerRole("ADMIN")).toBe(true);
expect(isVacationManagerRole("MANAGER")).toBe(true);
expect(isVacationManagerRole("USER")).toBe(false);
expect(canActorCancelVacation({
actorId: "user_1",
actorRole: "USER",
requestedById: "user_1",
resourceUserId: "user_2",
})).toBe(true);
expect(canActorCancelVacation({
actorId: "user_1",
actorRole: "USER",
requestedById: "user_2",
resourceUserId: "user_1",
})).toBe(true);
expect(canActorCancelVacation({
actorId: "user_mgr",
actorRole: "MANAGER",
requestedById: "user_2",
resourceUserId: null,
})).toBe(true);
expect(canActorCancelVacation({
actorId: "user_1",
actorRole: "USER",
requestedById: "user_2",
resourceUserId: "user_3",
})).toBe(false);
});
});
@@ -18,6 +18,16 @@ import {
dispatchVacationWebhookInBackground, dispatchVacationWebhookInBackground,
notifyVacationStatusInBackground, notifyVacationStatusInBackground,
} from "./vacation-side-effects.js"; } from "./vacation-side-effects.js";
import {
assertVacationApprovable,
assertVacationCancelable,
assertVacationRejectable,
buildApprovedVacationUpdateData,
buildRejectedVacationUpdateData,
buildVacationStatusUpdateData,
canActorCancelVacation,
isVacationManagerRole,
} from "./vacation-management-support.js";
const BatchCreatePublicHolidaysSchema = z.object({ const BatchCreatePublicHolidaysSchema = z.object({
year: z.number().int().min(2000).max(2100), year: z.number().int().min(2000).max(2100),
@@ -44,10 +54,7 @@ export const vacationManagementProcedures = {
ctx.db.vacation.findUnique({ where: { id: input.id } }), ctx.db.vacation.findUnique({ where: { id: input.id } }),
"Vacation", "Vacation",
); );
const approvableStatuses: string[] = [VacationStatus.PENDING, VacationStatus.CANCELLED, VacationStatus.REJECTED]; assertVacationApprovable(existing.status);
if (!approvableStatuses.includes(existing.status)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved" });
}
await assertVacationStillChargeable(ctx.db, { await assertVacationStillChargeable(ctx.db, {
resourceId: existing.resourceId, resourceId: existing.resourceId,
@@ -74,13 +81,11 @@ export const vacationManagementProcedures = {
const updated = await ctx.db.vacation.update({ const updated = await ctx.db.vacation.update({
where: { id: input.id }, where: { id: input.id },
data: { data: buildApprovedVacationUpdateData({
status: VacationStatus.APPROVED, deductionSnapshotWriteData,
rejectionReason: null, approvedById: userRecord?.id,
...deductionSnapshotWriteData,
...(userRecord?.id ? { approvedById: userRecord.id } : {}),
approvedAt: new Date(), approvedAt: new Date(),
}, }),
}); });
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
@@ -121,16 +126,13 @@ export const vacationManagementProcedures = {
ctx.db.vacation.findUnique({ where: { id: input.id } }), ctx.db.vacation.findUnique({ where: { id: input.id } }),
"Vacation", "Vacation",
); );
if (existing.status !== VacationStatus.PENDING) { assertVacationRejectable(existing.status);
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING vacations can be rejected" });
}
const updated = await ctx.db.vacation.update({ const updated = await ctx.db.vacation.update({
where: { id: input.id }, where: { id: input.id },
data: { data: buildRejectedVacationUpdateData({
status: VacationStatus.REJECTED, rejectionReason: input.rejectionReason,
...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}), }),
},
}); });
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
@@ -200,13 +202,11 @@ export const vacationManagementProcedures = {
}); });
const updated = await ctx.db.vacation.update({ const updated = await ctx.db.vacation.update({
where: { id: vacation.id }, where: { id: vacation.id },
data: { data: buildApprovedVacationUpdateData({
status: VacationStatus.APPROVED, deductionSnapshotWriteData,
rejectionReason: null, approvedById: userRecord?.id,
...deductionSnapshotWriteData,
...(userRecord?.id ? { approvedById: userRecord.id } : {}),
approvedAt: new Date(), approvedAt: new Date(),
}, }),
}); });
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
@@ -252,10 +252,9 @@ export const vacationManagementProcedures = {
await ctx.db.vacation.updateMany({ await ctx.db.vacation.updateMany({
where: { id: { in: vacations.map((vacation) => vacation.id) } }, where: { id: { in: vacations.map((vacation) => vacation.id) } },
data: { data: buildRejectedVacationUpdateData({
status: VacationStatus.REJECTED, rejectionReason: input.rejectionReason,
...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}), }),
},
}); });
for (const vacation of vacations) { for (const vacation of vacations) {
@@ -293,28 +292,29 @@ export const vacationManagementProcedures = {
ctx.db.vacation.findUnique({ where: { id: input.id } }), ctx.db.vacation.findUnique({ where: { id: input.id } }),
"Vacation", "Vacation",
); );
if (existing.status === VacationStatus.CANCELLED) { assertVacationCancelable(existing.status);
throw new TRPCError({ code: "BAD_REQUEST", message: "Already cancelled" });
}
const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email);
if (!userRecord) { if (!userRecord) {
throw new TRPCError({ code: "UNAUTHORIZED" }); throw new TRPCError({ code: "UNAUTHORIZED" });
} }
const isManagerOrAdmin = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER"; const resource = isVacationManagerRole(userRecord.systemRole) || existing.requestedById === userRecord.id
if (!isManagerOrAdmin) { ? null
if (existing.requestedById !== userRecord.id) { : await ctx.db.resource.findUnique({
const resource = await ctx.db.resource.findUnique({
where: { id: existing.resourceId }, where: { id: existing.resourceId },
select: { userId: true }, select: { userId: true },
}); });
if (!resource || resource.userId !== userRecord.id) {
throw new TRPCError({ if (!canActorCancelVacation({
code: "FORBIDDEN", actorId: userRecord.id,
message: "You can only cancel your own vacation requests", actorRole: userRecord.systemRole,
}); requestedById: existing.requestedById,
} resourceUserId: resource?.userId,
} })) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only cancel your own vacation requests",
});
} }
const updated = await ctx.db.vacation.update({ const updated = await ctx.db.vacation.update({
@@ -396,24 +396,18 @@ export const vacationManagementProcedures = {
throw new TRPCError({ code: "UNAUTHORIZED" }); throw new TRPCError({ code: "UNAUTHORIZED" });
} }
const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER"; if (input.status !== "CANCELLED" && !isVacationManagerRole(userRecord.systemRole)) {
if (input.status !== "CANCELLED" && !isManager) {
throw new TRPCError({ code: "FORBIDDEN", message: "Manager role required to approve/reject" }); throw new TRPCError({ code: "FORBIDDEN", message: "Manager role required to approve/reject" });
} }
const data: Record<string, unknown> = { status: input.status };
if (input.status === "APPROVED") {
data.approvedById = userRecord.id;
data.approvedAt = new Date();
data.rejectionReason = null;
}
if (input.note !== undefined) {
data.note = input.note;
}
const updated = await ctx.db.vacation.update({ const updated = await ctx.db.vacation.update({
where: { id: input.id }, where: { id: input.id },
data, data: buildVacationStatusUpdateData({
status: input.status,
note: input.note,
approvedById: userRecord.id,
approvedAt: new Date(),
}),
}); });
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
@@ -0,0 +1,99 @@
import type { Prisma } from "@capakraken/db";
import { VacationStatus } from "@capakraken/db";
import type { UpdateVacationStatusInput } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
type VacationApprovalWriteData = Record<string, unknown>;
export const approvableVacationStatuses: readonly VacationStatus[] = [
VacationStatus.PENDING,
VacationStatus.CANCELLED,
VacationStatus.REJECTED,
];
export function assertVacationApprovable(status: VacationStatus): void {
if (!approvableVacationStatuses.includes(status)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved",
});
}
}
export function assertVacationRejectable(status: VacationStatus): void {
if (status !== VacationStatus.PENDING) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Only PENDING vacations can be rejected",
});
}
}
export function assertVacationCancelable(status: VacationStatus): void {
if (status === VacationStatus.CANCELLED) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Already cancelled",
});
}
}
export function isVacationManagerRole(role: string | null | undefined): boolean {
return role === "ADMIN" || role === "MANAGER";
}
export function canActorCancelVacation(input: {
actorId: string;
actorRole: string | null | undefined;
requestedById: string | null | undefined;
resourceUserId: string | null | undefined;
}): boolean {
if (isVacationManagerRole(input.actorRole)) {
return true;
}
return input.requestedById === input.actorId || input.resourceUserId === input.actorId;
}
export function buildApprovedVacationUpdateData(input: {
deductionSnapshotWriteData: VacationApprovalWriteData;
approvedById?: string | undefined;
approvedAt: Date;
}): Prisma.VacationUncheckedUpdateInput {
return {
status: VacationStatus.APPROVED,
rejectionReason: null,
...input.deductionSnapshotWriteData,
...(input.approvedById ? { approvedById: input.approvedById } : {}),
approvedAt: input.approvedAt,
};
}
export function buildRejectedVacationUpdateData(input: {
rejectionReason?: string | undefined;
}): Prisma.VacationUncheckedUpdateInput {
return {
status: VacationStatus.REJECTED,
...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}),
};
}
export function buildVacationStatusUpdateData(input: {
status: UpdateVacationStatusInput["status"];
note?: string | undefined;
approvedById: string;
approvedAt: Date;
}): Prisma.VacationUncheckedUpdateInput {
const data: Prisma.VacationUncheckedUpdateInput = { status: input.status };
if (input.status === VacationStatus.APPROVED) {
data.approvedById = input.approvedById;
data.approvedAt = input.approvedAt;
data.rejectionReason = null;
}
if (input.note !== undefined) {
data.note = input.note;
}
return data;
}