Files
CapaKraken/packages/api/src/router/vacation-read.ts
T

398 lines
13 KiB
TypeScript

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<TRPCContext, "db" | "dbUser">;
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<string | null> {
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<void> {
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<T extends {
resource?: { id: string } | null;
requestedBy?: { id?: string | null; name?: string | null; email?: string | null } | null;
approvedBy?: { id?: string | null; name?: string | null; email?: string | null } | null;
}>(
vacation: T,
directory: Awaited<ReturnType<typeof getAnonymizationDirectory>>,
): 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<VacationChargeableInput, "startDate" | "endDate" | "isHalfDay"> = {
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,
});
}),
};