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
@@ -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 };
}