refactor(api): split timeline read router by concern
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
import { listAssignmentBookings } from "@capakraken/application";
|
||||
import { computeBudgetStatus, validateShift } from "@capakraken/engine";
|
||||
import { ShiftProjectSchema } from "@capakraken/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import { controllerProcedure } from "../trpc.js";
|
||||
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
|
||||
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
|
||||
import { loadTimelineHolidayOverlays, formatHolidayOverlays, summarizeHolidayOverlays } from "./timeline-holiday-read.js";
|
||||
import {
|
||||
anonymizeResourceOnEntry,
|
||||
createTimelineDateRange,
|
||||
fmtDate,
|
||||
getAssignmentResourceIds,
|
||||
rangesOverlap,
|
||||
ShiftDbClient,
|
||||
summarizeTimelineEntries,
|
||||
toDate,
|
||||
} from "./timeline-read-shared.js";
|
||||
|
||||
export async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) {
|
||||
const [project, planningRead] = await Promise.all([
|
||||
findUniqueOrThrow(
|
||||
db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: {
|
||||
id: true,
|
||||
budgetCents: true,
|
||||
winProbability: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
}),
|
||||
"Project",
|
||||
),
|
||||
loadProjectPlanningReadModel(db, { projectId, activeOnly: true }),
|
||||
]);
|
||||
|
||||
const { demandRequirements, assignments, readModel: projectReadModel } = planningRead;
|
||||
|
||||
const resourceIds = getAssignmentResourceIds(projectReadModel);
|
||||
const allAssignmentWindows =
|
||||
resourceIds.length === 0
|
||||
? []
|
||||
: (
|
||||
await listAssignmentBookings(db, {
|
||||
resourceIds,
|
||||
})
|
||||
).map((booking) => ({
|
||||
id: booking.id,
|
||||
resourceId: booking.resourceId!,
|
||||
projectId: booking.projectId,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
status: booking.status,
|
||||
}));
|
||||
|
||||
const shiftPlan = buildTimelineShiftPlan({
|
||||
demandRequirements,
|
||||
assignments,
|
||||
allAssignmentWindows,
|
||||
});
|
||||
|
||||
return {
|
||||
project,
|
||||
demandRequirements,
|
||||
assignments,
|
||||
shiftPlan,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadTimelineProjectContext(db: ShiftDbClient, projectId: string) {
|
||||
const [project, planningRead] = await Promise.all([
|
||||
findUniqueOrThrow(
|
||||
db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortCode: true,
|
||||
orderType: true,
|
||||
budgetCents: true,
|
||||
winProbability: true,
|
||||
status: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
staffingReqs: true,
|
||||
},
|
||||
}),
|
||||
"Project",
|
||||
),
|
||||
loadProjectPlanningReadModel(db, {
|
||||
projectId,
|
||||
activeOnly: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const resourceIds = getAssignmentResourceIds(planningRead.readModel);
|
||||
const allResourceAllocations =
|
||||
resourceIds.length === 0
|
||||
? []
|
||||
: await listAssignmentBookings(db, {
|
||||
resourceIds,
|
||||
});
|
||||
|
||||
return {
|
||||
project,
|
||||
allocations: planningRead.readModel.allocations,
|
||||
demands: planningRead.readModel.demands,
|
||||
assignments: planningRead.readModel.assignments,
|
||||
allResourceAllocations,
|
||||
resourceIds,
|
||||
};
|
||||
}
|
||||
|
||||
export async function previewTimelineProjectShift(
|
||||
db: ShiftDbClient,
|
||||
input: {
|
||||
projectId: string;
|
||||
newStartDate: Date;
|
||||
newEndDate: Date;
|
||||
},
|
||||
) {
|
||||
const { project, shiftPlan } = await loadProjectShiftContext(db, input.projectId);
|
||||
|
||||
return validateShift({
|
||||
project: {
|
||||
id: project.id,
|
||||
budgetCents: project.budgetCents,
|
||||
winProbability: project.winProbability,
|
||||
startDate: project.startDate,
|
||||
endDate: project.endDate,
|
||||
},
|
||||
newStartDate: input.newStartDate,
|
||||
newEndDate: input.newEndDate,
|
||||
allocations: shiftPlan.validationAllocations,
|
||||
});
|
||||
}
|
||||
|
||||
export const timelineProjectReadProcedures = {
|
||||
getProjectContext: controllerProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const {
|
||||
project,
|
||||
allocations,
|
||||
demands,
|
||||
assignments,
|
||||
allResourceAllocations,
|
||||
resourceIds,
|
||||
} = await loadTimelineProjectContext(ctx.db, input.projectId);
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
return {
|
||||
project,
|
||||
allocations: allocations.map((allocation) =>
|
||||
anonymizeResourceOnEntry(allocation, directory),
|
||||
),
|
||||
demands,
|
||||
assignments: assignments.map((assignment) =>
|
||||
anonymizeResourceOnEntry(assignment, directory),
|
||||
),
|
||||
allResourceAllocations: allResourceAllocations.map((allocation) =>
|
||||
anonymizeResourceOnEntry(allocation, directory),
|
||||
),
|
||||
resourceIds,
|
||||
};
|
||||
}),
|
||||
|
||||
getProjectContextDetail: controllerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
durationDays: z.number().int().min(1).max(366).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId);
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
const derivedStartDate = input.startDate
|
||||
? createTimelineDateRange({ startDate: input.startDate, durationDays: 1 }).startDate
|
||||
: projectContext.project.startDate
|
||||
?? projectContext.assignments[0]?.startDate
|
||||
?? projectContext.demands[0]?.startDate
|
||||
?? createTimelineDateRange({ durationDays: 1 }).startDate;
|
||||
const derivedEndDate = input.endDate
|
||||
? createTimelineDateRange({ startDate: fmtDate(derivedStartDate) ?? undefined, endDate: input.endDate }).endDate
|
||||
: projectContext.project.endDate
|
||||
?? createTimelineDateRange({
|
||||
startDate: fmtDate(derivedStartDate) ?? undefined,
|
||||
durationDays: input.durationDays ?? 21,
|
||||
}).endDate;
|
||||
|
||||
if (derivedEndDate < derivedStartDate) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "endDate must be on or after startDate.",
|
||||
});
|
||||
}
|
||||
|
||||
const holidayOverlays = projectContext.resourceIds.length > 0
|
||||
? await loadTimelineHolidayOverlays(ctx.db, {
|
||||
startDate: derivedStartDate,
|
||||
endDate: derivedEndDate,
|
||||
resourceIds: projectContext.resourceIds,
|
||||
projectIds: [input.projectId],
|
||||
})
|
||||
: [];
|
||||
const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);
|
||||
|
||||
const assignmentConflicts = projectContext.assignments
|
||||
.filter((assignment) => assignment.resourceId && assignment.resource)
|
||||
.map((assignment) => {
|
||||
const overlaps = projectContext.allResourceAllocations
|
||||
.filter((booking) => (
|
||||
booking.resourceId === assignment.resourceId
|
||||
&& booking.id !== assignment.id
|
||||
&& rangesOverlap(
|
||||
toDate(booking.startDate),
|
||||
toDate(booking.endDate),
|
||||
toDate(assignment.startDate),
|
||||
toDate(assignment.endDate),
|
||||
)
|
||||
))
|
||||
.map((booking) => ({
|
||||
id: booking.id,
|
||||
projectId: booking.projectId,
|
||||
projectName: booking.project?.name ?? null,
|
||||
projectShortCode: booking.project?.shortCode ?? null,
|
||||
startDate: fmtDate(toDate(booking.startDate)),
|
||||
endDate: fmtDate(toDate(booking.endDate)),
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
status: booking.status,
|
||||
sameProject: booking.projectId === input.projectId,
|
||||
}));
|
||||
|
||||
return {
|
||||
assignmentId: assignment.id,
|
||||
resourceId: assignment.resourceId!,
|
||||
resourceName: assignment.resource?.displayName ?? null,
|
||||
startDate: fmtDate(toDate(assignment.startDate)),
|
||||
endDate: fmtDate(toDate(assignment.endDate)),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
overlapCount: overlaps.length,
|
||||
crossProjectOverlapCount: overlaps.filter((booking) => !booking.sameProject).length,
|
||||
overlaps,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
project: projectContext.project,
|
||||
period: {
|
||||
startDate: fmtDate(derivedStartDate),
|
||||
endDate: fmtDate(derivedEndDate),
|
||||
},
|
||||
summary: {
|
||||
...summarizeTimelineEntries({
|
||||
allocations: projectContext.allocations,
|
||||
demands: projectContext.demands,
|
||||
assignments: projectContext.assignments,
|
||||
}),
|
||||
resourceIds: projectContext.resourceIds.length,
|
||||
allResourceAllocationCount: projectContext.allResourceAllocations.length,
|
||||
conflictedAssignmentCount: assignmentConflicts.filter((item) => item.crossProjectOverlapCount > 0).length,
|
||||
...summarizeHolidayOverlays(formattedHolidayOverlays),
|
||||
},
|
||||
allocations: projectContext.allocations.map((allocation) =>
|
||||
anonymizeResourceOnEntry(allocation, directory),
|
||||
),
|
||||
demands: projectContext.demands,
|
||||
assignments: projectContext.assignments.map((assignment) =>
|
||||
anonymizeResourceOnEntry(assignment, directory),
|
||||
),
|
||||
allResourceAllocations: projectContext.allResourceAllocations.map((allocation) =>
|
||||
anonymizeResourceOnEntry(allocation, directory),
|
||||
),
|
||||
assignmentConflicts,
|
||||
holidayOverlays: formattedHolidayOverlays,
|
||||
resourceIds: projectContext.resourceIds,
|
||||
};
|
||||
}),
|
||||
|
||||
previewShift: controllerProcedure
|
||||
.input(ShiftProjectSchema)
|
||||
.query(async ({ ctx, input }) => previewTimelineProjectShift(ctx.db, input)),
|
||||
|
||||
getShiftPreviewDetail: controllerProcedure
|
||||
.input(ShiftProjectSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [project, preview] = await Promise.all([
|
||||
findUniqueOrThrow(
|
||||
ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortCode: true,
|
||||
status: true,
|
||||
responsiblePerson: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
}),
|
||||
"Project",
|
||||
),
|
||||
previewTimelineProjectShift(ctx.db, input),
|
||||
]);
|
||||
|
||||
return {
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
shortCode: project.shortCode,
|
||||
status: project.status,
|
||||
responsiblePerson: project.responsiblePerson,
|
||||
startDate: fmtDate(project.startDate),
|
||||
endDate: fmtDate(project.endDate),
|
||||
},
|
||||
requestedShift: {
|
||||
newStartDate: fmtDate(input.newStartDate),
|
||||
newEndDate: fmtDate(input.newEndDate),
|
||||
},
|
||||
preview,
|
||||
};
|
||||
}),
|
||||
|
||||
getBudgetStatus: controllerProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const project = await findUniqueOrThrow(
|
||||
ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortCode: true,
|
||||
budgetCents: true,
|
||||
winProbability: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
}),
|
||||
"Project",
|
||||
);
|
||||
|
||||
const bookings = await listAssignmentBookings(ctx.db, {
|
||||
projectIds: [project.id],
|
||||
});
|
||||
|
||||
const budgetStatus = computeBudgetStatus(
|
||||
project.budgetCents,
|
||||
project.winProbability,
|
||||
bookings.map((booking) => ({
|
||||
status: booking.status,
|
||||
dailyCostCents: booking.dailyCostCents,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
})) as unknown as Pick<
|
||||
import("@capakraken/shared").Allocation,
|
||||
"status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay"
|
||||
>[],
|
||||
project.startDate,
|
||||
project.endDate,
|
||||
);
|
||||
|
||||
return {
|
||||
...budgetStatus,
|
||||
projectName: project.name,
|
||||
projectCode: project.shortCode,
|
||||
totalAllocations: bookings.length,
|
||||
budgetCents: project.budgetCents,
|
||||
};
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user