refactor(api): extract vacation chargeability helpers

This commit is contained in:
2026-03-31 11:08:58 +02:00
parent 3e0d9d9af7
commit 656c3d0ee5
3 changed files with 219 additions and 156 deletions
@@ -0,0 +1,84 @@
import { TRPCError } from "@trpc/server";
import { countCalendarDaysInPeriod } from "../lib/vacation-day-count.js";
import {
VACATION_BALANCE_TYPES,
buildVacationDeductionSnapshotWriteData,
calculateVacationDeductionSnapshot,
type VacationChargeableInput,
} from "../lib/vacation-deduction-snapshot.js";
import type { TRPCContext } from "../trpc.js";
type VacationDb = TRPCContext["db"];
type VacationDeductionWriteData = ReturnType<typeof buildVacationDeductionSnapshotWriteData>;
export async function calculateVacationEffectiveDays(
db: VacationDb,
vacation: VacationChargeableInput,
): Promise<number> {
if (!VACATION_BALANCE_TYPES.has(vacation.type)) {
return countCalendarDaysInPeriod(vacation);
}
const snapshot = await calculateVacationDeductionSnapshot(db, vacation);
return snapshot.deductedDays;
}
export async function assertVacationStillChargeable(
db: VacationDb,
vacation: VacationChargeableInput,
): Promise<void> {
if (!VACATION_BALANCE_TYPES.has(vacation.type)) {
return;
}
const effectiveDays = await calculateVacationEffectiveDays(db, vacation);
if (effectiveDays <= 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Vacation no longer deducts any vacation days for the current holiday calendar and cannot be approved",
});
}
}
export async function resolveVacationCreationChargeability(
db: VacationDb,
vacation: VacationChargeableInput,
): Promise<{
effectiveDays: number | null;
deductionSnapshotWriteData: VacationDeductionWriteData | null;
}> {
if (!VACATION_BALANCE_TYPES.has(vacation.type)) {
return {
effectiveDays: null,
deductionSnapshotWriteData: null,
};
}
const deductionSnapshot = await calculateVacationDeductionSnapshot(db, vacation);
const effectiveDays = deductionSnapshot.deductedDays;
if (effectiveDays <= 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Selected vacation period only contains public holidays and does not deduct any vacation days",
});
}
return {
effectiveDays,
deductionSnapshotWriteData: buildVacationDeductionSnapshotWriteData(deductionSnapshot),
};
}
export async function buildVacationApprovalWriteData(
db: VacationDb,
vacation: VacationChargeableInput,
): Promise<VacationDeductionWriteData | { deductedDays: 0 }> {
if (!VACATION_BALANCE_TYPES.has(vacation.type)) {
return { deductedDays: 0 };
}
return buildVacationDeductionSnapshotWriteData(
await calculateVacationDeductionSnapshot(db, vacation),
);
}
@@ -0,0 +1,98 @@
import { VacationStatus, VacationType } from "@capakraken/db";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import type { TRPCContext } from "../trpc.js";
type VacationDb = TRPCContext["db"];
export type BatchCreatePublicHolidaysInput = {
year: number;
federalState?: string | undefined;
chapter?: string | undefined;
replaceExisting: boolean;
};
export async function batchCreatePublicHolidayVacations(
db: VacationDb,
input: BatchCreatePublicHolidaysInput,
adminUserId: string,
): Promise<{ created: number; holidays: number; resources: number }> {
const resources = await db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: {
id: true,
federalState: true,
countryId: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
if (resources.length === 0) {
return { created: 0, holidays: 0, resources: 0 };
}
let created = 0;
let holidayCount = 0;
for (const resource of resources) {
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: input.federalState ?? resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
});
holidayCount += holidays.length;
for (const holiday of holidays) {
const startDate = new Date(holiday.date);
const endDate = new Date(holiday.date);
if (input.replaceExisting) {
await db.vacation.deleteMany({
where: {
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
startDate,
endDate,
},
});
}
const exists = await db.vacation.findFirst({
where: {
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
startDate,
endDate,
},
});
if (exists) {
continue;
}
await db.vacation.create({
data: {
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
status: VacationStatus.APPROVED,
startDate,
endDate,
note: holiday.name,
requestedById: adminUserId,
approvedById: adminUserId,
approvedAt: new Date(),
},
});
created++;
}
}
return { created, holidays: holidayCount, resources: resources.length };
}
+37 -156
View File
@@ -9,15 +9,17 @@ import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure
import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { getAnonymizationDirectory } from "../lib/anonymization.js";
import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js"; import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js";
import { createAuditEntry } from "../lib/audit.js"; import { createAuditEntry } from "../lib/audit.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; import {
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js"; buildVacationApprovalWriteData,
resolveVacationCreationChargeability,
assertVacationStillChargeable,
} from "./vacation-chargeability.js";
import {
batchCreatePublicHolidayVacations,
} from "./vacation-public-holidays.js";
import { import {
VACATION_BALANCE_TYPES, VACATION_BALANCE_TYPES,
buildVacationDeductionSnapshotWriteData,
calculateVacationDeductionSnapshot,
type VacationChargeableInput,
} from "../lib/vacation-deduction-snapshot.js"; } from "../lib/vacation-deduction-snapshot.js";
import type { TRPCContext } from "../trpc.js";
import { anonymizeVacationRecord, isSameUtcDay, vacationReadProcedures } from "./vacation-read.js"; import { anonymizeVacationRecord, isSameUtcDay, vacationReadProcedures } from "./vacation-read.js";
import { import {
completeVacationApprovalTasks, completeVacationApprovalTasks,
@@ -26,35 +28,6 @@ import {
notifyVacationStatusInBackground, notifyVacationStatusInBackground,
} from "./vacation-side-effects.js"; } from "./vacation-side-effects.js";
async function calculateVacationEffectiveDays(
db: TRPCContext["db"],
vacation: VacationChargeableInput,
): Promise<number> {
if (!VACATION_BALANCE_TYPES.has(vacation.type)) {
return countCalendarDaysInPeriod(vacation);
}
const snapshot = await calculateVacationDeductionSnapshot(db, vacation);
return snapshot.deductedDays;
}
async function assertVacationStillChargeable(
db: TRPCContext["db"],
vacation: VacationChargeableInput,
): Promise<void> {
if (!VACATION_BALANCE_TYPES.has(vacation.type)) {
return;
}
const effectiveDays = await calculateVacationEffectiveDays(db, vacation);
if (effectiveDays <= 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Vacation no longer deducts any vacation days for the current holiday calendar and cannot be approved",
});
}
}
const CreateVacationRequestSchema = z.object({ const CreateVacationRequestSchema = z.object({
resourceId: z.string(), resourceId: z.string(),
type: z.nativeEnum(VacationType), type: z.nativeEnum(VacationType),
@@ -158,26 +131,13 @@ export const vacationRouter = createTRPCRouter({
}); });
} }
let effectiveDays: number | null = null; const { effectiveDays, deductionSnapshotWriteData } = await resolveVacationCreationChargeability(ctx.db, {
let deductionSnapshotWriteData: ReturnType<typeof buildVacationDeductionSnapshotWriteData> | null = null; resourceId: input.resourceId,
if (VACATION_BALANCE_TYPES.has(input.type)) { type: input.type,
const deductionSnapshot = await calculateVacationDeductionSnapshot(ctx.db, { startDate: input.startDate,
resourceId: input.resourceId, endDate: input.endDate,
type: input.type, isHalfDay: input.isHalfDay ?? false,
startDate: input.startDate, });
endDate: input.endDate,
isHalfDay: input.isHalfDay ?? false,
});
effectiveDays = deductionSnapshot.deductedDays;
deductionSnapshotWriteData = buildVacationDeductionSnapshotWriteData(deductionSnapshot);
if (effectiveDays <= 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Selected vacation period only contains public holidays and does not deduct any vacation days",
});
}
}
const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING; const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING;
@@ -257,17 +217,13 @@ export const vacationRouter = createTRPCRouter({
endDate: existing.endDate, endDate: existing.endDate,
isHalfDay: existing.isHalfDay, isHalfDay: existing.isHalfDay,
}); });
const deductionSnapshotWriteData = VACATION_BALANCE_TYPES.has(existing.type) const deductionSnapshotWriteData = await buildVacationApprovalWriteData(ctx.db, {
? buildVacationDeductionSnapshotWriteData( resourceId: existing.resourceId,
await calculateVacationDeductionSnapshot(ctx.db, { type: existing.type,
resourceId: existing.resourceId, startDate: existing.startDate,
type: existing.type, endDate: existing.endDate,
startDate: existing.startDate, isHalfDay: existing.isHalfDay,
endDate: existing.endDate, });
isHalfDay: existing.isHalfDay,
}),
)
: { deductedDays: 0 };
const userRecord = await ctx.db.user.findUnique({ const userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" }, where: { email: ctx.session.user?.email ?? "" },
@@ -412,17 +368,13 @@ export const vacationRouter = createTRPCRouter({
); );
for (const v of vacations) { for (const v of vacations) {
const deductionSnapshotWriteData = VACATION_BALANCE_TYPES.has(v.type) const deductionSnapshotWriteData = await buildVacationApprovalWriteData(ctx.db, {
? buildVacationDeductionSnapshotWriteData( resourceId: v.resourceId,
await calculateVacationDeductionSnapshot(ctx.db, { type: v.type,
resourceId: v.resourceId, startDate: v.startDate,
type: v.type, endDate: v.endDate,
startDate: v.startDate, isHalfDay: v.isHalfDay,
endDate: v.endDate, });
isHalfDay: v.isHalfDay,
}),
)
: { deductedDays: 0 };
const updated = await ctx.db.vacation.update({ const updated = await ctx.db.vacation.update({
where: { id: v.id }, where: { id: v.id },
data: { data: {
@@ -609,88 +561,17 @@ export const vacationRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: {
id: true,
federalState: true,
countryId: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
if (resources.length === 0) {
return { created: 0 };
}
const adminUser = await ctx.db.user.findUnique({ const adminUser = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" }, where: { email: ctx.session.user?.email ?? "" },
select: { id: true }, select: { id: true },
}); });
if (!adminUser) throw new TRPCError({ code: "UNAUTHORIZED" }); if (!adminUser) throw new TRPCError({ code: "UNAUTHORIZED" });
let created = 0; const { created, holidays: holidayCount, resources } = await batchCreatePublicHolidayVacations(
let holidayCount = 0; ctx.db,
input,
for (const resource of resources) { adminUser.id,
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { );
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: input.federalState ?? resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
});
holidayCount += holidays.length;
for (const holiday of holidays) {
const startDate = new Date(holiday.date);
const endDate = new Date(holiday.date);
if (input.replaceExisting) {
// Remove any existing public holiday on this exact date for this resource
await ctx.db.vacation.deleteMany({
where: {
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
startDate,
endDate,
},
});
}
// Check if one already exists
const exists = await ctx.db.vacation.findFirst({
where: {
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
startDate,
endDate,
},
});
if (exists) continue;
await ctx.db.vacation.create({
data: {
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
status: VacationStatus.APPROVED,
startDate,
endDate,
note: holiday.name,
requestedById: adminUser.id,
approvedById: adminUser.id,
approvedAt: new Date(),
},
});
created++;
}
}
void createAuditEntry({ void createAuditEntry({
db: ctx.db, db: ctx.db,
@@ -699,12 +580,12 @@ export const vacationRouter = createTRPCRouter({
entityName: `Public Holidays ${input.year}${input.federalState ? ` (${input.federalState})` : ""}`, entityName: `Public Holidays ${input.year}${input.federalState ? ` (${input.federalState})` : ""}`,
action: "CREATE", action: "CREATE",
userId: adminUser.id, userId: adminUser.id,
after: { created, holidays: holidayCount, resources: resources.length, year: input.year, federalState: input.federalState } as unknown as Record<string, unknown>, after: { created, holidays: holidayCount, resources, year: input.year, federalState: input.federalState } as unknown as Record<string, unknown>,
source: "ui", source: "ui",
summary: `Batch created ${created} public holidays for ${resources.length} resources (${input.year})`, summary: `Batch created ${created} public holidays for ${resources} resources (${input.year})`,
}); });
return { created, holidays: holidayCount, resources: resources.length }; return { created, holidays: holidayCount, resources };
}), }),
/** /**