refactor(api): extract vacation chargeability helpers
This commit is contained in:
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
if (VACATION_BALANCE_TYPES.has(input.type)) {
|
|
||||||
const deductionSnapshot = await calculateVacationDeductionSnapshot(ctx.db, {
|
|
||||||
resourceId: input.resourceId,
|
resourceId: input.resourceId,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
startDate: input.startDate,
|
startDate: input.startDate,
|
||||||
endDate: input.endDate,
|
endDate: input.endDate,
|
||||||
isHalfDay: input.isHalfDay ?? false,
|
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(
|
|
||||||
await calculateVacationDeductionSnapshot(ctx.db, {
|
|
||||||
resourceId: existing.resourceId,
|
resourceId: existing.resourceId,
|
||||||
type: existing.type,
|
type: existing.type,
|
||||||
startDate: existing.startDate,
|
startDate: existing.startDate,
|
||||||
endDate: existing.endDate,
|
endDate: existing.endDate,
|
||||||
isHalfDay: existing.isHalfDay,
|
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(
|
|
||||||
await calculateVacationDeductionSnapshot(ctx.db, {
|
|
||||||
resourceId: v.resourceId,
|
resourceId: v.resourceId,
|
||||||
type: v.type,
|
type: v.type,
|
||||||
startDate: v.startDate,
|
startDate: v.startDate,
|
||||||
endDate: v.endDate,
|
endDate: v.endDate,
|
||||||
isHalfDay: v.isHalfDay,
|
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 };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user