feat(application): extract entitlement use-cases from API router layer
Move core entitlement business logic (syncEntitlement, balance reading, year summary, set/bulk-set) into packages/application/src/use-cases/entitlement/ using the deps-injection pattern. Audit logging stays in the router support file; authorization check for getBalance/getBalanceDetail stays in the router layer. The router support file becomes a thin wiring adapter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { VacationType, VacationStatus } from "@capakraken/db";
|
||||
import type { Prisma, PrismaClient } from "@capakraken/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
/** Types that consume from annual leave balance */
|
||||
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
|
||||
|
||||
type DbClient = Pick<PrismaClient, "vacation" | "vacationEntitlement" | "systemSettings" | "resource">;
|
||||
|
||||
export type EntitlementSnapshot = {
|
||||
id: string;
|
||||
entitledDays: number;
|
||||
carryoverDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
};
|
||||
|
||||
type VacationSnapshotCarrier = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
isHalfDay: boolean;
|
||||
deductedDays: number | null;
|
||||
holidayCountryCode: string | null;
|
||||
holidayFederalState: string | null;
|
||||
holidayMetroCityName: string | null;
|
||||
holidayCalendarDates: Prisma.JsonValue | null;
|
||||
holidayLegacyPublicHolidayDates: Prisma.JsonValue | null;
|
||||
};
|
||||
|
||||
export type ResourceHolidayContext = {
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
calendarHolidayStrings: string[];
|
||||
publicHolidayStrings: string[];
|
||||
};
|
||||
|
||||
export type SyncEntitlementDeps = {
|
||||
loadResourceHolidayContext: (
|
||||
db: DbClient,
|
||||
resourceId: string,
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
) => Promise<ResourceHolidayContext>;
|
||||
countCalendarDaysInPeriod: (
|
||||
vacation: { startDate: Date; endDate: Date; isHalfDay: boolean },
|
||||
periodStart?: Date,
|
||||
periodEnd?: Date,
|
||||
) => number;
|
||||
countVacationChargeableDays: (args: {
|
||||
vacation: { startDate: Date; endDate: Date; isHalfDay: boolean };
|
||||
periodStart?: Date;
|
||||
periodEnd?: Date;
|
||||
countryCode?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
calendarHolidayStrings: string[];
|
||||
publicHolidayStrings: string[];
|
||||
}) => number;
|
||||
countVacationChargeableDaysFromSnapshot: (
|
||||
vacation: VacationSnapshotCarrier,
|
||||
periodStart?: Date,
|
||||
periodEnd?: Date,
|
||||
) => number | null;
|
||||
};
|
||||
|
||||
function calculateCarryoverDays(entitlement: {
|
||||
entitledDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
}): number {
|
||||
return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays);
|
||||
}
|
||||
|
||||
async function getOrCreateEntitlement(
|
||||
db: DbClient,
|
||||
resourceId: string,
|
||||
year: number,
|
||||
defaultDays: number,
|
||||
) {
|
||||
let entitlement = await db.vacationEntitlement.findUnique({
|
||||
where: { resourceId_year: { resourceId, year } },
|
||||
});
|
||||
|
||||
if (!entitlement) {
|
||||
const prevYear = await db.vacationEntitlement.findUnique({
|
||||
where: { resourceId_year: { resourceId, year: year - 1 } },
|
||||
});
|
||||
|
||||
const carryover = prevYear
|
||||
? Math.max(0, prevYear.entitledDays - prevYear.usedDays - prevYear.pendingDays)
|
||||
: 0;
|
||||
|
||||
entitlement = await db.vacationEntitlement.create({
|
||||
data: {
|
||||
resourceId,
|
||||
year,
|
||||
entitledDays: defaultDays + carryover,
|
||||
carryoverDays: carryover,
|
||||
usedDays: 0,
|
||||
pendingDays: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return entitlement;
|
||||
}
|
||||
|
||||
async function calculateEntitlementVacationDays(
|
||||
yearStart: Date,
|
||||
yearEnd: Date,
|
||||
vacation: VacationSnapshotCarrier,
|
||||
getLegacyHolidayContext: () => Promise<ResourceHolidayContext>,
|
||||
deps: SyncEntitlementDeps,
|
||||
): Promise<number> {
|
||||
const persistedDays = deps.countVacationChargeableDaysFromSnapshot(vacation, yearStart, yearEnd);
|
||||
if (persistedDays !== null) {
|
||||
return persistedDays;
|
||||
}
|
||||
|
||||
const holidayContext = await getLegacyHolidayContext();
|
||||
return deps.countVacationChargeableDays({
|
||||
vacation,
|
||||
periodStart: yearStart,
|
||||
periodEnd: yearEnd,
|
||||
countryCode: holidayContext.countryCode ?? null,
|
||||
federalState: holidayContext.federalState ?? null,
|
||||
metroCityName: holidayContext.metroCityName ?? null,
|
||||
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
|
||||
publicHolidayStrings: holidayContext.publicHolidayStrings,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute used/pending days from actual vacation records and update the cached values.
|
||||
*/
|
||||
export async function syncEntitlement(
|
||||
db: DbClient,
|
||||
resourceId: string,
|
||||
year: number,
|
||||
defaultDays: number,
|
||||
deps: SyncEntitlementDeps,
|
||||
visitedYears: Set<number> = new Set(),
|
||||
): Promise<EntitlementSnapshot> {
|
||||
if (visitedYears.has(year)) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Detected recursive entitlement sync for year ${year}`,
|
||||
});
|
||||
}
|
||||
visitedYears.add(year);
|
||||
|
||||
let previousYearEntitlement: EntitlementSnapshot | null = await db.vacationEntitlement.findUnique({
|
||||
where: { resourceId_year: { resourceId, year: year - 1 } },
|
||||
});
|
||||
|
||||
if (previousYearEntitlement) {
|
||||
previousYearEntitlement = await syncEntitlement(
|
||||
db,
|
||||
resourceId,
|
||||
year - 1,
|
||||
defaultDays,
|
||||
deps,
|
||||
visitedYears,
|
||||
);
|
||||
}
|
||||
|
||||
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
|
||||
const carryoverDays = previousYearEntitlement
|
||||
? calculateCarryoverDays(previousYearEntitlement)
|
||||
: 0;
|
||||
const expectedEntitledDays = defaultDays + carryoverDays;
|
||||
const entitlementWithCarryover = (
|
||||
entitlement.carryoverDays !== carryoverDays
|
||||
|| entitlement.entitledDays !== expectedEntitledDays
|
||||
)
|
||||
? await db.vacationEntitlement.update({
|
||||
where: { id: entitlement.id },
|
||||
data: {
|
||||
carryoverDays,
|
||||
entitledDays: expectedEntitledDays,
|
||||
},
|
||||
})
|
||||
: entitlement;
|
||||
|
||||
const yearStart = new Date(`${year}-01-01T00:00:00.000Z`);
|
||||
const yearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
|
||||
|
||||
const vacations = await db.vacation.findMany({
|
||||
where: {
|
||||
resourceId,
|
||||
type: { in: BALANCE_TYPES },
|
||||
startDate: { lte: yearEnd },
|
||||
endDate: { gte: yearStart },
|
||||
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
|
||||
},
|
||||
select: {
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
status: true,
|
||||
isHalfDay: true,
|
||||
deductedDays: true,
|
||||
holidayCountryCode: true,
|
||||
holidayFederalState: true,
|
||||
holidayMetroCityName: true,
|
||||
holidayCalendarDates: true,
|
||||
holidayLegacyPublicHolidayDates: true,
|
||||
},
|
||||
});
|
||||
|
||||
let usedDays = 0;
|
||||
let pendingDays = 0;
|
||||
let legacyHolidayContextPromise: Promise<ResourceHolidayContext> | null = null;
|
||||
const getLegacyHolidayContext = async () => {
|
||||
if (!legacyHolidayContextPromise) {
|
||||
legacyHolidayContextPromise = deps.loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
|
||||
}
|
||||
return legacyHolidayContextPromise;
|
||||
};
|
||||
|
||||
for (const vacation of vacations) {
|
||||
const days = await calculateEntitlementVacationDays(
|
||||
yearStart,
|
||||
yearEnd,
|
||||
vacation,
|
||||
getLegacyHolidayContext,
|
||||
deps,
|
||||
);
|
||||
if (vacation.status === VacationStatus.APPROVED) {
|
||||
usedDays += days;
|
||||
} else {
|
||||
pendingDays += days;
|
||||
}
|
||||
}
|
||||
|
||||
return db.vacationEntitlement.update({
|
||||
where: { id: entitlementWithCarryover.id },
|
||||
data: { usedDays, pendingDays },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user