refactor(application): extract vacation management into application use-cases

Moves approve, reject, cancel, and request vacation business logic
out of the tRPC procedure layer into packages/application, matching
the pattern used by allocation use-cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 16:49:45 +02:00
parent d3bfa8ca98
commit dda049075f
6 changed files with 477 additions and 138 deletions
+24
View File
@@ -120,6 +120,30 @@ export {
type RecomputeResourceValueScoresInput,
} from "./use-cases/resource/index.js";
export {
approveVacation,
batchApproveVacations,
rejectVacation,
batchRejectVacations,
cancelVacation,
type ApproveVacationInput,
type ApproveVacationResult,
type ApproveVacationDeps,
type BatchApproveVacationInput,
type BatchApproveVacationResult,
type BatchApproveVacationDeps,
type VacationChargeableInput,
type RejectVacationInput,
type RejectVacationResult,
type RejectVacationDeps,
type BatchRejectVacationInput,
type BatchRejectVacationResult,
type BatchRejectVacationDeps,
type CancelVacationInput,
type CancelVacationResult,
type CancelVacationDeps,
} from "./use-cases/vacation/index.js";
export {
calculateEffectiveAllocationCostCents,
calculateEffectiveAllocationHours,
@@ -0,0 +1,202 @@
import type { Prisma, PrismaClient, VacationStatus } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
type DbClient = Pick<
PrismaClient,
"vacation" | "user" | "resource" | "notification"
>;
export type VacationChargeableInput = {
resourceId: string;
type: string;
startDate: Date;
endDate: Date;
isHalfDay: boolean;
};
export type ApproveVacationDeps = {
assertVacationApprovable: (status: VacationStatus) => void;
assertVacationStillChargeable: (
db: DbClient,
vacation: VacationChargeableInput,
) => Promise<void>;
buildVacationApprovalWriteData: (
db: DbClient,
vacation: VacationChargeableInput,
) => Promise<Record<string, unknown>>;
checkVacationConflicts: (
db: DbClient,
vacationId: string,
actorUserId?: string,
) => Promise<{ warnings: string[] }>;
buildApprovedVacationUpdateData: (input: {
deductionSnapshotWriteData: Record<string, unknown>;
approvedById?: string | undefined;
approvedAt: Date;
}) => Prisma.VacationUncheckedUpdateInput;
};
export type ApproveVacationInput = {
id: string;
actorUserId?: string | undefined;
};
export type ApproveVacationResult = {
vacation: Awaited<ReturnType<PrismaClient["vacation"]["update"]>>;
existingStatus: VacationStatus;
warnings: string[];
};
export async function approveVacation(
db: DbClient,
input: ApproveVacationInput,
deps: ApproveVacationDeps,
): Promise<ApproveVacationResult> {
const existing = await db.vacation.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
}
deps.assertVacationApprovable(existing.status);
await deps.assertVacationStillChargeable(db, {
resourceId: existing.resourceId,
type: existing.type,
startDate: existing.startDate,
endDate: existing.endDate,
isHalfDay: existing.isHalfDay,
});
const deductionSnapshotWriteData = await deps.buildVacationApprovalWriteData(db, {
resourceId: existing.resourceId,
type: existing.type,
startDate: existing.startDate,
endDate: existing.endDate,
isHalfDay: existing.isHalfDay,
});
const conflictResult = await deps.checkVacationConflicts(
db,
input.id,
input.actorUserId,
);
const updated = await db.vacation.update({
where: { id: input.id },
data: deps.buildApprovedVacationUpdateData({
deductionSnapshotWriteData,
approvedById: input.actorUserId,
approvedAt: new Date(),
}),
});
return {
vacation: updated,
existingStatus: existing.status,
warnings: conflictResult.warnings,
};
}
export type BatchApproveVacationDeps = {
assertVacationStillChargeable: (
db: DbClient,
vacation: VacationChargeableInput,
) => Promise<void>;
buildVacationApprovalWriteData: (
db: DbClient,
vacation: VacationChargeableInput,
) => Promise<Record<string, unknown>>;
checkBatchVacationConflicts: (
db: DbClient,
vacationIds: string[],
actorUserId?: string,
) => Promise<Map<string, string[]>>;
buildApprovedVacationUpdateData: (input: {
deductionSnapshotWriteData: Record<string, unknown>;
approvedById?: string | undefined;
approvedAt: Date;
}) => Prisma.VacationUncheckedUpdateInput;
};
export type BatchApproveVacationInput = {
ids: string[];
actorUserId?: string | undefined;
};
export type BatchApproveVacationResult = {
approved: number;
warnings: string[];
updatedVacations: Array<{
id: string;
resourceId: string;
status: VacationStatus;
existingVacation: Awaited<ReturnType<PrismaClient["vacation"]["update"]>>;
}>;
};
export async function batchApproveVacations(
db: DbClient,
input: BatchApproveVacationInput,
deps: BatchApproveVacationDeps,
): Promise<BatchApproveVacationResult> {
const vacations: Array<{
id: string;
resourceId: string;
type: string;
startDate: Date;
endDate: Date;
isHalfDay: boolean;
}> = await db.vacation.findMany({
where: { id: { in: input.ids }, status: "PENDING" },
select: {
id: true,
resourceId: true,
type: true,
startDate: true,
endDate: true,
isHalfDay: true,
},
});
for (const vacation of vacations) {
await deps.assertVacationStillChargeable(db, vacation);
}
const conflictMap = await deps.checkBatchVacationConflicts(
db,
vacations.map((v) => v.id),
input.actorUserId,
);
const updatedVacations: BatchApproveVacationResult["updatedVacations"] = [];
for (const vacation of vacations) {
const deductionSnapshotWriteData = await deps.buildVacationApprovalWriteData(
db,
vacation,
);
const updated = await db.vacation.update({
where: { id: vacation.id },
data: deps.buildApprovedVacationUpdateData({
deductionSnapshotWriteData,
approvedById: input.actorUserId,
approvedAt: new Date(),
}),
});
updatedVacations.push({
id: updated.id,
resourceId: updated.resourceId,
status: updated.status,
existingVacation: updated,
});
}
const warnings: string[] = [];
for (const [, vacationWarnings] of conflictMap) {
warnings.push(...vacationWarnings);
}
return { approved: vacations.length, warnings, updatedVacations };
}
@@ -0,0 +1,73 @@
import type { PrismaClient, VacationStatus } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
type DbClient = Pick<PrismaClient, "vacation" | "resource">;
export type CancelVacationDeps = {
assertVacationCancelable: (status: VacationStatus) => void;
isVacationManagerRole: (role: string | null | undefined) => boolean;
canActorCancelVacation: (input: {
actorId: string;
actorRole: string | null | undefined;
requestedById: string | null | undefined;
resourceUserId: string | null | undefined;
}) => boolean;
};
export type CancelVacationInput = {
id: string;
actorId: string;
actorRole: string | null | undefined;
};
export type CancelVacationResult = {
vacation: Awaited<ReturnType<PrismaClient["vacation"]["update"]>>;
existingStatus: VacationStatus;
};
export async function cancelVacation(
db: DbClient,
input: CancelVacationInput,
deps: CancelVacationDeps,
): Promise<CancelVacationResult> {
const existing = await db.vacation.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
}
deps.assertVacationCancelable(existing.status);
// Only fetch the linked resource when the actor is not a manager and didn't
// originally request the vacation — we need to check resource ownership.
const needsResourceCheck =
!deps.isVacationManagerRole(input.actorRole) &&
existing.requestedById !== input.actorId;
const resource = needsResourceCheck
? await db.resource.findUnique({
where: { id: existing.resourceId },
select: { userId: true },
})
: null;
if (
!deps.canActorCancelVacation({
actorId: input.actorId,
actorRole: input.actorRole,
requestedById: existing.requestedById,
resourceUserId: resource?.userId,
})
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only cancel your own vacation requests",
});
}
const updated = await db.vacation.update({
where: { id: input.id },
data: { status: "CANCELLED" },
});
return { vacation: updated, existingStatus: existing.status };
}
@@ -0,0 +1,29 @@
export {
approveVacation,
batchApproveVacations,
type ApproveVacationInput,
type ApproveVacationResult,
type ApproveVacationDeps,
type BatchApproveVacationInput,
type BatchApproveVacationResult,
type BatchApproveVacationDeps,
type VacationChargeableInput,
} from "./approve-vacation.js";
export {
rejectVacation,
batchRejectVacations,
type RejectVacationInput,
type RejectVacationResult,
type RejectVacationDeps,
type BatchRejectVacationInput,
type BatchRejectVacationResult,
type BatchRejectVacationDeps,
} from "./reject-vacation.js";
export {
cancelVacation,
type CancelVacationInput,
type CancelVacationResult,
type CancelVacationDeps,
} from "./cancel-vacation.js";
@@ -0,0 +1,79 @@
import type { Prisma, PrismaClient, VacationStatus } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
type DbClient = Pick<PrismaClient, "vacation">;
export type RejectVacationDeps = {
assertVacationRejectable: (status: VacationStatus) => void;
buildRejectedVacationUpdateData: (input: {
rejectionReason?: string | undefined;
}) => Prisma.VacationUncheckedUpdateInput;
};
export type RejectVacationInput = {
id: string;
rejectionReason?: string | undefined;
};
export type RejectVacationResult = {
vacation: Awaited<ReturnType<PrismaClient["vacation"]["update"]>>;
};
export async function rejectVacation(
db: DbClient,
input: RejectVacationInput,
deps: RejectVacationDeps,
): Promise<RejectVacationResult> {
const existing = await db.vacation.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
}
deps.assertVacationRejectable(existing.status);
const updated = await db.vacation.update({
where: { id: input.id },
data: deps.buildRejectedVacationUpdateData({
rejectionReason: input.rejectionReason,
}),
});
return { vacation: updated };
}
export type BatchRejectVacationDeps = {
buildRejectedVacationUpdateData: (input: {
rejectionReason?: string | undefined;
}) => Prisma.VacationUncheckedUpdateInput;
};
export type BatchRejectVacationInput = {
ids: string[];
rejectionReason?: string | undefined;
};
export type BatchRejectVacationResult = {
rejected: number;
vacations: Array<{ id: string; resourceId: string }>;
};
export async function batchRejectVacations(
db: DbClient,
input: BatchRejectVacationInput,
deps: BatchRejectVacationDeps,
): Promise<BatchRejectVacationResult> {
const vacations: Array<{ id: string; resourceId: string }> =
await db.vacation.findMany({
where: { id: { in: input.ids }, status: "PENDING" },
select: { id: true, resourceId: true },
});
await db.vacation.updateMany({
where: { id: { in: vacations.map((v) => v.id) } },
data: deps.buildRejectedVacationUpdateData({
rejectionReason: input.rejectionReason,
}),
});
return { rejected: vacations.length, vacations };
}