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
@@ -18,6 +18,16 @@ import {
dispatchVacationWebhookInBackground,
notifyVacationStatusInBackground,
} from "./vacation-side-effects.js";
import {
assertVacationApprovable,
assertVacationCancelable,
assertVacationRejectable,
buildApprovedVacationUpdateData,
buildRejectedVacationUpdateData,
buildVacationStatusUpdateData,
canActorCancelVacation,
isVacationManagerRole,
} from "./vacation-management-support.js";
const BatchCreatePublicHolidaysSchema = z.object({
year: z.number().int().min(2000).max(2100),
@@ -44,10 +54,7 @@ export const vacationManagementProcedures = {
ctx.db.vacation.findUnique({ where: { id: input.id } }),
"Vacation",
);
const approvableStatuses: string[] = [VacationStatus.PENDING, VacationStatus.CANCELLED, VacationStatus.REJECTED];
if (!approvableStatuses.includes(existing.status)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved" });
}
assertVacationApprovable(existing.status);
await assertVacationStillChargeable(ctx.db, {
resourceId: existing.resourceId,
@@ -74,13 +81,11 @@ export const vacationManagementProcedures = {
const updated = await ctx.db.vacation.update({
where: { id: input.id },
data: {
status: VacationStatus.APPROVED,
rejectionReason: null,
...deductionSnapshotWriteData,
...(userRecord?.id ? { approvedById: userRecord.id } : {}),
data: buildApprovedVacationUpdateData({
deductionSnapshotWriteData,
approvedById: userRecord?.id,
approvedAt: new Date(),
},
}),
});
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 } }),
"Vacation",
);
if (existing.status !== VacationStatus.PENDING) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING vacations can be rejected" });
}
assertVacationRejectable(existing.status);
const updated = await ctx.db.vacation.update({
where: { id: input.id },
data: {
status: VacationStatus.REJECTED,
...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}),
},
data: buildRejectedVacationUpdateData({
rejectionReason: input.rejectionReason,
}),
});
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
@@ -200,13 +202,11 @@ export const vacationManagementProcedures = {
});
const updated = await ctx.db.vacation.update({
where: { id: vacation.id },
data: {
status: VacationStatus.APPROVED,
rejectionReason: null,
...deductionSnapshotWriteData,
...(userRecord?.id ? { approvedById: userRecord.id } : {}),
data: buildApprovedVacationUpdateData({
deductionSnapshotWriteData,
approvedById: userRecord?.id,
approvedAt: new Date(),
},
}),
});
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
@@ -252,10 +252,9 @@ export const vacationManagementProcedures = {
await ctx.db.vacation.updateMany({
where: { id: { in: vacations.map((vacation) => vacation.id) } },
data: {
status: VacationStatus.REJECTED,
...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}),
},
data: buildRejectedVacationUpdateData({
rejectionReason: input.rejectionReason,
}),
});
for (const vacation of vacations) {
@@ -293,28 +292,29 @@ export const vacationManagementProcedures = {
ctx.db.vacation.findUnique({ where: { id: input.id } }),
"Vacation",
);
if (existing.status === VacationStatus.CANCELLED) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Already cancelled" });
}
assertVacationCancelable(existing.status);
const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email);
if (!userRecord) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const isManagerOrAdmin = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
if (!isManagerOrAdmin) {
if (existing.requestedById !== userRecord.id) {
const resource = await ctx.db.resource.findUnique({
const resource = isVacationManagerRole(userRecord.systemRole) || existing.requestedById === userRecord.id
? null
: await ctx.db.resource.findUnique({
where: { id: existing.resourceId },
select: { userId: true },
});
if (!resource || resource.userId !== userRecord.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only cancel your own vacation requests",
});
}
}
if (!canActorCancelVacation({
actorId: userRecord.id,
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({
@@ -396,24 +396,18 @@ export const vacationManagementProcedures = {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
if (input.status !== "CANCELLED" && !isManager) {
if (input.status !== "CANCELLED" && !isVacationManagerRole(userRecord.systemRole)) {
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({
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 });