diff --git a/packages/api/src/router/vacation-chargeability.ts b/packages/api/src/router/vacation-chargeability.ts new file mode 100644 index 0000000..ba4c4ba --- /dev/null +++ b/packages/api/src/router/vacation-chargeability.ts @@ -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; + +export async function calculateVacationEffectiveDays( + db: VacationDb, + vacation: VacationChargeableInput, +): Promise { + 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 { + 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 { + if (!VACATION_BALANCE_TYPES.has(vacation.type)) { + return { deductedDays: 0 }; + } + + return buildVacationDeductionSnapshotWriteData( + await calculateVacationDeductionSnapshot(db, vacation), + ); +} diff --git a/packages/api/src/router/vacation-public-holidays.ts b/packages/api/src/router/vacation-public-holidays.ts new file mode 100644 index 0000000..3b154f9 --- /dev/null +++ b/packages/api/src/router/vacation-public-holidays.ts @@ -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 }; +} diff --git a/packages/api/src/router/vacation.ts b/packages/api/src/router/vacation.ts index 325bf65..133a05f 100644 --- a/packages/api/src/router/vacation.ts +++ b/packages/api/src/router/vacation.ts @@ -9,15 +9,17 @@ import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js"; import { createAuditEntry } from "../lib/audit.js"; -import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; -import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js"; +import { + buildVacationApprovalWriteData, + resolveVacationCreationChargeability, + assertVacationStillChargeable, +} from "./vacation-chargeability.js"; +import { + batchCreatePublicHolidayVacations, +} from "./vacation-public-holidays.js"; import { VACATION_BALANCE_TYPES, - buildVacationDeductionSnapshotWriteData, - calculateVacationDeductionSnapshot, - type VacationChargeableInput, } from "../lib/vacation-deduction-snapshot.js"; -import type { TRPCContext } from "../trpc.js"; import { anonymizeVacationRecord, isSameUtcDay, vacationReadProcedures } from "./vacation-read.js"; import { completeVacationApprovalTasks, @@ -26,35 +28,6 @@ import { notifyVacationStatusInBackground, } from "./vacation-side-effects.js"; -async function calculateVacationEffectiveDays( - db: TRPCContext["db"], - vacation: VacationChargeableInput, -): Promise { - 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 { - 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({ resourceId: z.string(), type: z.nativeEnum(VacationType), @@ -158,26 +131,13 @@ export const vacationRouter = createTRPCRouter({ }); } - let effectiveDays: number | null = null; - let deductionSnapshotWriteData: ReturnType | null = null; - if (VACATION_BALANCE_TYPES.has(input.type)) { - const deductionSnapshot = await calculateVacationDeductionSnapshot(ctx.db, { - resourceId: input.resourceId, - type: input.type, - 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 { 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; @@ -257,17 +217,13 @@ export const vacationRouter = createTRPCRouter({ endDate: existing.endDate, isHalfDay: existing.isHalfDay, }); - const deductionSnapshotWriteData = VACATION_BALANCE_TYPES.has(existing.type) - ? buildVacationDeductionSnapshotWriteData( - await calculateVacationDeductionSnapshot(ctx.db, { - resourceId: existing.resourceId, - type: existing.type, - startDate: existing.startDate, - endDate: existing.endDate, - isHalfDay: existing.isHalfDay, - }), - ) - : { deductedDays: 0 }; + const deductionSnapshotWriteData = await buildVacationApprovalWriteData(ctx.db, { + resourceId: existing.resourceId, + type: existing.type, + startDate: existing.startDate, + endDate: existing.endDate, + isHalfDay: existing.isHalfDay, + }); const userRecord = await ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, @@ -412,17 +368,13 @@ export const vacationRouter = createTRPCRouter({ ); for (const v of vacations) { - const deductionSnapshotWriteData = VACATION_BALANCE_TYPES.has(v.type) - ? buildVacationDeductionSnapshotWriteData( - await calculateVacationDeductionSnapshot(ctx.db, { - resourceId: v.resourceId, - type: v.type, - startDate: v.startDate, - endDate: v.endDate, - isHalfDay: v.isHalfDay, - }), - ) - : { deductedDays: 0 }; + const deductionSnapshotWriteData = await buildVacationApprovalWriteData(ctx.db, { + resourceId: v.resourceId, + type: v.type, + startDate: v.startDate, + endDate: v.endDate, + isHalfDay: v.isHalfDay, + }); const updated = await ctx.db.vacation.update({ where: { id: v.id }, data: { @@ -609,88 +561,17 @@ export const vacationRouter = createTRPCRouter({ }), ) .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({ where: { email: ctx.session.user?.email ?? "" }, select: { id: true }, }); if (!adminUser) throw new TRPCError({ code: "UNAUTHORIZED" }); - let created = 0; - let holidayCount = 0; - - for (const resource of resources) { - 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++; - } - } + const { created, holidays: holidayCount, resources } = await batchCreatePublicHolidayVacations( + ctx.db, + input, + adminUser.id, + ); void createAuditEntry({ db: ctx.db, @@ -699,12 +580,12 @@ export const vacationRouter = createTRPCRouter({ entityName: `Public Holidays ${input.year}${input.federalState ? ` (${input.federalState})` : ""}`, action: "CREATE", userId: adminUser.id, - after: { created, holidays: holidayCount, resources: resources.length, year: input.year, federalState: input.federalState } as unknown as Record, + after: { created, holidays: holidayCount, resources, year: input.year, federalState: input.federalState } as unknown as Record, 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 }; }), /**