refactor(api): extract entitlement procedures
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
import { VacationType, VacationStatus } from "@capakraken/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
|
||||
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
|
||||
import { countVacationChargeableDaysFromSnapshot } from "../lib/vacation-deduction-snapshot.js";
|
||||
import type { TRPCContext } from "../trpc.js";
|
||||
|
||||
/** Types that consume from annual leave balance */
|
||||
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
|
||||
|
||||
type EntitlementSnapshot = {
|
||||
id: string;
|
||||
entitledDays: number;
|
||||
carryoverDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
};
|
||||
|
||||
type EntitlementReadContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||
type EntitlementWriteContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||
|
||||
export const EntitlementBalanceInputSchema = z.object({
|
||||
resourceId: z.string(),
|
||||
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
|
||||
});
|
||||
|
||||
export const EntitlementGetInputSchema = z.object({
|
||||
resourceId: z.string(),
|
||||
year: z.number().int(),
|
||||
});
|
||||
|
||||
export const EntitlementSetInputSchema = z.object({
|
||||
resourceId: z.string(),
|
||||
year: z.number().int(),
|
||||
entitledDays: z.number().min(0).max(365),
|
||||
});
|
||||
|
||||
export const EntitlementBulkSetInputSchema = z.object({
|
||||
year: z.number().int(),
|
||||
entitledDays: z.number().min(0).max(365),
|
||||
resourceIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const EntitlementYearSummaryInputSchema = z.object({
|
||||
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
|
||||
chapter: z.string().optional(),
|
||||
});
|
||||
|
||||
export const EntitlementYearSummaryDetailInputSchema = EntitlementYearSummaryInputSchema.extend({
|
||||
resourceName: z.string().optional(),
|
||||
});
|
||||
|
||||
function mapBalanceDetail(resource: {
|
||||
displayName: string;
|
||||
eid: string;
|
||||
}, balance: {
|
||||
year: number;
|
||||
entitledDays: number;
|
||||
carryoverDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
remainingDays: number;
|
||||
sickDays: number;
|
||||
}) {
|
||||
return {
|
||||
resource: resource.displayName,
|
||||
eid: resource.eid,
|
||||
year: balance.year,
|
||||
entitlement: balance.entitledDays,
|
||||
carryOver: balance.carryoverDays,
|
||||
taken: balance.usedDays,
|
||||
pending: balance.pendingDays,
|
||||
remaining: balance.remainingDays,
|
||||
sickDays: balance.sickDays,
|
||||
};
|
||||
}
|
||||
|
||||
function mapYearSummaryDetail(
|
||||
year: number,
|
||||
summaries: Array<{
|
||||
displayName: string;
|
||||
eid: string;
|
||||
chapter: string | null;
|
||||
entitledDays: number;
|
||||
carryoverDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
remainingDays: number;
|
||||
}>,
|
||||
resourceName?: string,
|
||||
) {
|
||||
const needle = resourceName?.toLowerCase();
|
||||
|
||||
return summaries
|
||||
.filter((summary) => {
|
||||
if (!needle) {
|
||||
return true;
|
||||
}
|
||||
return summary.displayName.toLowerCase().includes(needle)
|
||||
|| summary.eid.toLowerCase().includes(needle);
|
||||
})
|
||||
.slice(0, 50)
|
||||
.map((summary) => ({
|
||||
resource: summary.displayName,
|
||||
eid: summary.eid,
|
||||
chapter: summary.chapter ?? null,
|
||||
year,
|
||||
entitled: summary.entitledDays,
|
||||
carryover: summary.carryoverDays,
|
||||
used: summary.usedDays,
|
||||
pending: summary.pendingDays,
|
||||
remaining: summary.remainingDays,
|
||||
}));
|
||||
}
|
||||
|
||||
async function readBalanceSnapshot(
|
||||
ctx: EntitlementReadContext,
|
||||
input: z.infer<typeof EntitlementBalanceInputSchema>,
|
||||
) {
|
||||
if (ctx.dbUser) {
|
||||
const allowedRoles = ["ADMIN", "MANAGER", "CONTROLLER"];
|
||||
if (!allowedRoles.includes(ctx.dbUser.systemRole)) {
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!resource || resource.userId !== ctx.dbUser.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can only view your own vacation balance",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
||||
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
||||
|
||||
const sickVacationsResult = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: input.resourceId,
|
||||
type: VacationType.SICK,
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
|
||||
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
|
||||
},
|
||||
select: { startDate: true, endDate: true, isHalfDay: true },
|
||||
});
|
||||
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
|
||||
const sickDays = sickVacations.reduce(
|
||||
(sum, vacation) => sum + countCalendarDaysInPeriod(
|
||||
vacation,
|
||||
new Date(`${input.year}-01-01T00:00:00.000Z`),
|
||||
new Date(`${input.year}-12-31T00:00:00.000Z`),
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
year: input.year,
|
||||
resourceId: input.resourceId,
|
||||
entitledDays: entitlement.entitledDays,
|
||||
carryoverDays: entitlement.carryoverDays,
|
||||
usedDays: entitlement.usedDays,
|
||||
pendingDays: entitlement.pendingDays,
|
||||
remainingDays: Math.max(
|
||||
0,
|
||||
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
|
||||
),
|
||||
sickDays,
|
||||
};
|
||||
}
|
||||
|
||||
async function readYearSummarySnapshot(
|
||||
ctx: Pick<TRPCContext, "db">,
|
||||
input: z.infer<typeof EntitlementYearSummaryInputSchema>,
|
||||
) {
|
||||
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
...(input.chapter ? { chapter: input.chapter } : {}),
|
||||
},
|
||||
select: { ...RESOURCE_BRIEF_SELECT, chapter: true },
|
||||
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
resources.map(async (resource) => {
|
||||
const entitlement = await syncEntitlement(ctx.db, resource.id, input.year, defaultDays);
|
||||
return {
|
||||
resourceId: resource.id,
|
||||
displayName: resource.displayName,
|
||||
eid: resource.eid,
|
||||
chapter: resource.chapter,
|
||||
entitledDays: entitlement.entitledDays,
|
||||
carryoverDays: entitlement.carryoverDays,
|
||||
usedDays: entitlement.usedDays,
|
||||
pendingDays: entitlement.pendingDays,
|
||||
remainingDays: Math.max(
|
||||
0,
|
||||
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an entitlement record, applying carryover from previous year if needed.
|
||||
*/
|
||||
async function getOrCreateEntitlement(
|
||||
db: TRPCContext["db"],
|
||||
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;
|
||||
}
|
||||
|
||||
function calculateCarryoverDays(entitlement: {
|
||||
entitledDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
}): number {
|
||||
return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays);
|
||||
}
|
||||
|
||||
async function calculateEntitlementVacationDays(
|
||||
yearStart: Date,
|
||||
yearEnd: Date,
|
||||
vacation: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
isHalfDay: boolean;
|
||||
deductedDays: number | null;
|
||||
holidayCountryCode: string | null;
|
||||
holidayFederalState: string | null;
|
||||
holidayMetroCityName: string | null;
|
||||
holidayCalendarDates: import("@capakraken/db").Prisma.JsonValue | null;
|
||||
holidayLegacyPublicHolidayDates: import("@capakraken/db").Prisma.JsonValue | null;
|
||||
},
|
||||
getLegacyHolidayContext: () => Promise<Awaited<ReturnType<typeof loadResourceHolidayContext>>>,
|
||||
): Promise<number> {
|
||||
const persistedDays = countVacationChargeableDaysFromSnapshot(vacation, yearStart, yearEnd);
|
||||
if (persistedDays !== null) {
|
||||
return persistedDays;
|
||||
}
|
||||
|
||||
const holidayContext = await getLegacyHolidayContext();
|
||||
return countVacationChargeableDays({
|
||||
vacation,
|
||||
periodStart: yearStart,
|
||||
periodEnd: yearEnd,
|
||||
countryCode: holidayContext.countryCode,
|
||||
federalState: holidayContext.federalState,
|
||||
metroCityName: holidayContext.metroCityName,
|
||||
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
|
||||
publicHolidayStrings: holidayContext.publicHolidayStrings,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute used/pending days from actual vacation records and update the cached values.
|
||||
*/
|
||||
async function syncEntitlement(
|
||||
db: TRPCContext["db"],
|
||||
resourceId: string,
|
||||
year: number,
|
||||
defaultDays: number,
|
||||
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,
|
||||
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<Awaited<ReturnType<typeof loadResourceHolidayContext>>> | null = null;
|
||||
const getLegacyHolidayContext = async () => {
|
||||
if (!legacyHolidayContextPromise) {
|
||||
legacyHolidayContextPromise = loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
|
||||
}
|
||||
return legacyHolidayContextPromise;
|
||||
};
|
||||
|
||||
for (const vacation of vacations) {
|
||||
const days = await calculateEntitlementVacationDays(
|
||||
yearStart,
|
||||
yearEnd,
|
||||
vacation,
|
||||
getLegacyHolidayContext,
|
||||
);
|
||||
if (vacation.status === VacationStatus.APPROVED) {
|
||||
usedDays += days;
|
||||
} else {
|
||||
pendingDays += days;
|
||||
}
|
||||
}
|
||||
|
||||
return db.vacationEntitlement.update({
|
||||
where: { id: entitlementWithCarryover.id },
|
||||
data: { usedDays, pendingDays },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEntitlementBalance(
|
||||
ctx: EntitlementReadContext,
|
||||
input: z.infer<typeof EntitlementBalanceInputSchema>,
|
||||
) {
|
||||
return readBalanceSnapshot(ctx, input);
|
||||
}
|
||||
|
||||
export async function getEntitlementBalanceDetail(
|
||||
ctx: EntitlementReadContext,
|
||||
input: z.infer<typeof EntitlementBalanceInputSchema>,
|
||||
) {
|
||||
const balance = await readBalanceSnapshot(ctx, input);
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
select: { displayName: true, eid: true },
|
||||
});
|
||||
|
||||
if (!resource) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Resource not found",
|
||||
});
|
||||
}
|
||||
|
||||
return mapBalanceDetail(resource, balance);
|
||||
}
|
||||
|
||||
export async function getEntitlement(
|
||||
ctx: Pick<TRPCContext, "db">,
|
||||
input: z.infer<typeof EntitlementGetInputSchema>,
|
||||
) {
|
||||
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
||||
return syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
||||
}
|
||||
|
||||
export async function setEntitlement(
|
||||
ctx: EntitlementWriteContext,
|
||||
input: z.infer<typeof EntitlementSetInputSchema>,
|
||||
) {
|
||||
const existing = await ctx.db.vacationEntitlement.findUnique({
|
||||
where: { resourceId_year: { resourceId: input.resourceId, year: input.year } },
|
||||
});
|
||||
if (existing) {
|
||||
const updated = await ctx.db.vacationEntitlement.update({
|
||||
where: { id: existing.id },
|
||||
data: { entitledDays: input.entitledDays },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "VacationEntitlement",
|
||||
entityId: updated.id,
|
||||
entityName: `Entitlement ${input.resourceId} / ${input.year}`,
|
||||
action: "UPDATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
before: existing as unknown as Record<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Updated entitlement from ${existing.entitledDays} to ${input.entitledDays} days (${input.year})`,
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
const created = await ctx.db.vacationEntitlement.create({
|
||||
data: {
|
||||
resourceId: input.resourceId,
|
||||
year: input.year,
|
||||
entitledDays: input.entitledDays,
|
||||
carryoverDays: 0,
|
||||
usedDays: 0,
|
||||
pendingDays: 0,
|
||||
},
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "VacationEntitlement",
|
||||
entityId: created.id,
|
||||
entityName: `Entitlement ${input.resourceId} / ${input.year}`,
|
||||
action: "CREATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
after: created as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Set entitlement to ${input.entitledDays} days (${input.year})`,
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function bulkSetEntitlements(
|
||||
ctx: EntitlementWriteContext,
|
||||
input: z.infer<typeof EntitlementBulkSetInputSchema>,
|
||||
) {
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
...(input.resourceIds ? { id: { in: input.resourceIds } } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
let updated = 0;
|
||||
for (const resource of resources) {
|
||||
await ctx.db.vacationEntitlement.upsert({
|
||||
where: { resourceId_year: { resourceId: resource.id, year: input.year } },
|
||||
create: {
|
||||
resourceId: resource.id,
|
||||
year: input.year,
|
||||
entitledDays: input.entitledDays,
|
||||
carryoverDays: 0,
|
||||
usedDays: 0,
|
||||
pendingDays: 0,
|
||||
},
|
||||
update: { entitledDays: input.entitledDays },
|
||||
});
|
||||
updated++;
|
||||
}
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "VacationEntitlement",
|
||||
entityId: `bulk-${input.year}`,
|
||||
entityName: `Bulk Entitlement ${input.year}`,
|
||||
action: "UPDATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
after: { year: input.year, entitledDays: input.entitledDays, resourceCount: updated } as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Bulk set entitlement to ${input.entitledDays} days for ${updated} resources (${input.year})`,
|
||||
});
|
||||
|
||||
return { updated };
|
||||
}
|
||||
|
||||
export async function getEntitlementYearSummary(
|
||||
ctx: Pick<TRPCContext, "db">,
|
||||
input: z.infer<typeof EntitlementYearSummaryInputSchema>,
|
||||
) {
|
||||
return readYearSummarySnapshot(ctx, input);
|
||||
}
|
||||
|
||||
export async function getEntitlementYearSummaryDetail(
|
||||
ctx: Pick<TRPCContext, "db">,
|
||||
input: z.infer<typeof EntitlementYearSummaryDetailInputSchema>,
|
||||
) {
|
||||
const summaries = await readYearSummarySnapshot(ctx, {
|
||||
year: input.year,
|
||||
...(input.chapter ? { chapter: input.chapter } : {}),
|
||||
});
|
||||
|
||||
return mapYearSummaryDetail(input.year, summaries, input.resourceName);
|
||||
}
|
||||
@@ -1,567 +1,46 @@
|
||||
/**
|
||||
* Vacation entitlement & balance router.
|
||||
* Tracks annual leave quotas per resource per year.
|
||||
* Balance is computed lazily: carryover from previous year is applied on first access.
|
||||
*/
|
||||
import { VacationType, VacationStatus } from "@capakraken/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
|
||||
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
|
||||
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
|
||||
import {
|
||||
countVacationChargeableDaysFromSnapshot,
|
||||
} from "../lib/vacation-deduction-snapshot.js";
|
||||
|
||||
/** Types that consume from annual leave balance */
|
||||
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
|
||||
|
||||
type EntitlementSnapshot = {
|
||||
id: string;
|
||||
entitledDays: number;
|
||||
carryoverDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
};
|
||||
|
||||
function mapBalanceDetail(resource: {
|
||||
displayName: string;
|
||||
eid: string;
|
||||
}, balance: {
|
||||
year: number;
|
||||
entitledDays: number;
|
||||
carryoverDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
remainingDays: number;
|
||||
sickDays: number;
|
||||
}) {
|
||||
return {
|
||||
resource: resource.displayName,
|
||||
eid: resource.eid,
|
||||
year: balance.year,
|
||||
entitlement: balance.entitledDays,
|
||||
carryOver: balance.carryoverDays,
|
||||
taken: balance.usedDays,
|
||||
pending: balance.pendingDays,
|
||||
remaining: balance.remainingDays,
|
||||
sickDays: balance.sickDays,
|
||||
};
|
||||
}
|
||||
|
||||
function mapYearSummaryDetail(
|
||||
year: number,
|
||||
summaries: Array<{
|
||||
displayName: string;
|
||||
eid: string;
|
||||
chapter: string | null;
|
||||
entitledDays: number;
|
||||
carryoverDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
remainingDays: number;
|
||||
}>,
|
||||
resourceName?: string,
|
||||
) {
|
||||
const needle = resourceName?.toLowerCase();
|
||||
|
||||
return summaries
|
||||
.filter((summary) => {
|
||||
if (!needle) {
|
||||
return true;
|
||||
}
|
||||
return summary.displayName.toLowerCase().includes(needle)
|
||||
|| summary.eid.toLowerCase().includes(needle);
|
||||
})
|
||||
.slice(0, 50)
|
||||
.map((summary) => ({
|
||||
resource: summary.displayName,
|
||||
eid: summary.eid,
|
||||
chapter: summary.chapter ?? null,
|
||||
year,
|
||||
entitled: summary.entitledDays,
|
||||
carryover: summary.carryoverDays,
|
||||
used: summary.usedDays,
|
||||
pending: summary.pendingDays,
|
||||
remaining: summary.remainingDays,
|
||||
}));
|
||||
}
|
||||
|
||||
type EntitlementReadContext = Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"];
|
||||
|
||||
async function readBalanceSnapshot(
|
||||
ctx: Pick<EntitlementReadContext, "db" | "dbUser">,
|
||||
input: { resourceId: string; year: number },
|
||||
) {
|
||||
if (ctx.dbUser) {
|
||||
const allowedRoles = ["ADMIN", "MANAGER", "CONTROLLER"];
|
||||
if (!allowedRoles.includes(ctx.dbUser.systemRole)) {
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!resource || resource.userId !== ctx.dbUser.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can only view your own vacation balance",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
||||
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
||||
|
||||
const sickVacationsResult = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: input.resourceId,
|
||||
type: VacationType.SICK,
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
|
||||
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
|
||||
},
|
||||
select: { startDate: true, endDate: true, isHalfDay: true },
|
||||
});
|
||||
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
|
||||
const sickDays = sickVacations.reduce(
|
||||
(sum, vacation) => sum + countCalendarDaysInPeriod(
|
||||
vacation,
|
||||
new Date(`${input.year}-01-01T00:00:00.000Z`),
|
||||
new Date(`${input.year}-12-31T00:00:00.000Z`),
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
year: input.year,
|
||||
resourceId: input.resourceId,
|
||||
entitledDays: entitlement.entitledDays,
|
||||
carryoverDays: entitlement.carryoverDays,
|
||||
usedDays: entitlement.usedDays,
|
||||
pendingDays: entitlement.pendingDays,
|
||||
remainingDays: Math.max(
|
||||
0,
|
||||
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
|
||||
),
|
||||
sickDays,
|
||||
};
|
||||
}
|
||||
|
||||
async function readYearSummarySnapshot(
|
||||
ctx: Pick<EntitlementReadContext, "db">,
|
||||
input: { year: number; chapter?: string },
|
||||
) {
|
||||
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
...(input.chapter ? { chapter: input.chapter } : {}),
|
||||
},
|
||||
select: { ...RESOURCE_BRIEF_SELECT, chapter: true },
|
||||
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
resources.map(async (resource) => {
|
||||
const entitlement = await syncEntitlement(ctx.db, resource.id, input.year, defaultDays);
|
||||
return {
|
||||
resourceId: resource.id,
|
||||
displayName: resource.displayName,
|
||||
eid: resource.eid,
|
||||
chapter: resource.chapter,
|
||||
entitledDays: entitlement.entitledDays,
|
||||
carryoverDays: entitlement.carryoverDays,
|
||||
usedDays: entitlement.usedDays,
|
||||
pendingDays: entitlement.pendingDays,
|
||||
remainingDays: Math.max(
|
||||
0,
|
||||
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an entitlement record, applying carryover from previous year if needed.
|
||||
*/
|
||||
async function getOrCreateEntitlement(
|
||||
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
|
||||
resourceId: string,
|
||||
year: number,
|
||||
defaultDays: number,
|
||||
) {
|
||||
let entitlement = await db.vacationEntitlement.findUnique({
|
||||
where: { resourceId_year: { resourceId, year } },
|
||||
});
|
||||
|
||||
if (!entitlement) {
|
||||
// Check previous year for carryover
|
||||
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;
|
||||
}
|
||||
|
||||
function calculateCarryoverDays(entitlement: {
|
||||
entitledDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
}): number {
|
||||
return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays);
|
||||
}
|
||||
|
||||
async function calculateEntitlementVacationDays(
|
||||
yearStart: Date,
|
||||
yearEnd: Date,
|
||||
vacation: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
isHalfDay: boolean;
|
||||
deductedDays: number | null;
|
||||
holidayCountryCode: string | null;
|
||||
holidayFederalState: string | null;
|
||||
holidayMetroCityName: string | null;
|
||||
holidayCalendarDates: import("@capakraken/db").Prisma.JsonValue | null;
|
||||
holidayLegacyPublicHolidayDates: import("@capakraken/db").Prisma.JsonValue | null;
|
||||
},
|
||||
getLegacyHolidayContext: () => Promise<Awaited<ReturnType<typeof loadResourceHolidayContext>>>,
|
||||
): Promise<number> {
|
||||
const persistedDays = countVacationChargeableDaysFromSnapshot(vacation, yearStart, yearEnd);
|
||||
if (persistedDays !== null) {
|
||||
return persistedDays;
|
||||
}
|
||||
|
||||
const holidayContext = await getLegacyHolidayContext();
|
||||
return countVacationChargeableDays({
|
||||
vacation,
|
||||
periodStart: yearStart,
|
||||
periodEnd: yearEnd,
|
||||
countryCode: holidayContext.countryCode,
|
||||
federalState: holidayContext.federalState,
|
||||
metroCityName: holidayContext.metroCityName,
|
||||
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
|
||||
publicHolidayStrings: holidayContext.publicHolidayStrings,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute used/pending days from actual vacation records and update the cached values.
|
||||
*/
|
||||
async function syncEntitlement(
|
||||
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
|
||||
resourceId: string,
|
||||
year: number,
|
||||
defaultDays: number,
|
||||
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,
|
||||
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<Awaited<ReturnType<typeof loadResourceHolidayContext>>> | null = null;
|
||||
const getLegacyHolidayContext = async () => {
|
||||
if (!legacyHolidayContextPromise) {
|
||||
legacyHolidayContextPromise = loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
|
||||
}
|
||||
return legacyHolidayContextPromise;
|
||||
};
|
||||
|
||||
for (const v of vacations) {
|
||||
const days = await calculateEntitlementVacationDays(
|
||||
yearStart,
|
||||
yearEnd,
|
||||
v,
|
||||
getLegacyHolidayContext,
|
||||
);
|
||||
if (v.status === VacationStatus.APPROVED) usedDays += days;
|
||||
else pendingDays += days;
|
||||
}
|
||||
|
||||
return db.vacationEntitlement.update({
|
||||
where: { id: entitlementWithCarryover.id },
|
||||
data: { usedDays, pendingDays },
|
||||
});
|
||||
}
|
||||
EntitlementBalanceInputSchema,
|
||||
EntitlementBulkSetInputSchema,
|
||||
EntitlementGetInputSchema,
|
||||
EntitlementSetInputSchema,
|
||||
EntitlementYearSummaryDetailInputSchema,
|
||||
EntitlementYearSummaryInputSchema,
|
||||
bulkSetEntitlements,
|
||||
getEntitlement,
|
||||
getEntitlementBalance,
|
||||
getEntitlementBalanceDetail,
|
||||
getEntitlementYearSummary,
|
||||
getEntitlementYearSummaryDetail,
|
||||
setEntitlement,
|
||||
} from "./entitlement-procedure-support.js";
|
||||
|
||||
export const entitlementRouter = createTRPCRouter({
|
||||
/**
|
||||
* Get vacation balance for a resource in a year.
|
||||
* Creates the entitlement record if it doesn't exist (with carryover).
|
||||
*/
|
||||
getBalance: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => readBalanceSnapshot(ctx, input)),
|
||||
.input(EntitlementBalanceInputSchema)
|
||||
.query(({ ctx, input }) => getEntitlementBalance(ctx, input)),
|
||||
|
||||
getBalanceDetail: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const balance = await readBalanceSnapshot(ctx, input);
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
select: { displayName: true, eid: true },
|
||||
});
|
||||
.input(EntitlementBalanceInputSchema)
|
||||
.query(({ ctx, input }) => getEntitlementBalanceDetail(ctx, input)),
|
||||
|
||||
if (!resource) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Resource not found",
|
||||
});
|
||||
}
|
||||
|
||||
return mapBalanceDetail(resource, balance);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get entitlement record for a resource/year (admin/manager only).
|
||||
*/
|
||||
get: managerProcedure
|
||||
.input(z.object({ resourceId: z.string(), year: z.number().int() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
||||
return syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
||||
}),
|
||||
.input(EntitlementGetInputSchema)
|
||||
.query(({ ctx, input }) => getEntitlement(ctx, input)),
|
||||
|
||||
/**
|
||||
* Set entitlement for a resource/year (admin/manager only).
|
||||
*/
|
||||
set: managerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
year: z.number().int(),
|
||||
entitledDays: z.number().min(0).max(365),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.vacationEntitlement.findUnique({
|
||||
where: { resourceId_year: { resourceId: input.resourceId, year: input.year } },
|
||||
});
|
||||
if (existing) {
|
||||
const updated = await ctx.db.vacationEntitlement.update({
|
||||
where: { id: existing.id },
|
||||
data: { entitledDays: input.entitledDays },
|
||||
});
|
||||
.input(EntitlementSetInputSchema)
|
||||
.mutation(({ ctx, input }) => setEntitlement(ctx, input)),
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "VacationEntitlement",
|
||||
entityId: updated.id,
|
||||
entityName: `Entitlement ${input.resourceId} / ${input.year}`,
|
||||
action: "UPDATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
before: existing as unknown as Record<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Updated entitlement from ${existing.entitledDays} to ${input.entitledDays} days (${input.year})`,
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
const created = await ctx.db.vacationEntitlement.create({
|
||||
data: {
|
||||
resourceId: input.resourceId,
|
||||
year: input.year,
|
||||
entitledDays: input.entitledDays,
|
||||
carryoverDays: 0,
|
||||
usedDays: 0,
|
||||
pendingDays: 0,
|
||||
},
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "VacationEntitlement",
|
||||
entityId: created.id,
|
||||
entityName: `Entitlement ${input.resourceId} / ${input.year}`,
|
||||
action: "CREATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
after: created as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Set entitlement to ${input.entitledDays} days (${input.year})`,
|
||||
});
|
||||
|
||||
return created;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk-set entitlements for multiple resources (admin only).
|
||||
* Useful for setting the default entitlement for a new year.
|
||||
*/
|
||||
bulkSet: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
year: z.number().int(),
|
||||
entitledDays: z.number().min(0).max(365),
|
||||
resourceIds: z.array(z.string()).optional(), // if omitted, applies to all active resources
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
...(input.resourceIds ? { id: { in: input.resourceIds } } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
.input(EntitlementBulkSetInputSchema)
|
||||
.mutation(({ ctx, input }) => bulkSetEntitlements(ctx, input)),
|
||||
|
||||
let updated = 0;
|
||||
for (const r of resources) {
|
||||
await ctx.db.vacationEntitlement.upsert({
|
||||
where: { resourceId_year: { resourceId: r.id, year: input.year } },
|
||||
create: {
|
||||
resourceId: r.id,
|
||||
year: input.year,
|
||||
entitledDays: input.entitledDays,
|
||||
carryoverDays: 0,
|
||||
usedDays: 0,
|
||||
pendingDays: 0,
|
||||
},
|
||||
update: { entitledDays: input.entitledDays },
|
||||
});
|
||||
updated++;
|
||||
}
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "VacationEntitlement",
|
||||
entityId: `bulk-${input.year}`,
|
||||
entityName: `Bulk Entitlement ${input.year}`,
|
||||
action: "UPDATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
after: { year: input.year, entitledDays: input.entitledDays, resourceCount: updated } as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Bulk set entitlement to ${input.entitledDays} days for ${updated} resources (${input.year})`,
|
||||
});
|
||||
|
||||
return { updated };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get year summary: all resources with their balance for a given year.
|
||||
* Manager/admin only.
|
||||
*/
|
||||
getYearSummary: managerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
|
||||
chapter: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => readYearSummarySnapshot(ctx, {
|
||||
year: input.year,
|
||||
...(input.chapter ? { chapter: input.chapter } : {}),
|
||||
})),
|
||||
.input(EntitlementYearSummaryInputSchema)
|
||||
.query(({ ctx, input }) => getEntitlementYearSummary(ctx, input)),
|
||||
|
||||
getYearSummaryDetail: managerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
|
||||
chapter: z.string().optional(),
|
||||
resourceName: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const summaries = await readYearSummarySnapshot(ctx, {
|
||||
year: input.year,
|
||||
...(input.chapter ? { chapter: input.chapter } : {}),
|
||||
});
|
||||
|
||||
return mapYearSummaryDetail(input.year, summaries, input.resourceName);
|
||||
}),
|
||||
.input(EntitlementYearSummaryDetailInputSchema)
|
||||
.query(({ ctx, input }) => getEntitlementYearSummaryDetail(ctx, input)),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user