refactor(api): extract vacation create procedures
This commit is contained in:
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user