refactor(api): extract vacation create procedures
This commit is contained in:
@@ -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 { getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import { resolveVacationCreationChargeability } from "./vacation-chargeability.js";
|
||||
import {
|
||||
createVacationRequest,
|
||||
CreateVacationRequestSchema,
|
||||
} from "./vacation-create-support.js";
|
||||
import { vacationManagementProcedures } from "./vacation-management-procedures.js";
|
||||
import { VACATION_BALANCE_TYPES } from "../lib/vacation-deduction-snapshot.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"],
|
||||
});
|
||||
}
|
||||
});
|
||||
import { vacationReadProcedures } from "./vacation-read.js";
|
||||
|
||||
export const vacationRouter = createTRPCRouter({
|
||||
...vacationReadProcedures,
|
||||
@@ -60,110 +12,5 @@ export const vacationRouter = createTRPCRouter({
|
||||
|
||||
create: protectedProcedure
|
||||
.input(CreateVacationRequestSchema)
|
||||
.mutation(async ({ 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 };
|
||||
}),
|
||||
.mutation(({ ctx, input }) => createVacationRequest(ctx, input)),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user