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 { protectedProcedure, type TRPCContext } from "../trpc.js"; import { buildVacationPreview, findVacationResourceChapter, listChapterVacationOverlaps, } from "./vacation-read-support.js"; import { assertCanReadOwnedResource, canManageOwnedResourceReads, resolveOwnedResourceReadFilter, } from "./resource-owned-read-access.js"; type VacationReadContext = Pick; export function canManageVacationReads(ctx: { dbUser: { systemRole: string } | null }): boolean { return canManageOwnedResourceReads(ctx); } export async function assertCanReadVacationResource( ctx: VacationReadContext, resourceId: string, ): Promise { await assertCanReadOwnedResource( ctx, resourceId, "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, ); return buildVacationPreview({ type: input.type, startDate: input.startDate, endDate: input.endDate, isHalfDay: input.isHalfDay ?? false, holidayContext, }); }), 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 }) => { const resourceIdFilter = await resolveOwnedResourceReadFilter( ctx, input.resourceId, "You can only view vacation data for your own resource", ); if (!canManageVacationReads(ctx) && !resourceIdFilter) { return []; } 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 chapter = await findVacationResourceChapter(ctx.db, input.resourceId); if (!chapter) { return []; } return listChapterVacationOverlaps(ctx.db, { chapter, resourceId: input.resourceId, startDate: input.startDate, endDate: input.endDate, }); }), 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 listChapterVacationOverlaps(ctx.db, { chapter: resource.chapter, resourceId: input.resourceId, startDate: input.startDate, endDate: input.endDate, }); return mapTeamOverlapDetail({ resource, startDate: input.startDate, endDate: input.endDate, overlaps, }); }), };