refactor(api): extract vacation create procedures

This commit is contained in:
2026-03-31 19:00:47 +02:00
parent d61527b38c
commit d5b52e5959
3 changed files with 381 additions and 159 deletions
@@ -0,0 +1,207 @@
import { VacationStatus, VacationType } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createVacationRequest,
CreateVacationRequestSchema,
} from "../router/vacation-create-support.js";
const {
emitVacationCreated,
createAuditEntry,
createVacationApprovalTasks,
getAnonymizationDirectory,
resolveVacationCreationChargeability,
} = vi.hoisted(() => ({
emitVacationCreated: vi.fn(),
createAuditEntry: vi.fn(),
createVacationApprovalTasks: vi.fn(),
getAnonymizationDirectory: vi.fn(),
resolveVacationCreationChargeability: vi.fn(),
}));
vi.mock("../sse/event-bus.js", () => ({
emitVacationCreated,
}));
vi.mock("../lib/audit.js", () => ({
createAuditEntry,
}));
vi.mock("../lib/anonymization.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/anonymization.js")>();
return {
...actual,
anonymizeResource: vi.fn((value) => value),
anonymizeUser: vi.fn((value) => value),
getAnonymizationDirectory,
};
});
vi.mock("../router/vacation-chargeability.js", () => ({
resolveVacationCreationChargeability,
}));
vi.mock("../router/vacation-side-effects.js", () => ({
createVacationApprovalTasks,
}));
function createContext(overrides: Record<string, unknown> = {}) {
return {
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({
id: "vac_1",
resourceId: "res_1",
type: VacationType.ANNUAL,
status: VacationStatus.PENDING,
startDate: new Date("2026-06-01T00:00:00.000Z"),
endDate: new Date("2026-06-03T00:00:00.000Z"),
isHalfDay: false,
halfDayPart: null,
resource: { id: "res_1", displayName: "Alice", eid: "E-001" },
requestedBy: { id: "user_1", name: "User", email: "user@example.com" },
}),
},
},
...overrides,
};
}
describe("vacation create support", () => {
beforeEach(() => {
emitVacationCreated.mockReset();
createAuditEntry.mockReset();
createVacationApprovalTasks.mockReset();
getAnonymizationDirectory.mockReset();
resolveVacationCreationChargeability.mockReset();
getAnonymizationDirectory.mockResolvedValue({
resourcesById: new Map(),
usersById: new Map(),
});
resolveVacationCreationChargeability.mockResolvedValue({
effectiveDays: 3,
deductionSnapshotWriteData: { deductedDays: 3, deductionSnapshot: { source: "calendar" } },
});
});
it("validates half-day requests against cross-day ranges", () => {
expect(() => CreateVacationRequestSchema.parse({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: "2026-06-01",
endDate: "2026-06-02",
isHalfDay: true,
halfDayPart: "MORNING",
})).toThrowError(/Half-day requests must start and end on the same day/);
});
it("creates pending vacations for regular users and schedules approval tasks", async () => {
const ctx = createContext();
const result = await createVacationRequest(ctx as never, {
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01T00:00:00.000Z"),
endDate: new Date("2026-06-03T00:00:00.000Z"),
note: "Summer",
isHalfDay: false,
});
expect(ctx.db.vacation.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.PENDING,
requestedById: "user_1",
deductedDays: 3,
}),
}));
expect(createVacationApprovalTasks).toHaveBeenCalledWith(expect.objectContaining({
submittedByUserId: "user_1",
vacationId: "vac_1",
}));
expect(emitVacationCreated).toHaveBeenCalledWith({
id: "vac_1",
resourceId: "res_1",
status: VacationStatus.PENDING,
});
expect(result).toMatchObject({
id: "vac_1",
effectiveDays: 3,
});
});
it("auto-approves manager-created vacations without approval tasks", async () => {
const ctx = createContext({
db: {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
resource: {
findUnique: vi.fn(),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({
id: "vac_mgr",
resourceId: "res_2",
type: VacationType.ANNUAL,
status: VacationStatus.APPROVED,
startDate: new Date("2026-07-01T00:00:00.000Z"),
endDate: new Date("2026-07-02T00:00:00.000Z"),
isHalfDay: false,
halfDayPart: null,
resource: { id: "res_2", displayName: "Bob", eid: "E-002" },
requestedBy: { id: "mgr_1", name: "Manager", email: "user@example.com" },
}),
},
},
});
await createVacationRequest(ctx as never, {
resourceId: "res_2",
type: VacationType.ANNUAL,
startDate: new Date("2026-07-01T00:00:00.000Z"),
endDate: new Date("2026-07-02T00:00:00.000Z"),
isHalfDay: false,
});
expect(ctx.db.resource.findUnique).not.toHaveBeenCalled();
expect(ctx.db.vacation.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.APPROVED,
approvedById: "mgr_1",
}),
}));
expect(createVacationApprovalTasks).not.toHaveBeenCalled();
});
it("rejects manual public holiday creation before hitting the database", async () => {
const ctx = createContext();
await expect(createVacationRequest(ctx as never, {
resourceId: "res_1",
type: VacationType.PUBLIC_HOLIDAY,
startDate: new Date("2026-12-25T00:00:00.000Z"),
endDate: new Date("2026-12-25T00:00:00.000Z"),
isHalfDay: false,
})).rejects.toThrowError(new TRPCError({
code: "BAD_REQUEST",
message: "Public holidays must be managed via Holiday Calendars or the legacy holiday import, not via manual vacation requests",
}));
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
expect(ctx.db.vacation.create).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,168 @@
import { VacationStatus, VacationType } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { emitVacationCreated } from "../sse/event-bus.js";
import { type TRPCContext } from "../trpc.js";
import { getAnonymizationDirectory } from "../lib/anonymization.js";
import { createAuditEntry } from "../lib/audit.js";
import { VACATION_BALANCE_TYPES } from "../lib/vacation-deduction-snapshot.js";
import { resolveVacationCreationChargeability } from "./vacation-chargeability.js";
import { anonymizeVacationRecord, isSameUtcDay } from "./vacation-read.js";
import { createVacationApprovalTasks } from "./vacation-side-effects.js";
export const CreateVacationRequestSchema = z.object({
resourceId: z.string(),
type: z.nativeEnum(VacationType),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
note: z.string().max(500).optional(),
isHalfDay: z.boolean().optional(),
halfDayPart: z.enum(["MORNING", "AFTERNOON"]).optional(),
}).superRefine((data, ctx) => {
if (data.endDate < data.startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End date must be after start date",
path: ["endDate"],
});
}
if (data.isHalfDay && !isSameUtcDay(data.startDate, data.endDate)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Half-day requests must start and end on the same day",
path: ["isHalfDay"],
});
}
if (data.isHalfDay && !data.halfDayPart) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Half-day requests require a half-day part",
path: ["halfDayPart"],
});
}
if (!data.isHalfDay && data.halfDayPart) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Half-day part is only allowed for half-day requests",
path: ["halfDayPart"],
});
}
});
export type CreateVacationRequestInput = z.infer<typeof CreateVacationRequestSchema>;
type VacationCreateContext = Pick<TRPCContext, "db" | "session">;
export async function createVacationRequest(
ctx: VacationCreateContext,
input: CreateVacationRequestInput,
) {
if (input.type === VacationType.PUBLIC_HOLIDAY) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Public holidays must be managed via Holiday Calendars or the legacy holiday import, not via manual vacation requests",
});
}
const userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session?.user?.email ?? "" },
select: { id: true, systemRole: true },
});
if (!userRecord) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
if (!isManager) {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { userId: true },
});
if (!resource || resource.userId !== userRecord.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only create vacation requests for your own resource",
});
}
}
const overlapping = await ctx.db.vacation.findFirst({
where: {
resourceId: input.resourceId,
status: { in: ["APPROVED", "PENDING"] },
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
...(VACATION_BALANCE_TYPES.has(input.type)
? { type: { not: VacationType.PUBLIC_HOLIDAY } }
: {}),
},
});
if (overlapping) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Overlapping vacation already exists for this resource in the selected period",
});
}
const { effectiveDays, deductionSnapshotWriteData } = await resolveVacationCreationChargeability(ctx.db, {
resourceId: input.resourceId,
type: input.type,
startDate: input.startDate,
endDate: input.endDate,
isHalfDay: input.isHalfDay ?? false,
});
const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING;
const vacation = await ctx.db.vacation.create({
data: {
resourceId: input.resourceId,
type: input.type,
status,
startDate: input.startDate,
endDate: input.endDate,
...(input.note !== undefined ? { note: input.note } : {}),
isHalfDay: input.isHalfDay ?? false,
...(input.halfDayPart !== undefined ? { halfDayPart: input.halfDayPart } : {}),
...(deductionSnapshotWriteData ?? { deductedDays: 0 }),
requestedById: userRecord.id,
...(isManager ? { approvedById: userRecord.id, approvedAt: new Date() } : {}),
},
include: {
resource: { select: RESOURCE_BRIEF_SELECT },
requestedBy: { select: { id: true, name: true, email: true } },
},
});
emitVacationCreated({ id: vacation.id, resourceId: vacation.resourceId, status: vacation.status });
void createAuditEntry({
db: ctx.db,
entityType: "Vacation",
entityId: vacation.id,
entityName: `${vacation.resource?.displayName ?? "Unknown"} - ${vacation.type}`,
action: "CREATE",
userId: userRecord.id,
after: vacation as unknown as Record<string, unknown>,
source: "ui",
});
if (status === VacationStatus.PENDING) {
await createVacationApprovalTasks({
db: ctx.db,
submittedByUserId: userRecord.id,
vacationId: vacation.id,
resourceName: vacation.resource?.displayName ?? "Unknown",
vacationType: input.type,
startDate: input.startDate,
endDate: input.endDate,
});
}
const directory = await getAnonymizationDirectory(ctx.db);
const result = anonymizeVacationRecord(vacation, directory);
return effectiveDays === null ? result : { ...result, effectiveDays };
}
+6 -159
View File
@@ -1,58 +1,10 @@
import { VacationStatus, VacationType } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { emitVacationCreated } from "../sse/event-bus.js";
import { createTRPCRouter, protectedProcedure } from "../trpc.js"; import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { getAnonymizationDirectory } from "../lib/anonymization.js"; import {
import { createAuditEntry } from "../lib/audit.js"; createVacationRequest,
import { resolveVacationCreationChargeability } from "./vacation-chargeability.js"; CreateVacationRequestSchema,
} from "./vacation-create-support.js";
import { vacationManagementProcedures } from "./vacation-management-procedures.js"; import { vacationManagementProcedures } from "./vacation-management-procedures.js";
import { VACATION_BALANCE_TYPES } from "../lib/vacation-deduction-snapshot.js"; import { vacationReadProcedures } from "./vacation-read.js";
import { anonymizeVacationRecord, isSameUtcDay, vacationReadProcedures } from "./vacation-read.js";
import { createVacationApprovalTasks } from "./vacation-side-effects.js";
const CreateVacationRequestSchema = z.object({
resourceId: z.string(),
type: z.nativeEnum(VacationType),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
note: z.string().max(500).optional(),
isHalfDay: z.boolean().optional(),
halfDayPart: z.enum(["MORNING", "AFTERNOON"]).optional(),
}).superRefine((data, ctx) => {
if (data.endDate < data.startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End date must be after start date",
path: ["endDate"],
});
}
if (data.isHalfDay && !isSameUtcDay(data.startDate, data.endDate)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Half-day requests must start and end on the same day",
path: ["isHalfDay"],
});
}
if (data.isHalfDay && !data.halfDayPart) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Half-day requests require a half-day part",
path: ["halfDayPart"],
});
}
if (!data.isHalfDay && data.halfDayPart) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Half-day part is only allowed for half-day requests",
path: ["halfDayPart"],
});
}
});
export const vacationRouter = createTRPCRouter({ export const vacationRouter = createTRPCRouter({
...vacationReadProcedures, ...vacationReadProcedures,
@@ -60,110 +12,5 @@ export const vacationRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(CreateVacationRequestSchema) .input(CreateVacationRequestSchema)
.mutation(async ({ ctx, input }) => { .mutation(({ ctx, input }) => createVacationRequest(ctx, input)),
if (input.type === VacationType.PUBLIC_HOLIDAY) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Public holidays must be managed via Holiday Calendars or the legacy holiday import, not via manual vacation requests",
});
}
const userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true, systemRole: true },
});
if (!userRecord) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
if (!isManager) {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { userId: true },
});
if (!resource || resource.userId !== userRecord.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only create vacation requests for your own resource",
});
}
}
const overlapping = await ctx.db.vacation.findFirst({
where: {
resourceId: input.resourceId,
status: { in: ["APPROVED", "PENDING"] },
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
...(VACATION_BALANCE_TYPES.has(input.type)
? { type: { not: VacationType.PUBLIC_HOLIDAY } }
: {}),
},
});
if (overlapping) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Overlapping vacation already exists for this resource in the selected period",
});
}
const { effectiveDays, deductionSnapshotWriteData } = await resolveVacationCreationChargeability(ctx.db, {
resourceId: input.resourceId,
type: input.type,
startDate: input.startDate,
endDate: input.endDate,
isHalfDay: input.isHalfDay ?? false,
});
const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING;
const vacation = await ctx.db.vacation.create({
data: {
resourceId: input.resourceId,
type: input.type,
status,
startDate: input.startDate,
endDate: input.endDate,
...(input.note !== undefined ? { note: input.note } : {}),
isHalfDay: input.isHalfDay ?? false,
...(input.halfDayPart !== undefined ? { halfDayPart: input.halfDayPart } : {}),
...(deductionSnapshotWriteData ?? { deductedDays: 0 }),
requestedById: userRecord.id,
...(isManager ? { approvedById: userRecord.id, approvedAt: new Date() } : {}),
},
include: {
resource: { select: RESOURCE_BRIEF_SELECT },
requestedBy: { select: { id: true, name: true, email: true } },
},
});
emitVacationCreated({ id: vacation.id, resourceId: vacation.resourceId, status: vacation.status });
void createAuditEntry({
db: ctx.db,
entityType: "Vacation",
entityId: vacation.id,
entityName: `${vacation.resource?.displayName ?? "Unknown"} - ${vacation.type}`,
action: "CREATE",
userId: userRecord.id,
after: vacation as unknown as Record<string, unknown>,
source: "ui",
});
if (status === VacationStatus.PENDING) {
await createVacationApprovalTasks({
db: ctx.db,
submittedByUserId: userRecord.id,
vacationId: vacation.id,
resourceName: vacation.resource?.displayName ?? "Unknown",
vacationType: input.type,
startDate: input.startDate,
endDate: input.endDate,
});
}
const directory = await getAnonymizationDirectory(ctx.db);
const result = anonymizeVacationRecord(vacation, directory);
return effectiveDays === null ? result : { ...result, effectiveDays };
}),
}); });