From dda049075fff72dc578c0215c660fc51f733be1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 16:49:45 +0200 Subject: [PATCH] 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 --- .../router/vacation-management-procedures.ts | 208 ++++++------------ packages/application/src/index.ts | 24 ++ .../use-cases/vacation/approve-vacation.ts | 202 +++++++++++++++++ .../src/use-cases/vacation/cancel-vacation.ts | 73 ++++++ .../src/use-cases/vacation/index.ts | 29 +++ .../src/use-cases/vacation/reject-vacation.ts | 79 +++++++ 6 files changed, 477 insertions(+), 138 deletions(-) create mode 100644 packages/application/src/use-cases/vacation/approve-vacation.ts create mode 100644 packages/application/src/use-cases/vacation/cancel-vacation.ts create mode 100644 packages/application/src/use-cases/vacation/index.ts create mode 100644 packages/application/src/use-cases/vacation/reject-vacation.ts diff --git a/packages/api/src/router/vacation-management-procedures.ts b/packages/api/src/router/vacation-management-procedures.ts index 822e24a..caa065a 100644 --- a/packages/api/src/router/vacation-management-procedures.ts +++ b/packages/api/src/router/vacation-management-procedures.ts @@ -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, after: updated as unknown as Record, 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, after: updated as unknown as Record, 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, + after: updated.existingVacation as unknown as Record, 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, after: updated as unknown as Record, 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) { diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 6c9781a..94543b4 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -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, diff --git a/packages/application/src/use-cases/vacation/approve-vacation.ts b/packages/application/src/use-cases/vacation/approve-vacation.ts new file mode 100644 index 0000000..b491519 --- /dev/null +++ b/packages/application/src/use-cases/vacation/approve-vacation.ts @@ -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; + buildVacationApprovalWriteData: ( + db: DbClient, + vacation: VacationChargeableInput, + ) => Promise>; + checkVacationConflicts: ( + db: DbClient, + vacationId: string, + actorUserId?: string, + ) => Promise<{ warnings: string[] }>; + buildApprovedVacationUpdateData: (input: { + deductionSnapshotWriteData: Record; + approvedById?: string | undefined; + approvedAt: Date; + }) => Prisma.VacationUncheckedUpdateInput; +}; + +export type ApproveVacationInput = { + id: string; + actorUserId?: string | undefined; +}; + +export type ApproveVacationResult = { + vacation: Awaited>; + existingStatus: VacationStatus; + warnings: string[]; +}; + +export async function approveVacation( + db: DbClient, + input: ApproveVacationInput, + deps: ApproveVacationDeps, +): Promise { + 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; + buildVacationApprovalWriteData: ( + db: DbClient, + vacation: VacationChargeableInput, + ) => Promise>; + checkBatchVacationConflicts: ( + db: DbClient, + vacationIds: string[], + actorUserId?: string, + ) => Promise>; + buildApprovedVacationUpdateData: (input: { + deductionSnapshotWriteData: Record; + 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>; + }>; +}; + +export async function batchApproveVacations( + db: DbClient, + input: BatchApproveVacationInput, + deps: BatchApproveVacationDeps, +): Promise { + 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 }; +} diff --git a/packages/application/src/use-cases/vacation/cancel-vacation.ts b/packages/application/src/use-cases/vacation/cancel-vacation.ts new file mode 100644 index 0000000..1f7738e --- /dev/null +++ b/packages/application/src/use-cases/vacation/cancel-vacation.ts @@ -0,0 +1,73 @@ +import type { PrismaClient, VacationStatus } from "@capakraken/db"; +import { TRPCError } from "@trpc/server"; + +type DbClient = Pick; + +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>; + existingStatus: VacationStatus; +}; + +export async function cancelVacation( + db: DbClient, + input: CancelVacationInput, + deps: CancelVacationDeps, +): Promise { + 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 }; +} diff --git a/packages/application/src/use-cases/vacation/index.ts b/packages/application/src/use-cases/vacation/index.ts new file mode 100644 index 0000000..bd5ee50 --- /dev/null +++ b/packages/application/src/use-cases/vacation/index.ts @@ -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"; diff --git a/packages/application/src/use-cases/vacation/reject-vacation.ts b/packages/application/src/use-cases/vacation/reject-vacation.ts new file mode 100644 index 0000000..5ea499e --- /dev/null +++ b/packages/application/src/use-cases/vacation/reject-vacation.ts @@ -0,0 +1,79 @@ +import type { Prisma, PrismaClient, VacationStatus } from "@capakraken/db"; +import { TRPCError } from "@trpc/server"; + +type DbClient = Pick; + +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>; +}; + +export async function rejectVacation( + db: DbClient, + input: RejectVacationInput, + deps: RejectVacationDeps, +): Promise { + 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 { + 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 }; +}