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
@@ -1,11 +1,16 @@
import { UpdateVacationStatusSchema } from "@capakraken/shared";
import { VacationStatus } from "@capakraken/db";
import {
approveVacation,
batchApproveVacations,
batchRejectVacations,
cancelVacation,
rejectVacation,
} from "@capakraken/application";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { createAuditEntry } from "../lib/audit.js";
import { checkBatchVacationConflicts, checkVacationConflicts } from "../lib/vacation-conflicts.js";
import { emitVacationUpdated } from "../sse/event-bus.js";
import { adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import {
@@ -28,6 +33,7 @@ import {
canActorCancelVacation,
isVacationManagerRole,
} from "./vacation-management-support.js";
import { checkBatchVacationConflicts, checkVacationConflicts } from "../lib/vacation-conflicts.js";
const BatchCreatePublicHolidaysSchema = z.object({
year: z.number().int().min(2000).max(2100),
@@ -50,43 +56,25 @@ export const vacationManagementProcedures = {
approve: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await findUniqueOrThrow(
ctx.db.vacation.findUnique({ where: { id: input.id } }),
"Vacation",
);
assertVacationApprovable(existing.status);
await assertVacationStillChargeable(ctx.db, {
resourceId: existing.resourceId,
type: existing.type,
startDate: existing.startDate,
endDate: existing.endDate,
isHalfDay: existing.isHalfDay,
});
const deductionSnapshotWriteData = await buildVacationApprovalWriteData(ctx.db, {
resourceId: existing.resourceId,
type: existing.type,
startDate: existing.startDate,
endDate: existing.endDate,
isHalfDay: existing.isHalfDay,
});
const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email);
const conflictResult = await checkVacationConflicts(
const result = await approveVacation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ctx.db as any,
input.id,
userRecord?.id,
{ id: input.id, actorUserId: userRecord?.id },
{
assertVacationApprovable,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assertVacationStillChargeable: assertVacationStillChargeable as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
buildVacationApprovalWriteData: buildVacationApprovalWriteData as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
checkVacationConflicts: checkVacationConflicts as any,
buildApprovedVacationUpdateData,
},
);
const updated = await ctx.db.vacation.update({
where: { id: input.id },
data: buildApprovedVacationUpdateData({
deductionSnapshotWriteData,
approvedById: userRecord?.id,
approvedAt: new Date(),
}),
});
const { vacation: updated, existingStatus, warnings } = result;
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
@@ -97,10 +85,9 @@ export const vacationManagementProcedures = {
entityName: `Vacation ${updated.id}`,
action: "UPDATE",
...(userRecord?.id ? { userId: userRecord.id } : {}),
before: existing as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
summary: `Approved vacation (was ${existing.status})`,
summary: `Approved vacation (was ${existingStatus})`,
});
dispatchVacationWebhookInBackground(ctx.db, "vacation.approved", {
@@ -112,28 +99,23 @@ export const vacationManagementProcedures = {
await completeVacationApprovalTasks(ctx.db, input.id, userRecord?.id);
if (existing.status === VacationStatus.PENDING) {
if (existingStatus === VacationStatus.PENDING) {
notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
}
return { ...updated, warnings: conflictResult.warnings };
return { ...updated, warnings };
}),
reject: managerProcedure
.input(z.object({ id: z.string(), rejectionReason: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
const existing = await findUniqueOrThrow(
ctx.db.vacation.findUnique({ where: { id: input.id } }),
"Vacation",
const result = await rejectVacation(
ctx.db,
{ id: input.id, rejectionReason: input.rejectionReason },
{ assertVacationRejectable, buildRejectedVacationUpdateData },
);
assertVacationRejectable(existing.status);
const updated = await ctx.db.vacation.update({
where: { id: input.id },
data: buildRejectedVacationUpdateData({
rejectionReason: input.rejectionReason,
}),
});
const { vacation: updated } = result;
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
@@ -147,7 +129,6 @@ export const vacationManagementProcedures = {
entityName: `Vacation ${updated.id}`,
action: "UPDATE",
...(userRecord?.id ? { userId: userRecord.id } : {}),
before: existing as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
summary: `Rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`,
@@ -169,46 +150,22 @@ export const vacationManagementProcedures = {
.mutation(async ({ ctx, input }) => {
const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email);
const vacations = await ctx.db.vacation.findMany({
where: { id: { in: input.ids }, status: VacationStatus.PENDING },
select: {
id: true,
resourceId: true,
type: true,
startDate: true,
endDate: true,
isHalfDay: true,
},
});
for (const vacation of vacations) {
await assertVacationStillChargeable(ctx.db, vacation);
}
const conflictMap = await checkBatchVacationConflicts(
const result = await batchApproveVacations(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ctx.db as any,
vacations.map((vacation) => vacation.id),
userRecord?.id,
{ ids: input.ids, actorUserId: userRecord?.id },
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assertVacationStillChargeable: assertVacationStillChargeable as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
buildVacationApprovalWriteData: buildVacationApprovalWriteData as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
checkBatchVacationConflicts: checkBatchVacationConflicts as any,
buildApprovedVacationUpdateData,
},
);
for (const vacation of vacations) {
const deductionSnapshotWriteData = await buildVacationApprovalWriteData(ctx.db, {
resourceId: vacation.resourceId,
type: vacation.type,
startDate: vacation.startDate,
endDate: vacation.endDate,
isHalfDay: vacation.isHalfDay,
});
const updated = await ctx.db.vacation.update({
where: { id: vacation.id },
data: buildApprovedVacationUpdateData({
deductionSnapshotWriteData,
approvedById: userRecord?.id,
approvedAt: new Date(),
}),
});
for (const updated of result.updatedVacations) {
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
@@ -219,7 +176,7 @@ export const vacationManagementProcedures = {
entityName: `Vacation ${updated.id}`,
action: "UPDATE",
...(userRecord?.id ? { userId: userRecord.id } : {}),
after: updated as unknown as Record<string, unknown>,
after: updated.existingVacation as unknown as Record<string, unknown>,
source: "ui",
summary: "Batch approved vacation",
});
@@ -227,12 +184,7 @@ export const vacationManagementProcedures = {
await completeVacationApprovalTasks(ctx.db, updated.id, userRecord?.id);
}
const warnings: string[] = [];
for (const [, vacationWarnings] of conflictMap) {
warnings.push(...vacationWarnings);
}
return { approved: vacations.length, warnings };
return { approved: result.approved, warnings: result.warnings };
}),
batchReject: managerProcedure
@@ -245,19 +197,13 @@ export const vacationManagementProcedures = {
.mutation(async ({ ctx, input }) => {
const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email);
const vacations = await ctx.db.vacation.findMany({
where: { id: { in: input.ids }, status: VacationStatus.PENDING },
select: { id: true, resourceId: true },
});
const result = await batchRejectVacations(
ctx.db,
{ ids: input.ids, rejectionReason: input.rejectionReason },
{ buildRejectedVacationUpdateData },
);
await ctx.db.vacation.updateMany({
where: { id: { in: vacations.map((vacation) => vacation.id) } },
data: buildRejectedVacationUpdateData({
rejectionReason: input.rejectionReason,
}),
});
for (const vacation of vacations) {
for (const vacation of result.vacations) {
emitVacationUpdated({ id: vacation.id, resourceId: vacation.resourceId, status: VacationStatus.REJECTED });
notifyVacationStatusInBackground(
ctx.db,
@@ -282,45 +228,32 @@ export const vacationManagementProcedures = {
await completeVacationApprovalTasks(ctx.db, vacation.id, userRecord?.id);
}
return { rejected: vacations.length };
return { rejected: result.rejected };
}),
cancel: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await findUniqueOrThrow(
ctx.db.vacation.findUnique({ where: { id: input.id } }),
"Vacation",
);
assertVacationCancelable(existing.status);
const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email);
if (!userRecord) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const resource = isVacationManagerRole(userRecord.systemRole) || existing.requestedById === userRecord.id
? null
: await ctx.db.resource.findUnique({
where: { id: existing.resourceId },
select: { userId: true },
});
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 result = await cancelVacation(
ctx.db,
{
id: input.id,
actorId: userRecord.id,
actorRole: userRecord.systemRole,
},
{
assertVacationCancelable,
isVacationManagerRole,
canActorCancelVacation,
},
);
const updated = await ctx.db.vacation.update({
where: { id: input.id },
data: { status: VacationStatus.CANCELLED },
});
const { vacation: updated, existingStatus } = result;
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
@@ -331,10 +264,9 @@ export const vacationManagementProcedures = {
entityName: `Vacation ${updated.id}`,
action: "UPDATE",
userId: userRecord.id,
before: existing as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
summary: `Cancelled vacation (was ${existing.status})`,
summary: `Cancelled vacation (was ${existingStatus})`,
});
return updated;
@@ -386,10 +318,10 @@ export const vacationManagementProcedures = {
updateStatus: protectedProcedure
.input(UpdateVacationStatusSchema)
.mutation(async ({ ctx, input }) => {
const existing = await findUniqueOrThrow(
ctx.db.vacation.findUnique({ where: { id: input.id } }),
"Vacation",
);
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
}
const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email);
if (!userRecord) {
+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 };
}