diff --git a/docs/api-router-procedure-support-backlog.md b/docs/api-router-procedure-support-backlog.md index 6cbe4c4..d35120c 100644 --- a/docs/api-router-procedure-support-backlog.md +++ b/docs/api-router-procedure-support-backlog.md @@ -19,15 +19,15 @@ Done - `notification` - `dashboard` - `resource-summary-read` +- `user` +- `entitlement` Ready next - none in the conflict-safe backlog Deferred or blocked - `assistant-tools` -- `entitlement` - `resource-read-shared` -- `user` - `timeline-router` tests - `vacation-router` tests diff --git a/packages/api/src/router/entitlement-procedure-support.ts b/packages/api/src/router/entitlement-procedure-support.ts new file mode 100644 index 0000000..4e37dbf --- /dev/null +++ b/packages/api/src/router/entitlement-procedure-support.ts @@ -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; +type EntitlementWriteContext = Pick; + +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, +) { + 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, + input: z.infer, +) { + 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>>, +): Promise { + 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 = new Set(), +): Promise { + 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>> | 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, +) { + return readBalanceSnapshot(ctx, input); +} + +export async function getEntitlementBalanceDetail( + ctx: EntitlementReadContext, + input: z.infer, +) { + 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, + input: z.infer, +) { + 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, +) { + 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, + after: updated as unknown as Record, + 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, + source: "ui", + summary: `Set entitlement to ${input.entitledDays} days (${input.year})`, + }); + + return created; +} + +export async function bulkSetEntitlements( + ctx: EntitlementWriteContext, + input: z.infer, +) { + 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, + source: "ui", + summary: `Bulk set entitlement to ${input.entitledDays} days for ${updated} resources (${input.year})`, + }); + + return { updated }; +} + +export async function getEntitlementYearSummary( + ctx: Pick, + input: z.infer, +) { + return readYearSummarySnapshot(ctx, input); +} + +export async function getEntitlementYearSummaryDetail( + ctx: Pick, + input: z.infer, +) { + const summaries = await readYearSummarySnapshot(ctx, { + year: input.year, + ...(input.chapter ? { chapter: input.chapter } : {}), + }); + + return mapYearSummaryDetail(input.year, summaries, input.resourceName); +} diff --git a/packages/api/src/router/entitlement.ts b/packages/api/src/router/entitlement.ts index b52a9a8..02c6c5f 100644 --- a/packages/api/src/router/entitlement.ts +++ b/packages/api/src/router/entitlement.ts @@ -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[0]>[0]["ctx"]; - -async function readBalanceSnapshot( - ctx: Pick, - 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, - 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[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>>, -): Promise { - 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[0]>[0]["ctx"]["db"], - resourceId: string, - year: number, - defaultDays: number, - visitedYears: Set = new Set(), -): Promise { - 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>> | 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, - after: updated as unknown as Record, - 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, - 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, - 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)), });