diff --git a/packages/api/src/__tests__/assistant-tools-vacation-approval-errors.test.ts b/packages/api/src/__tests__/assistant-tools-vacation-approval-errors.test.ts index 214efd9..c2818d4 100644 --- a/packages/api/src/__tests__/assistant-tools-vacation-approval-errors.test.ts +++ b/packages/api/src/__tests__/assistant-tools-vacation-approval-errors.test.ts @@ -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).vacation as Record), - 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({ diff --git a/packages/api/src/__tests__/assistant-tools-vacation-cancellation-errors.test.ts b/packages/api/src/__tests__/assistant-tools-vacation-cancellation-errors.test.ts index c65be47..3cc0471 100644 --- a/packages/api/src/__tests__/assistant-tools-vacation-cancellation-errors.test.ts +++ b/packages/api/src/__tests__/assistant-tools-vacation-cancellation-errors.test.ts @@ -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).vacation as Record), - 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({ diff --git a/packages/api/src/__tests__/assistant-tools-vacation-review-cancel.test.ts b/packages/api/src/__tests__/assistant-tools-vacation-review-cancel.test.ts index 17a6912..32f4050 100644 --- a/packages/api/src/__tests__/assistant-tools-vacation-review-cancel.test.ts +++ b/packages/api/src/__tests__/assistant-tools-vacation-review-cancel.test.ts @@ -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" }), diff --git a/packages/api/src/__tests__/vacation-router.test.ts b/packages/api/src/__tests__/vacation-router.test.ts index 012fe4f..1149c15 100644 --- a/packages/api/src/__tests__/vacation-router.test.ts +++ b/packages/api/src/__tests__/vacation-router.test.ts @@ -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({ diff --git a/packages/api/src/router/vacation-management-procedures.ts b/packages/api/src/router/vacation-management-procedures.ts index e4e8aa1..8e1db35 100644 --- a/packages/api/src/router/vacation-management-procedures.ts +++ b/packages/api/src/router/vacation-management-procedures.ts @@ -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, after: updated as unknown as Record, - 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, after: updated as unknown as Record, 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, + after: updated.existingVacation as unknown as Record, 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, after: updated as unknown as Record, - 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); diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 5d41763..feb07a8 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -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, 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 }; +}