diff --git a/packages/api/src/router/vacation-read.ts b/packages/api/src/router/vacation-read.ts new file mode 100644 index 0000000..0c6ae7b --- /dev/null +++ b/packages/api/src/router/vacation-read.ts @@ -0,0 +1,397 @@ +import { VacationStatus, VacationType } from "@capakraken/db"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; +import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js"; +import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js"; +import { + VACATION_BALANCE_TYPES, + type VacationChargeableInput, +} from "../lib/vacation-deduction-snapshot.js"; +import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js"; +import { protectedProcedure, type TRPCContext } from "../trpc.js"; + +type VacationReadContext = Pick; + +export function canManageVacationReads(ctx: { dbUser: { systemRole: string } | null }): boolean { + const role = ctx.dbUser?.systemRole; + return role === "ADMIN" || role === "MANAGER"; +} + +export async function findOwnedResourceId( + ctx: VacationReadContext, +): Promise { + if (!ctx.dbUser?.id) { + return null; + } + + if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") { + return null; + } + + const resource = await ctx.db.resource.findFirst({ + where: { userId: ctx.dbUser.id }, + select: { id: true }, + }); + + return resource?.id ?? null; +} + +export async function assertCanReadVacationResource( + ctx: VacationReadContext, + resourceId: string, +): Promise { + if (canManageVacationReads(ctx)) { + return; + } + + const ownedResourceId = await findOwnedResourceId(ctx); + if (!ownedResourceId || ownedResourceId !== resourceId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only view vacation data for your own resource", + }); + } +} + +export function isSameUtcDay(left: Date, right: Date): boolean { + return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10); +} + +function mapTeamOverlapDetail(params: { + resource: { displayName: string; chapter: string | null }; + startDate: Date; + endDate: Date; + overlaps: Array<{ + type: VacationType; + status: VacationStatus; + startDate: Date; + endDate: Date; + resource: { displayName: string }; + }>; +}) { + return { + resource: params.resource.displayName, + chapter: params.resource.chapter, + period: `${params.startDate.toISOString().slice(0, 10)} to ${params.endDate.toISOString().slice(0, 10)}`, + overlappingVacations: params.overlaps.map((vacation) => ({ + resource: vacation.resource.displayName, + type: vacation.type, + status: vacation.status, + start: vacation.startDate.toISOString().slice(0, 10), + end: vacation.endDate.toISOString().slice(0, 10), + })), + overlapCount: params.overlaps.length, + }; +} + +export const PreviewVacationRequestSchema = z.object({ + resourceId: z.string(), + type: z.nativeEnum(VacationType), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + isHalfDay: z.boolean().optional(), +}).superRefine((data, ctx) => { + if (data.endDate < data.startDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End date must be after start date", + path: ["endDate"], + }); + } + + if (data.isHalfDay && !isSameUtcDay(data.startDate, data.endDate)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Half-day requests must start and end on the same day", + path: ["isHalfDay"], + }); + } +}); + +export function anonymizeVacationRecord( + vacation: T, + directory: Awaited>, +): T { + return { + ...vacation, + ...(vacation.resource ? { resource: anonymizeResource(vacation.resource, directory) } : {}), + ...(vacation.requestedBy ? { requestedBy: anonymizeUser(vacation.requestedBy, directory) } : {}), + ...(vacation.approvedBy ? { approvedBy: anonymizeUser(vacation.approvedBy, directory) } : {}), + }; +} + +export const vacationReadProcedures = { + previewRequest: protectedProcedure + .input(PreviewVacationRequestSchema) + .query(async ({ ctx, input }) => { + await assertCanReadVacationResource(ctx, input.resourceId); + + const holidayContext = await loadResourceHolidayContext( + ctx.db, + input.resourceId, + input.startDate, + input.endDate, + ); + const vacation: Pick = { + startDate: input.startDate, + endDate: input.endDate, + isHalfDay: input.isHalfDay ?? false, + }; + const requestedDays = countCalendarDaysInPeriod(vacation); + const effectiveDays = VACATION_BALANCE_TYPES.has(input.type) + ? countVacationChargeableDays({ + vacation, + countryCode: holidayContext.countryCode, + federalState: holidayContext.federalState, + metroCityName: holidayContext.metroCityName, + calendarHolidayStrings: holidayContext.calendarHolidayStrings, + publicHolidayStrings: holidayContext.publicHolidayStrings, + }) + : requestedDays; + const publicHolidayDates = [...new Set([ + ...holidayContext.calendarHolidayStrings, + ...holidayContext.publicHolidayStrings, + ])].sort(); + const holidayDetails = publicHolidayDates.map((date) => ({ + date, + source: + holidayContext.calendarHolidayStrings.includes(date) && holidayContext.publicHolidayStrings.includes(date) + ? "CALENDAR_AND_LEGACY" + : holidayContext.calendarHolidayStrings.includes(date) + ? "CALENDAR" + : "LEGACY_PUBLIC_HOLIDAY", + })); + + return { + requestedDays, + effectiveDays, + deductedDays: VACATION_BALANCE_TYPES.has(input.type) ? effectiveDays : 0, + publicHolidayDates, + holidayDetails, + holidayContext: { + countryCode: holidayContext.countryCode ?? null, + countryName: holidayContext.countryName ?? null, + federalState: holidayContext.federalState ?? null, + metroCityName: holidayContext.metroCityName ?? null, + sources: { + hasCalendarHolidays: holidayContext.calendarHolidayStrings.length > 0, + hasLegacyPublicHolidayEntries: holidayContext.publicHolidayStrings.length > 0, + }, + }, + }; + }), + + list: protectedProcedure + .input( + z.object({ + resourceId: z.string().optional(), + status: z.union([z.nativeEnum(VacationStatus), z.array(z.nativeEnum(VacationStatus))]).optional(), + type: z.nativeEnum(VacationType).optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + limit: z.number().min(1).max(500).default(100), + }), + ) + .query(async ({ ctx, input }) => { + let resourceIdFilter = input.resourceId; + + if (!canManageVacationReads(ctx)) { + const ownedResourceId = await findOwnedResourceId(ctx); + if (input.resourceId && input.resourceId !== ownedResourceId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only view vacation data for your own resource", + }); + } + if (!ownedResourceId) { + return []; + } + resourceIdFilter = ownedResourceId; + } + + const vacations = await ctx.db.vacation.findMany({ + where: { + ...(resourceIdFilter ? { resourceId: resourceIdFilter } : {}), + ...(input.status ? { status: Array.isArray(input.status) ? { in: input.status } : input.status } : {}), + ...(input.type ? { type: input.type } : {}), + ...(input.startDate ? { endDate: { gte: input.startDate } } : {}), + ...(input.endDate ? { startDate: { lte: input.endDate } } : {}), + }, + include: { + resource: { select: RESOURCE_BRIEF_SELECT }, + requestedBy: { select: { id: true, name: true, email: true } }, + approvedBy: { select: { id: true, name: true, email: true } }, + }, + orderBy: { startDate: "asc" }, + take: input.limit, + }); + const directory = await getAnonymizationDirectory(ctx.db); + return vacations.map((vacation) => anonymizeVacationRecord(vacation, directory)); + }), + + getById: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const vacation = await findUniqueOrThrow( + ctx.db.vacation.findUnique({ + where: { id: input.id }, + include: { + resource: { select: { ...RESOURCE_BRIEF_SELECT, userId: true } }, + requestedBy: { select: { id: true, name: true, email: true } }, + approvedBy: { select: { id: true, name: true, email: true } }, + }, + }), + "Vacation", + ); + + if (!canManageVacationReads(ctx)) { + const isOwnVacation = vacation.resource?.userId === ctx.dbUser?.id || vacation.requestedById === ctx.dbUser?.id; + if (!isOwnVacation) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only view your own vacation data", + }); + } + } + + const directory = await getAnonymizationDirectory(ctx.db); + const anonymized = anonymizeVacationRecord(vacation, directory); + return { + ...anonymized, + resource: anonymized.resource + ? { + id: anonymized.resource.id, + displayName: anonymized.resource.displayName, + eid: anonymized.resource.eid, + lcrCents: anonymized.resource.lcrCents, + chapter: anonymized.resource.chapter, + } + : null, + }; + }), + + getForResource: protectedProcedure + .input( + z.object({ + resourceId: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + }), + ) + .query(async ({ ctx, input }) => { + await assertCanReadVacationResource(ctx, input.resourceId); + + return ctx.db.vacation.findMany({ + where: { + resourceId: input.resourceId, + status: VacationStatus.APPROVED, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + }, + select: { + id: true, + startDate: true, + endDate: true, + type: true, + status: true, + }, + orderBy: { startDate: "asc" }, + }); + }), + + getTeamOverlap: protectedProcedure + .input( + z.object({ + resourceId: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + }), + ) + .query(async ({ ctx, input }) => { + await assertCanReadVacationResource(ctx, input.resourceId); + + const resource = await ctx.db.resource.findUnique({ + where: { id: input.resourceId }, + select: { chapter: true }, + }); + if (!resource?.chapter) { + return []; + } + + return ctx.db.vacation.findMany({ + where: { + resource: { chapter: resource.chapter }, + resourceId: { not: input.resourceId }, + status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + }, + include: { + resource: { select: RESOURCE_BRIEF_SELECT }, + }, + orderBy: { startDate: "asc" }, + take: 20, + }); + }), + + getTeamOverlapDetail: protectedProcedure + .input( + z.object({ + resourceId: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + }), + ) + .query(async ({ ctx, input }) => { + await assertCanReadVacationResource(ctx, input.resourceId); + + const resource = await ctx.db.resource.findUnique({ + where: { id: input.resourceId }, + select: { displayName: true, chapter: true }, + }); + + if (!resource) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Resource not found", + }); + } + + if (!resource.chapter) { + return mapTeamOverlapDetail({ + resource, + startDate: input.startDate, + endDate: input.endDate, + overlaps: [], + }); + } + + const overlaps = await ctx.db.vacation.findMany({ + where: { + resource: { chapter: resource.chapter }, + resourceId: { not: input.resourceId }, + status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + }, + include: { + resource: { select: RESOURCE_BRIEF_SELECT }, + }, + orderBy: { startDate: "asc" }, + take: 20, + }); + + return mapTeamOverlapDetail({ + resource, + startDate: input.startDate, + endDate: input.endDate, + overlaps, + }); + }), +}; diff --git a/packages/api/src/router/vacation.ts b/packages/api/src/router/vacation.ts index 77a5949..5915ee0 100644 --- a/packages/api/src/router/vacation.ts +++ b/packages/api/src/router/vacation.ts @@ -8,23 +8,49 @@ import { emitVacationCreated, emitVacationUpdated, emitTaskAssigned } from "../s import { createNotification } from "../lib/create-notification.js"; import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; import { sendEmail } from "../lib/email.js"; -import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js"; +import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js"; import { dispatchWebhooks } from "../lib/webhook-dispatcher.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 { loadResourceHolidayContext } from "../lib/resource-holiday-context.js"; +import { + VACATION_BALANCE_TYPES, + buildVacationDeductionSnapshotWriteData, + calculateVacationDeductionSnapshot, + type VacationChargeableInput, +} from "../lib/vacation-deduction-snapshot.js"; import { logger } from "../lib/logger.js"; import type { TRPCContext } from "../trpc.js"; +import { anonymizeVacationRecord, isSameUtcDay, vacationReadProcedures } from "./vacation-read.js"; -/** Types that consume from annual leave balance */ -const BALANCE_TYPES = new Set([VacationType.ANNUAL, VacationType.OTHER]); -type VacationReadContext = Pick; +async function calculateVacationEffectiveDays( + db: TRPCContext["db"], + vacation: VacationChargeableInput, +): Promise { + if (!VACATION_BALANCE_TYPES.has(vacation.type)) { + return countCalendarDaysInPeriod(vacation); + } -function canManageVacationReads(ctx: { dbUser: { systemRole: string } | null }): boolean { - const role = ctx.dbUser?.systemRole; - return role === "ADMIN" || role === "MANAGER"; + 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", + }); + } } function runVacationBackgroundEffect( @@ -68,97 +94,6 @@ function dispatchVacationWebhookInBackground( ); } -async function findOwnedResourceId( - ctx: VacationReadContext, -): Promise { - if (!ctx.dbUser?.id) { - return null; - } - - if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") { - return null; - } - - const resource = await ctx.db.resource.findFirst({ - where: { userId: ctx.dbUser.id }, - select: { id: true }, - }); - - return resource?.id ?? null; -} - -async function assertCanReadVacationResource( - ctx: VacationReadContext, - resourceId: string, -): Promise { - if (canManageVacationReads(ctx)) { - return; - } - - const ownedResourceId = await findOwnedResourceId(ctx); - if (!ownedResourceId || ownedResourceId !== resourceId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You can only view vacation data for your own resource", - }); - } -} - -function isSameUtcDay(left: Date, right: Date): boolean { - return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10); -} - -function mapTeamOverlapDetail(params: { - resource: { displayName: string; chapter: string | null }; - startDate: Date; - endDate: Date; - overlaps: Array<{ - type: VacationType; - status: VacationStatus; - startDate: Date; - endDate: Date; - resource: { displayName: string }; - }>; -}) { - return { - resource: params.resource.displayName, - chapter: params.resource.chapter, - period: `${params.startDate.toISOString().slice(0, 10)} to ${params.endDate.toISOString().slice(0, 10)}`, - overlappingVacations: params.overlaps.map((vacation) => ({ - resource: vacation.resource.displayName, - type: vacation.type, - status: vacation.status, - start: vacation.startDate.toISOString().slice(0, 10), - end: vacation.endDate.toISOString().slice(0, 10), - })), - overlapCount: params.overlaps.length, - }; -} - -const PreviewVacationRequestSchema = z.object({ - resourceId: z.string(), - type: z.nativeEnum(VacationType), - startDate: z.coerce.date(), - endDate: z.coerce.date(), - isHalfDay: z.boolean().optional(), -}).superRefine((data, ctx) => { - if (data.endDate < data.startDate) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "End date must be after start date", - path: ["endDate"], - }); - } - - if (data.isHalfDay && !isSameUtcDay(data.startDate, data.endDate)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Half-day requests must start and end on the same day", - path: ["isHalfDay"], - }); - } -}); - const CreateVacationRequestSchema = z.object({ resourceId: z.string(), type: z.nativeEnum(VacationType), @@ -201,22 +136,6 @@ const CreateVacationRequestSchema = z.object({ } }); -function anonymizeVacationRecord( - vacation: T, - directory: Awaited>, -): T { - return { - ...vacation, - ...(vacation.resource ? { resource: anonymizeResource(vacation.resource, directory) } : {}), - ...(vacation.requestedBy ? { requestedBy: anonymizeUser(vacation.requestedBy, directory) } : {}), - ...(vacation.approvedBy ? { approvedBy: anonymizeUser(vacation.approvedBy, directory) } : {}), - }; -} - /** Send in-app notification + optional email when vacation status changes */ async function notifyVacationStatus( db: Parameters[0]>[0]["ctx"]["db"], @@ -263,160 +182,7 @@ async function notifyVacationStatus( } export const vacationRouter = createTRPCRouter({ - previewRequest: protectedProcedure - .input(PreviewVacationRequestSchema) - .query(async ({ ctx, input }) => { - await assertCanReadVacationResource(ctx, input.resourceId); - - const holidayContext = await loadResourceHolidayContext( - ctx.db, - input.resourceId, - input.startDate, - input.endDate, - ); - const vacation = { - startDate: input.startDate, - endDate: input.endDate, - isHalfDay: input.isHalfDay ?? false, - }; - const requestedDays = countCalendarDaysInPeriod(vacation); - const effectiveDays = BALANCE_TYPES.has(input.type) - ? countVacationChargeableDays({ - vacation, - countryCode: holidayContext.countryCode, - federalState: holidayContext.federalState, - metroCityName: holidayContext.metroCityName, - calendarHolidayStrings: holidayContext.calendarHolidayStrings, - publicHolidayStrings: holidayContext.publicHolidayStrings, - }) - : requestedDays; - const publicHolidayDates = [...new Set([ - ...holidayContext.calendarHolidayStrings, - ...holidayContext.publicHolidayStrings, - ])].sort(); - const holidayDetails = publicHolidayDates.map((date) => ({ - date, - source: - holidayContext.calendarHolidayStrings.includes(date) && holidayContext.publicHolidayStrings.includes(date) - ? "CALENDAR_AND_LEGACY" - : holidayContext.calendarHolidayStrings.includes(date) - ? "CALENDAR" - : "LEGACY_PUBLIC_HOLIDAY", - })); - - return { - requestedDays, - effectiveDays, - deductedDays: BALANCE_TYPES.has(input.type) ? effectiveDays : 0, - publicHolidayDates, - holidayDetails, - holidayContext: { - countryCode: holidayContext.countryCode ?? null, - countryName: holidayContext.countryName ?? null, - federalState: holidayContext.federalState ?? null, - metroCityName: holidayContext.metroCityName ?? null, - sources: { - hasCalendarHolidays: holidayContext.calendarHolidayStrings.length > 0, - hasLegacyPublicHolidayEntries: holidayContext.publicHolidayStrings.length > 0, - }, - }, - }; - }), - - /** - * List vacations with optional filters. - */ - list: protectedProcedure - .input( - z.object({ - resourceId: z.string().optional(), - status: z.union([z.nativeEnum(VacationStatus), z.array(z.nativeEnum(VacationStatus))]).optional(), - type: z.nativeEnum(VacationType).optional(), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), - limit: z.number().min(1).max(500).default(100), - }), - ) - .query(async ({ ctx, input }) => { - let resourceIdFilter = input.resourceId; - - if (!canManageVacationReads(ctx)) { - const ownedResourceId = await findOwnedResourceId(ctx); - if (input.resourceId && input.resourceId !== ownedResourceId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You can only view vacation data for your own resource", - }); - } - if (!ownedResourceId) { - return []; - } - resourceIdFilter = ownedResourceId; - } - - const vacations = await ctx.db.vacation.findMany({ - where: { - ...(resourceIdFilter ? { resourceId: resourceIdFilter } : {}), - ...(input.status ? { status: Array.isArray(input.status) ? { in: input.status } : input.status } : {}), - ...(input.type ? { type: input.type } : {}), - ...(input.startDate ? { endDate: { gte: input.startDate } } : {}), - ...(input.endDate ? { startDate: { lte: input.endDate } } : {}), - }, - include: { - resource: { select: RESOURCE_BRIEF_SELECT }, - requestedBy: { select: { id: true, name: true, email: true } }, - approvedBy: { select: { id: true, name: true, email: true } }, - }, - orderBy: { startDate: "asc" }, - take: input.limit, - }); - const directory = await getAnonymizationDirectory(ctx.db); - return vacations.map((vacation) => anonymizeVacationRecord(vacation, directory)); - }), - - /** - * Get a single vacation by ID. - */ - getById: protectedProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const vacation = await findUniqueOrThrow( - ctx.db.vacation.findUnique({ - where: { id: input.id }, - include: { - resource: { select: { ...RESOURCE_BRIEF_SELECT, userId: true } }, - requestedBy: { select: { id: true, name: true, email: true } }, - approvedBy: { select: { id: true, name: true, email: true } }, - }, - }), - "Vacation", - ); - - if (!canManageVacationReads(ctx)) { - const isOwnVacation = vacation.resource?.userId === ctx.dbUser?.id || vacation.requestedById === ctx.dbUser?.id; - if (!isOwnVacation) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You can only view your own vacation data", - }); - } - } - - const directory = await getAnonymizationDirectory(ctx.db); - const anonymized = anonymizeVacationRecord(vacation, directory); - return { - ...anonymized, - resource: anonymized.resource - ? { - id: anonymized.resource.id, - displayName: anonymized.resource.displayName, - eid: anonymized.resource.eid, - lcrCents: anonymized.resource.lcrCents, - chapter: anonymized.resource.chapter, - } - : null, - }; - }), + ...vacationReadProcedures, /** * Create a vacation request. @@ -464,7 +230,7 @@ export const vacationRouter = createTRPCRouter({ status: { in: ["APPROVED", "PENDING"] }, startDate: { lte: input.endDate }, endDate: { gte: input.startDate }, - ...(BALANCE_TYPES.has(input.type) + ...(VACATION_BALANCE_TYPES.has(input.type) ? { type: { not: VacationType.PUBLIC_HOLIDAY } } : {}), }, @@ -477,25 +243,17 @@ export const vacationRouter = createTRPCRouter({ } let effectiveDays: number | null = null; - if (BALANCE_TYPES.has(input.type)) { - const holidayContext = await loadResourceHolidayContext( - ctx.db, - input.resourceId, - input.startDate, - input.endDate, - ); - effectiveDays = countVacationChargeableDays({ - vacation: { - startDate: input.startDate, - endDate: input.endDate, - isHalfDay: input.isHalfDay ?? false, - }, - countryCode: holidayContext.countryCode, - federalState: holidayContext.federalState, - metroCityName: holidayContext.metroCityName, - calendarHolidayStrings: holidayContext.calendarHolidayStrings, - publicHolidayStrings: holidayContext.publicHolidayStrings, + 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({ @@ -517,6 +275,7 @@ export const vacationRouter = createTRPCRouter({ ...(input.note !== undefined ? { note: input.note } : {}), isHalfDay: input.isHalfDay ?? false, ...(input.halfDayPart !== undefined ? { halfDayPart: input.halfDayPart } : {}), + ...(deductionSnapshotWriteData ?? { deductedDays: 0 }), requestedById: userRecord.id, ...(isManager ? { approvedById: userRecord.id, approvedAt: new Date() } @@ -594,6 +353,25 @@ export const vacationRouter = createTRPCRouter({ throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved" }); } + await assertVacationStillChargeable(ctx.db, { + resourceId: existing.resourceId, + type: existing.type, + startDate: existing.startDate, + 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 userRecord = await ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, select: { id: true }, @@ -608,6 +386,7 @@ export const vacationRouter = createTRPCRouter({ data: { status: VacationStatus.APPROVED, rejectionReason: null, + ...deductionSnapshotWriteData, ...(userRecord?.id ? { approvedById: userRecord.id } : {}), approvedAt: new Date(), }, @@ -735,9 +514,20 @@ export const vacationRouter = createTRPCRouter({ const vacations = await ctx.db.vacation.findMany({ where: { id: { in: input.ids }, status: VacationStatus.PENDING }, - select: { id: true, resourceId: true }, + select: { + id: true, + resourceId: true, + type: true, + startDate: true, + endDate: true, + isHalfDay: true, + }, }); + for (const vacation of vacations) { + await assertVacationStillChargeable(ctx.db, vacation); + } + // Check for team conflicts before approving (non-blocking) const conflictMap = await checkBatchVacationConflicts( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -746,28 +536,40 @@ export const vacationRouter = createTRPCRouter({ userRecord?.id, ); - await ctx.db.vacation.updateMany({ - where: { id: { in: vacations.map((v) => v.id) } }, - data: { - status: VacationStatus.APPROVED, - rejectionReason: null, - ...(userRecord?.id ? { approvedById: userRecord.id } : {}), - approvedAt: new Date(), - }, - }); - for (const v of vacations) { - emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED }); - notifyVacationStatusInBackground(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED); + 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 updated = await ctx.db.vacation.update({ + where: { id: v.id }, + data: { + status: VacationStatus.APPROVED, + rejectionReason: null, + ...deductionSnapshotWriteData, + ...(userRecord?.id ? { approvedById: userRecord.id } : {}), + approvedAt: new Date(), + }, + }); + + emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); + notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED); void createAuditEntry({ db: ctx.db, entityType: "Vacation", - entityId: v.id, - entityName: `Vacation ${v.id}`, + entityId: updated.id, + entityName: `Vacation ${updated.id}`, action: "UPDATE", ...(userRecord?.id ? { userId: userRecord.id } : {}), - after: { status: VacationStatus.APPROVED } as unknown as Record, + after: updated as unknown as Record, source: "ui", summary: "Batch approved vacation", }); @@ -775,7 +577,7 @@ export const vacationRouter = createTRPCRouter({ // Mark approval tasks as DONE await ctx.db.notification.updateMany({ where: { - taskAction: buildTaskAction("approve_vacation", v.id), + taskAction: buildTaskAction("approve_vacation", updated.id), category: "APPROVAL", taskStatus: "OPEN", }, @@ -926,38 +728,6 @@ export const vacationRouter = createTRPCRouter({ return updated; }), - /** - * Get all APPROVED vacations for a resource in a date range (used by calculator). - */ - getForResource: protectedProcedure - .input( - z.object({ - resourceId: z.string(), - startDate: z.coerce.date(), - endDate: z.coerce.date(), - }), - ) - .query(async ({ ctx, input }) => { - await assertCanReadVacationResource(ctx, input.resourceId); - - return ctx.db.vacation.findMany({ - where: { - resourceId: input.resourceId, - status: VacationStatus.APPROVED, - startDate: { lte: input.endDate }, - endDate: { gte: input.startDate }, - }, - select: { - id: true, - startDate: true, - endDate: true, - type: true, - status: true, - }, - orderBy: { startDate: "asc" }, - }); - }), - /** * Get all PENDING vacations awaiting approval (manager/admin only). */ @@ -972,100 +742,6 @@ export const vacationRouter = createTRPCRouter({ }); }), - /** - * Get team overlap: other vacations in the same chapter for a given period. - * Used by the creation modal to warn the requester. - */ - getTeamOverlap: protectedProcedure - .input( - z.object({ - resourceId: z.string(), - startDate: z.coerce.date(), - endDate: z.coerce.date(), - }), - ) - .query(async ({ ctx, input }) => { - await assertCanReadVacationResource(ctx, input.resourceId); - - // Find the chapter of the requesting resource - const resource = await ctx.db.resource.findUnique({ - where: { id: input.resourceId }, - select: { chapter: true }, - }); - if (!resource?.chapter) return []; - - // Find team members in the same chapter who are off in this period - return ctx.db.vacation.findMany({ - where: { - resource: { chapter: resource.chapter }, - resourceId: { not: input.resourceId }, - status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, - startDate: { lte: input.endDate }, - endDate: { gte: input.startDate }, - }, - include: { - resource: { select: RESOURCE_BRIEF_SELECT }, - }, - orderBy: { startDate: "asc" }, - take: 20, - }); - }), - - getTeamOverlapDetail: protectedProcedure - .input( - z.object({ - resourceId: z.string(), - startDate: z.coerce.date(), - endDate: z.coerce.date(), - }), - ) - .query(async ({ ctx, input }) => { - await assertCanReadVacationResource(ctx, input.resourceId); - - const resource = await ctx.db.resource.findUnique({ - where: { id: input.resourceId }, - select: { displayName: true, chapter: true }, - }); - - if (!resource) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Resource not found", - }); - } - - if (!resource.chapter) { - return mapTeamOverlapDetail({ - resource, - startDate: input.startDate, - endDate: input.endDate, - overlaps: [], - }); - } - - const overlaps = await ctx.db.vacation.findMany({ - where: { - resource: { chapter: resource.chapter }, - resourceId: { not: input.resourceId }, - status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, - startDate: { lte: input.endDate }, - endDate: { gte: input.startDate }, - }, - include: { - resource: { select: RESOURCE_BRIEF_SELECT }, - }, - orderBy: { startDate: "asc" }, - take: 20, - }); - - return mapTeamOverlapDetail({ - resource, - startDate: input.startDate, - endDate: input.endDate, - overlaps, - }); - }), - /** * Batch-create public holidays for all resources (or a chapter) for a given year+state. * Admin-only. Creates as APPROVED automatically.