refactor(api): extract vacation management support
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user