312 lines
9.6 KiB
TypeScript
312 lines
9.6 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 { 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<TRPCContext, "db" | "dbUser">;
|
|
|
|
export function canManageVacationReads(ctx: { dbUser: { systemRole: string } | null }): boolean {
|
|
return canManageOwnedResourceReads(ctx);
|
|
}
|
|
|
|
export async function assertCanReadVacationResource(
|
|
ctx: VacationReadContext,
|
|
resourceId: string,
|
|
): Promise<void> {
|
|
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<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,
|
|
);
|
|
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,
|
|
});
|
|
}),
|
|
};
|