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, }); }), };