refactor(api): extract vacation read procedures
This commit is contained in:
+107
-431
@@ -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>([VacationType.ANNUAL, VacationType.OTHER]);
|
||||
type VacationReadContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||
async function calculateVacationEffectiveDays(
|
||||
db: TRPCContext["db"],
|
||||
vacation: VacationChargeableInput,
|
||||
): Promise<number> {
|
||||
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<void> {
|
||||
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<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;
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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<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) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Send in-app notification + optional email when vacation status changes */
|
||||
async function notifyVacationStatus(
|
||||
db: Parameters<Parameters<typeof protectedProcedure["query"]>[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<typeof buildVacationDeductionSnapshotWriteData> | 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<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user