import { VacationStatus, VacationType } from "@capakraken/db"; import { TRPCError } from "@trpc/server"; import { isAdminOrManager } from "@capakraken/shared"; 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; type VacationCreateContext = Pick; 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 = isAdminOrManager(userRecord.systemRole); 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, 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 }; }