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 17:11:37 +02:00
10 changed files with 523 additions and 274 deletions
@@ -35,7 +35,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
};
});
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
import { executeTool } from "../router/assistant-tools.js";
import { createToolContext } from "./assistant-tools-vacation-entitlement-test-helpers.js";
describe("assistant vacation approval error paths", () => {
@@ -67,13 +67,22 @@ describe("assistant vacation approval error paths", () => {
});
it("returns a stable assistant error when vacation approval violates lifecycle preconditions", async () => {
const alreadyApprovedVacation = {
id: "vac_approved",
status: "APPROVED",
resource: { displayName: "Alice Example" },
};
const ctx = createToolContext(
{
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
vacation: {
findUnique: vi.fn().mockResolvedValue({
id: "vac_approved",
resource: { displayName: "Alice Example" },
}),
findUnique: vi.fn().mockResolvedValue(alreadyApprovedVacation),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
count: vi.fn().mockResolvedValue(0),
},
},
{ userRole: SystemRole.MANAGER },
@@ -82,30 +91,7 @@ describe("assistant vacation approval error paths", () => {
const result = await executeTool(
"approve_vacation",
JSON.stringify({ vacationId: "vac_approved" }),
{
...ctx,
db: {
...ctx.db,
vacation: {
...((ctx.db as Record<string, unknown>).vacation as Record<string, unknown>),
findUnique: vi.fn()
.mockResolvedValueOnce({
id: "vac_approved",
resource: { displayName: "Alice Example" },
})
.mockResolvedValueOnce({
id: "vac_approved",
resource: { displayName: "Alice Example" },
}),
update: vi.fn().mockRejectedValue(
new TRPCError({
code: "BAD_REQUEST",
message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved",
}),
),
},
} as ToolContext["db"],
},
ctx,
);
expect(JSON.parse(result.content)).toEqual({
@@ -36,7 +36,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
};
});
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
import { executeTool } from "../router/assistant-tools.js";
import { createToolContext } from "./assistant-tools-vacation-entitlement-test-helpers.js";
describe("assistant vacation cancellation error paths", () => {
@@ -68,14 +68,19 @@ describe("assistant vacation cancellation error paths", () => {
});
it("returns a stable assistant error when vacation cancellation violates lifecycle preconditions", async () => {
const alreadyCancelledVacation = {
id: "vac_cancelled",
status: VacationStatus.CANCELLED,
requestedById: "user_1",
resource: { displayName: "Alice Example", userId: "user_1" },
};
const ctx = createToolContext(
{
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
vacation: {
findUnique: vi.fn().mockResolvedValue({
id: "vac_cancelled",
requestedById: "user_1",
resource: { displayName: "Alice Example", userId: "user_1" },
}),
findUnique: vi.fn().mockResolvedValue(alreadyCancelledVacation),
},
},
{ userRole: SystemRole.USER, permissions: [] },
@@ -84,32 +89,7 @@ describe("assistant vacation cancellation error paths", () => {
const result = await executeTool(
"cancel_vacation",
JSON.stringify({ vacationId: "vac_cancelled" }),
{
...ctx,
db: {
...ctx.db,
vacation: {
...((ctx.db as Record<string, unknown>).vacation as Record<string, unknown>),
findUnique: vi.fn()
.mockResolvedValueOnce({
id: "vac_cancelled",
requestedById: "user_1",
resource: { displayName: "Alice Example", userId: "user_1" },
})
.mockResolvedValueOnce({
id: "vac_cancelled",
status: VacationStatus.CANCELLED,
resource: { displayName: "Alice Example" },
}),
update: vi.fn().mockRejectedValue(
new TRPCError({
code: "BAD_REQUEST",
message: "Already cancelled",
}),
),
},
} as ToolContext["db"],
},
ctx,
);
expect(JSON.parse(result.content)).toEqual({
@@ -72,13 +72,13 @@ describe("assistant vacation mutation tools", () => {
message: "Rejected vacation for Alice Example: Capacity freeze",
}),
);
expect(db.vacation.updateMany).toHaveBeenCalledWith(
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: "vac_cancelled" }),
data: expect.objectContaining({ status: "APPROVED" }),
}),
);
expect(db.vacation.updateMany).toHaveBeenCalledWith(
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: "vac_pending" }),
data: expect.objectContaining({ status: "REJECTED", rejectionReason: "Capacity freeze" }),
@@ -312,6 +312,7 @@ describe("vacation router", () => {
it("logs and swallows async notification failures during approval", async () => {
vi.mocked(createNotification).mockRejectedValueOnce(new Error("notification down"));
const approvedVacation = { ...sampleVacation, status: VacationStatus.APPROVED };
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
@@ -332,11 +333,7 @@ describe("vacation router", () => {
...sampleVacation,
status: VacationStatus.PENDING,
}),
findUniqueOrThrow: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
update: vi.fn().mockResolvedValue(approvedVacation),
},
});
@@ -361,6 +358,7 @@ describe("vacation router", () => {
it("logs and swallows webhook failures during approval", async () => {
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook down"));
const approvedVacation = { ...sampleVacation, status: VacationStatus.APPROVED };
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
@@ -381,11 +379,7 @@ describe("vacation router", () => {
...sampleVacation,
status: VacationStatus.PENDING,
}),
findUniqueOrThrow: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
update: vi.fn().mockResolvedValue(approvedVacation),
},
});
@@ -899,8 +893,7 @@ describe("vacation router", () => {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
findUniqueOrThrow: vi.fn().mockResolvedValue(updatedVacation),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
update: vi.fn().mockResolvedValue(updatedVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
@@ -914,7 +907,7 @@ describe("vacation router", () => {
const result = await caller.approve({ id: "vac_1" });
expect(result.status).toBe(VacationStatus.APPROVED);
expect(db.vacation.updateMany).toHaveBeenCalledWith(
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: "vac_1" }),
data: expect.objectContaining({
@@ -1020,8 +1013,7 @@ describe("vacation router", () => {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
findUniqueOrThrow: vi.fn().mockResolvedValue(updatedVacation),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
update: vi.fn().mockResolvedValue(updatedVacation),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
@@ -1032,7 +1024,7 @@ describe("vacation router", () => {
const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" });
expect(result.status).toBe(VacationStatus.REJECTED);
expect(db.vacation.updateMany).toHaveBeenCalledWith(
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: "vac_1" }),
data: expect.objectContaining({
@@ -1,12 +1,17 @@
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 { VACATION_BALANCE_TYPES } from "../lib/vacation-deduction-snapshot.js";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { makeAuditLogger } from "../lib/audit-helpers.js";
import { checkBatchVacationConflicts, checkVacationConflicts, type DbClient as VacationConflictDbClient } from "../lib/vacation-conflicts.js";
import { checkBatchVacationConflicts, checkVacationConflicts } from "../lib/vacation-conflicts.js";
import { emitVacationUpdated } from "../sse/event-bus.js";
import { adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import {
@@ -20,7 +25,6 @@ import {
notifyVacationStatusInBackground,
} from "./vacation-side-effects.js";
import {
approvableVacationStatuses,
assertVacationApprovable,
assertVacationCancelable,
assertVacationRejectable,
@@ -52,48 +56,26 @@ 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 audit = makeAuditLogger(ctx.db, userRecord?.id);
const conflictResult = await checkVacationConflicts(
ctx.db as unknown as VacationConflictDbClient,
input.id,
userRecord?.id,
const result = await approveVacation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ctx.db as any,
{ 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 approveResult = await ctx.db.vacation.updateMany({
where: { id: input.id, status: { in: approvableVacationStatuses as VacationStatus[] } },
data: buildApprovedVacationUpdateData({
deductionSnapshotWriteData,
approvedById: userRecord?.id,
approvedAt: new Date(),
}),
});
if (approveResult.count === 0) {
throw new TRPCError({ code: "CONFLICT", message: "Vacation was already processed by another request" });
}
const updated = await ctx.db.vacation.findUniqueOrThrow({ where: { id: input.id } });
const { vacation: updated, existingStatus, warnings } = result;
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
@@ -102,9 +84,8 @@ export const vacationManagementProcedures = {
entityId: updated.id,
entityName: `Vacation ${updated.id}`,
action: "UPDATE",
before: existing as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
summary: `Approved vacation (was ${existing.status})`,
summary: `Approved vacation (was ${existingStatus})`,
});
dispatchVacationWebhookInBackground(ctx.db, "vacation.approved", {
@@ -116,33 +97,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 rejectResult = await ctx.db.vacation.updateMany({
where: { id: input.id, status: VacationStatus.PENDING },
data: buildRejectedVacationUpdateData({
rejectionReason: input.rejectionReason,
}),
});
if (rejectResult.count === 0) {
throw new TRPCError({ code: "CONFLICT", message: "Vacation was already processed by another request" });
}
const updated = await ctx.db.vacation.findUniqueOrThrow({ where: { id: input.id } });
const { vacation: updated } = result;
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
@@ -155,7 +126,6 @@ export const vacationManagementProcedures = {
entityId: updated.id,
entityName: `Vacation ${updated.id}`,
action: "UPDATE",
before: existing as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
summary: `Rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`,
});
@@ -177,83 +147,36 @@ export const vacationManagementProcedures = {
const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email);
const audit = makeAuditLogger(ctx.db, userRecord?.id);
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(
ctx.db as unknown as VacationConflictDbClient,
vacations.map((vacation) => vacation.id),
userRecord?.id,
);
// Pre-compute read-only deduction data before opening the transaction
const approvalPayloads = await Promise.all(
vacations.map(async (vacation) => ({
vacation,
writeData: await buildVacationApprovalWriteData(ctx.db, {
resourceId: vacation.resourceId,
type: vacation.type,
startDate: vacation.startDate,
endDate: vacation.endDate,
isHalfDay: vacation.isHalfDay,
}),
})),
);
// Execute all writes atomically, collect side-effect payloads
const approvedNow: Array<{ id: string; resourceId: string; status: typeof VacationStatus.APPROVED }> = [];
const approvedAt = new Date();
await ctx.db.$transaction(async (tx) => {
approvedNow.length = 0;
for (const { vacation, writeData } of approvalPayloads) {
const updated = await tx.vacation.update({
where: { id: vacation.id },
data: buildApprovedVacationUpdateData({
deductionSnapshotWriteData: writeData,
approvedById: userRecord?.id,
approvedAt,
}),
});
const result = await batchApproveVacations(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ctx.db as any,
{ ids: input.ids, actorUserId: userRecord?.id },
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await completeVacationApprovalTasks(tx as any, updated.id, userRecord?.id);
approvedNow.push({ id: updated.id, resourceId: updated.resourceId, status: VacationStatus.APPROVED });
}
});
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,
},
);
// Side effects — dispatched after the transaction commits
for (const entry of approvedNow) {
emitVacationUpdated(entry);
notifyVacationStatusInBackground(ctx.db, entry.id, entry.resourceId, VacationStatus.APPROVED);
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);
await completeVacationApprovalTasks(ctx.db, updated.id, userRecord?.id);
audit({
entityType: "Vacation",
entityId: entry.id,
entityName: `Vacation ${entry.id}`,
entityId: updated.id,
entityName: `Vacation ${updated.id}`,
action: "UPDATE",
after: entry as unknown as Record<string, unknown>,
after: updated.existingVacation as unknown as Record<string, unknown>,
summary: "Batch approved vacation",
});
}
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
@@ -267,19 +190,13 @@ export const vacationManagementProcedures = {
const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email);
const audit = makeAuditLogger(ctx.db, userRecord?.id);
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,
@@ -301,65 +218,33 @@ 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);
const audit = makeAuditLogger(ctx.db, userRecord?.id);
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 wasApproved = existing.status === VacationStatus.APPROVED;
const shouldReverseEntitlement =
wasApproved &&
VACATION_BALANCE_TYPES.has(existing.type) &&
typeof existing.deductedDays === "number" &&
existing.deductedDays > 0;
const updated = await ctx.db.$transaction(async (tx) => {
const cancelledVacation = await tx.vacation.update({
where: { id: input.id },
data: { status: VacationStatus.CANCELLED },
});
if (shouldReverseEntitlement) {
const year = existing.startDate.getFullYear();
await tx.vacationEntitlement.updateMany({
where: { resourceId: existing.resourceId, year },
data: { usedDays: { decrement: existing.deductedDays as number } },
});
}
return cancelledVacation;
});
const { vacation: updated, existingStatus } = result;
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
@@ -368,9 +253,8 @@ export const vacationManagementProcedures = {
entityId: updated.id,
entityName: `Vacation ${updated.id}`,
action: "UPDATE",
before: existing as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
summary: `Cancelled vacation (was ${existing.status})`,
summary: `Cancelled vacation (was ${existingStatus})`,
});
return updated;
@@ -420,10 +304,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);
const audit = makeAuditLogger(ctx.db, userRecord?.id);
+24
View File
@@ -121,6 +121,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 };
}