refactor(api): split timeline read router by concern

This commit is contained in:
2026-03-31 07:45:15 +02:00
parent 857914a38f
commit 5e4c0f3610
6 changed files with 1042 additions and 983 deletions
@@ -0,0 +1,111 @@
import { z } from "zod";
import { getAnonymizationDirectory } from "../lib/anonymization.js";
import { controllerProcedure, protectedProcedure } from "../trpc.js";
import {
anonymizeResourceOnEntry,
buildSelfServiceTimelineInput,
createEmptyTimelineEntriesView,
createTimelineDateRange,
createTimelineFilters,
fmtDate,
loadTimelineEntriesReadModel,
summarizeTimelineEntries,
TimelineWindowFiltersSchema,
} from "./timeline-read-shared.js";
import {
formatHolidayOverlays,
loadTimelineHolidayOverlaysForReadModel,
summarizeHolidayOverlays,
} from "./timeline-holiday-read.js";
export const timelineEntryReadProcedures = {
getEntries: controllerProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
const directory = await getAnonymizationDirectory(ctx.db);
return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory));
}),
getEntriesView: controllerProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const [readModel, directory] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, input),
getAnonymizationDirectory(ctx.db),
]);
return {
...readModel,
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
};
}),
getMyEntriesView: protectedProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input);
if (!selfServiceInput) {
return createEmptyTimelineEntriesView();
}
const [readModel, directory] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, selfServiceInput),
getAnonymizationDirectory(ctx.db),
]);
return {
...readModel,
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
};
}),
getEntriesDetail: controllerProcedure
.input(
z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
durationDays: z.number().int().min(1).max(366).optional(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { startDate, endDate } = createTimelineDateRange(input);
const filters = createTimelineFilters(input);
const timelineInput = { ...filters, startDate, endDate };
const [readModel, directory] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, timelineInput),
getAnonymizationDirectory(ctx.db),
]);
const holidayOverlays = await loadTimelineHolidayOverlaysForReadModel(
ctx.db,
timelineInput,
readModel,
);
const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);
return {
period: {
startDate: fmtDate(startDate),
endDate: fmtDate(endDate),
},
filters,
summary: {
...summarizeTimelineEntries(readModel),
...summarizeHolidayOverlays(formattedHolidayOverlays),
},
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
demands: readModel.demands,
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
holidayOverlays: formattedHolidayOverlays,
};
}),
};
@@ -0,0 +1,234 @@
import { buildSplitAllocationReadModel } from "@capakraken/application";
import { Prisma, VacationType } from "@capakraken/db";
import { z } from "zod";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { controllerProcedure, protectedProcedure } from "../trpc.js";
import {
buildSelfServiceTimelineInput,
createTimelineDateRange,
createTimelineFilters,
fmtDate,
loadTimelineEntriesReadModel,
TimelineEntriesDbClient,
TimelineEntriesFilters,
TimelineWindowFiltersSchema,
} from "./timeline-read-shared.js";
export function formatHolidayOverlays(
overlays: Array<{
id: string;
resourceId: string;
startDate: Date;
endDate: Date;
note?: string | null;
scope?: string | null;
calendarName?: string | null;
sourceType?: string | null;
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
}>,
) {
return overlays.map((overlay) => ({
id: overlay.id,
resourceId: overlay.resourceId,
startDate: fmtDate(overlay.startDate),
endDate: fmtDate(overlay.endDate),
note: overlay.note ?? null,
scope: overlay.scope ?? null,
calendarName: overlay.calendarName ?? null,
sourceType: overlay.sourceType ?? null,
countryCode: overlay.countryCode ?? null,
countryName: overlay.countryName ?? null,
federalState: overlay.federalState ?? null,
metroCityName: overlay.metroCityName ?? null,
}));
}
export function summarizeHolidayOverlays(
overlays: ReturnType<typeof formatHolidayOverlays>,
) {
const resourceIds = new Set<string>();
const byScope = new Map<string, number>();
for (const overlay of overlays) {
resourceIds.add(overlay.resourceId);
const scope = overlay.scope ?? "UNKNOWN";
byScope.set(scope, (byScope.get(scope) ?? 0) + 1);
}
return {
overlayCount: overlays.length,
holidayResourceCount: resourceIds.size,
byScope: [...byScope.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([scope, count]) => ({ scope, count })),
};
}
export async function loadTimelineHolidayOverlays(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
) {
const readModel = await loadTimelineEntriesReadModel(db, input);
return loadTimelineHolidayOverlaysForReadModel(db, input, readModel);
}
export async function loadTimelineHolidayOverlaysForReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
) {
const resourceIds = [...new Set(
readModel.assignments
.map((assignment) => assignment.resourceId)
.filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0),
)];
if (input.resourceIds && input.resourceIds.length > 0) {
for (const resourceId of input.resourceIds) {
if (resourceId && !resourceIds.includes(resourceId)) {
resourceIds.push(resourceId);
}
}
}
const hasResourceFilters =
(input.chapters?.length ?? 0) > 0 ||
(input.eids?.length ?? 0) > 0 ||
(input.countryCodes?.length ?? 0) > 0;
if (hasResourceFilters) {
const andConditions: Prisma.ResourceWhereInput[] = [];
if (input.chapters && input.chapters.length > 0) {
andConditions.push({ chapter: { in: input.chapters } });
}
if (input.eids && input.eids.length > 0) {
andConditions.push({ eid: { in: input.eids } });
}
if (input.countryCodes && input.countryCodes.length > 0) {
andConditions.push({ country: { code: { in: input.countryCodes } } });
}
const matchingResources = await db.resource.findMany({
where: andConditions.length === 1 ? andConditions[0]! : { AND: andConditions },
select: { id: true },
});
for (const resource of matchingResources) {
if (!resourceIds.includes(resource.id)) {
resourceIds.push(resource.id);
}
}
}
if (resourceIds.length === 0) {
return [];
}
const resources = await db.resource.findMany({
where: { id: { in: resourceIds } },
select: {
id: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
});
const overlays = await Promise.all(
resources.map(async (resource) => {
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
periodStart: input.startDate,
periodEnd: input.endDate,
countryId: resource.countryId,
countryCode: resource.country?.code ?? null,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name ?? null,
});
return holidays.map((holiday) => {
const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`);
return {
id: `calendar-holiday:${resource.id}:${holiday.date}`,
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
status: "APPROVED" as const,
startDate: holidayDate,
endDate: holidayDate,
note: holiday.name,
scope: holiday.scope,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
countryCode: resource.country?.code ?? null,
countryName: resource.country?.name ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCity?.name ?? null,
};
});
}),
);
return overlays.flat().sort((left, right) => {
if (left.resourceId !== right.resourceId) {
return left.resourceId.localeCompare(right.resourceId);
}
return left.startDate.getTime() - right.startDate.getTime();
});
}
export const timelineHolidayReadProcedures = {
getHolidayOverlays: controllerProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)),
getMyHolidayOverlays: protectedProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input);
if (!selfServiceInput) {
return [];
}
return loadTimelineHolidayOverlays(ctx.db, selfServiceInput);
}),
getHolidayOverlayDetail: controllerProcedure
.input(
z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
durationDays: z.number().int().min(1).max(366).optional(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { startDate, endDate } = createTimelineDateRange(input);
const filters = createTimelineFilters(input);
const holidayOverlays = await loadTimelineHolidayOverlays(ctx.db, {
...filters,
startDate,
endDate,
});
const formattedOverlays = formatHolidayOverlays(holidayOverlays);
return {
period: {
startDate: fmtDate(startDate),
endDate: fmtDate(endDate),
},
filters,
summary: summarizeHolidayOverlays(formattedOverlays),
overlays: formattedOverlays,
};
}),
};
@@ -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,
};
}),
};
@@ -0,0 +1,308 @@
import { buildSplitAllocationReadModel } from "@capakraken/application";
import { Prisma } from "@capakraken/db";
import type { PrismaClient } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
import type { TRPCContext } from "../trpc.js";
import {
PROJECT_PLANNING_DEMAND_INCLUDE,
TIMELINE_ASSIGNMENT_INCLUDE,
} from "./project-planning-read-model.js";
export type ShiftDbClient = Pick<
PrismaClient,
"project" | "demandRequirement" | "assignment"
>;
export type TimelineEntriesDbClient = Pick<
PrismaClient,
"demandRequirement" | "assignment" | "resource" | "project" | "holidayCalendar" | "country" | "metroCity"
>;
export type TimelineEntriesFilters = {
startDate: Date;
endDate: Date;
resourceIds?: string[] | undefined;
projectIds?: string[] | undefined;
clientIds?: string[] | undefined;
chapters?: string[] | undefined;
eids?: string[] | undefined;
countryCodes?: string[] | undefined;
};
export const TimelineWindowFiltersSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
});
type TimelineWindowFiltersInput = z.infer<typeof TimelineWindowFiltersSchema>;
type TimelineSelfServiceContext = Pick<TRPCContext, "db" | "dbUser">;
export function getAssignmentResourceIds(
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
): string[] {
return [
...new Set(
readModel.assignments
.map((assignment) => assignment.resourceId)
.filter((resourceId): resourceId is string => resourceId !== null),
),
];
}
export function fmtDate(value: Date | null | undefined): string | null {
if (!value) {
return null;
}
return value.toISOString().slice(0, 10);
}
function createUtcDate(year: number, month: number, day: number): Date {
return new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
}
export function createTimelineDateRange(input: {
startDate?: string | undefined;
endDate?: string | undefined;
durationDays?: number | undefined;
}): { startDate: Date; endDate: Date } {
const now = new Date();
const startDate = input.startDate
? new Date(`${input.startDate}T00:00:00.000Z`)
: createUtcDate(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
if (Number.isNaN(startDate.getTime())) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid startDate: ${input.startDate}`,
});
}
const endDate = input.endDate
? new Date(`${input.endDate}T00:00:00.000Z`)
: createUtcDate(
startDate.getUTCFullYear(),
startDate.getUTCMonth(),
startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0),
);
if (Number.isNaN(endDate.getTime())) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid endDate: ${input.endDate}`,
});
}
if (endDate < startDate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "endDate must be on or after startDate.",
});
}
return { startDate, endDate };
}
function normalizeStringList(values?: string[] | undefined): string[] | undefined {
const normalized = values
?.map((value) => value.trim())
.filter((value) => value.length > 0);
return normalized && normalized.length > 0 ? normalized : undefined;
}
export function createTimelineFilters(input: {
resourceIds?: string[] | undefined;
projectIds?: string[] | undefined;
clientIds?: string[] | undefined;
chapters?: string[] | undefined;
eids?: string[] | undefined;
countryCodes?: string[] | undefined;
}): Omit<TimelineEntriesFilters, "startDate" | "endDate"> {
return {
resourceIds: normalizeStringList(input.resourceIds),
projectIds: normalizeStringList(input.projectIds),
clientIds: normalizeStringList(input.clientIds),
chapters: normalizeStringList(input.chapters),
eids: normalizeStringList(input.eids),
countryCodes: normalizeStringList(input.countryCodes),
};
}
export function createEmptyTimelineEntriesView() {
return buildSplitAllocationReadModel({
demandRequirements: [],
assignments: [],
});
}
async function findOwnedTimelineResourceId(
ctx: TimelineSelfServiceContext,
): 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 buildSelfServiceTimelineInput(
ctx: TimelineSelfServiceContext,
input: TimelineWindowFiltersInput,
): Promise<TimelineEntriesFilters | null> {
const ownedResourceId = await findOwnedTimelineResourceId(ctx);
if (!ownedResourceId) {
return null;
}
return {
startDate: input.startDate,
endDate: input.endDate,
resourceIds: [ownedResourceId],
projectIds: normalizeStringList(input.projectIds),
clientIds: normalizeStringList(input.clientIds),
};
}
export function summarizeTimelineEntries(readModel: {
allocations: Array<{ projectId: string | null; resourceId: string | null }>;
demands: Array<{ projectId: string | null }>;
assignments: Array<{ projectId: string | null; resourceId: string | null }>;
}) {
const projectIds = new Set<string>();
const resourceIds = new Set<string>();
for (const entry of [...readModel.allocations, ...readModel.demands, ...readModel.assignments]) {
if (entry.projectId) {
projectIds.add(entry.projectId);
}
}
for (const assignment of [...readModel.allocations, ...readModel.assignments]) {
if (assignment.resourceId) {
resourceIds.add(assignment.resourceId);
}
}
return {
allocationCount: readModel.allocations.length,
demandCount: readModel.demands.length,
assignmentCount: readModel.assignments.length,
projectCount: projectIds.size,
resourceCount: resourceIds.size,
};
}
export function rangesOverlap(
leftStart: Date,
leftEnd: Date,
rightStart: Date,
rightEnd: Date,
): boolean {
return leftStart <= rightEnd && rightStart <= leftEnd;
}
export function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
export function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }>(
entry: T,
directory: Awaited<ReturnType<typeof getAnonymizationDirectory>>,
): T {
if (!entry.resource) {
return entry;
}
return {
...entry,
resource: anonymizeResource(entry.resource, directory),
};
}
export async function loadTimelineEntriesReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
) {
const { startDate, endDate, resourceIds, projectIds, clientIds, chapters, eids, countryCodes } = input;
const effectiveResourceIds = await (async () => {
if (resourceIds && resourceIds.length > 0) return resourceIds;
const hasChapters = chapters && chapters.length > 0;
const hasEids = eids && eids.length > 0;
const hasCountry = countryCodes && countryCodes.length > 0;
if (!hasChapters && !hasEids && !hasCountry) return undefined;
const andConditions: Record<string, unknown>[] = [];
if (hasChapters) andConditions.push({ chapter: { in: chapters } });
if (hasEids) andConditions.push({ eid: { in: eids } });
if (hasCountry) andConditions.push({ country: { code: { in: countryCodes } } });
const matching = await db.resource.findMany({
where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as Prisma.ResourceWhereInput,
select: { id: true },
});
return matching.map((resource) => resource.id);
})();
const effectiveProjectIds = await (async () => {
if (!clientIds || clientIds.length === 0) return projectIds;
const matchingProjects = await db.project.findMany({
where: { clientId: { in: clientIds } },
select: { id: true },
});
const clientProjectIds = matchingProjects.map((project) => project.id);
if (!projectIds || projectIds.length === 0) {
return clientProjectIds;
}
const allowedIds = new Set(clientProjectIds);
return projectIds.filter((projectId) => allowedIds.has(projectId));
})();
const excludeDemands = effectiveResourceIds !== undefined;
const [demandRequirements, assignments] = await Promise.all([
excludeDemands
? Promise.resolve([])
: db.demandRequirement.findMany({
where: {
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}),
},
include: PROJECT_PLANNING_DEMAND_INCLUDE,
orderBy: [{ startDate: "asc" }, { projectId: "asc" }],
}),
db.assignment.findMany({
where: {
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
...(effectiveResourceIds ? { resourceId: { in: effectiveResourceIds } } : {}),
...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}),
},
include: TIMELINE_ASSIGNMENT_INCLUDE,
orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
}),
]);
return buildSplitAllocationReadModel({ demandRequirements, assignments });
}
+6 -982
View File
@@ -1,985 +1,9 @@
import {
buildSplitAllocationReadModel,
listAssignmentBookings,
} from "@capakraken/application";
import { Prisma, VacationType } from "@capakraken/db";
import type { PrismaClient } from "@capakraken/db";
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 { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import type { TRPCContext } from "../trpc.js";
import { controllerProcedure, protectedProcedure } from "../trpc.js";
import {
loadProjectPlanningReadModel,
PROJECT_PLANNING_DEMAND_INCLUDE,
TIMELINE_ASSIGNMENT_INCLUDE,
} from "./project-planning-read-model.js";
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
type ShiftDbClient = Pick<
PrismaClient,
"project" | "demandRequirement" | "assignment"
>;
export type TimelineEntriesDbClient = Pick<
PrismaClient,
"demandRequirement" | "assignment" | "resource" | "project" | "holidayCalendar" | "country" | "metroCity"
>;
export type TimelineEntriesFilters = {
startDate: Date;
endDate: Date;
resourceIds?: string[] | undefined;
projectIds?: string[] | undefined;
clientIds?: string[] | undefined;
chapters?: string[] | undefined;
eids?: string[] | undefined;
countryCodes?: string[] | undefined;
};
export const TimelineWindowFiltersSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
});
type TimelineWindowFiltersInput = z.infer<typeof TimelineWindowFiltersSchema>;
type TimelineSelfServiceContext = Pick<TRPCContext, "db" | "dbUser">;
export function getAssignmentResourceIds(
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
): string[] {
return [
...new Set(
readModel.assignments
.map((assignment) => assignment.resourceId)
.filter((resourceId): resourceId is string => resourceId !== null),
),
];
}
function fmtDate(value: Date | null | undefined): string | null {
if (!value) {
return null;
}
return value.toISOString().slice(0, 10);
}
function createUtcDate(year: number, month: number, day: number): Date {
return new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
}
function createTimelineDateRange(input: {
startDate?: string | undefined;
endDate?: string | undefined;
durationDays?: number | undefined;
}): { startDate: Date; endDate: Date } {
const now = new Date();
const startDate = input.startDate
? new Date(`${input.startDate}T00:00:00.000Z`)
: createUtcDate(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
if (Number.isNaN(startDate.getTime())) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid startDate: ${input.startDate}`,
});
}
const endDate = input.endDate
? new Date(`${input.endDate}T00:00:00.000Z`)
: createUtcDate(
startDate.getUTCFullYear(),
startDate.getUTCMonth(),
startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0),
);
if (Number.isNaN(endDate.getTime())) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid endDate: ${input.endDate}`,
});
}
if (endDate < startDate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "endDate must be on or after startDate.",
});
}
return { startDate, endDate };
}
function normalizeStringList(values?: string[] | undefined): string[] | undefined {
const normalized = values
?.map((value) => value.trim())
.filter((value) => value.length > 0);
return normalized && normalized.length > 0 ? normalized : undefined;
}
function createTimelineFilters(input: {
resourceIds?: string[] | undefined;
projectIds?: string[] | undefined;
clientIds?: string[] | undefined;
chapters?: string[] | undefined;
eids?: string[] | undefined;
countryCodes?: string[] | undefined;
}): Omit<TimelineEntriesFilters, "startDate" | "endDate"> {
return {
resourceIds: normalizeStringList(input.resourceIds),
projectIds: normalizeStringList(input.projectIds),
clientIds: normalizeStringList(input.clientIds),
chapters: normalizeStringList(input.chapters),
eids: normalizeStringList(input.eids),
countryCodes: normalizeStringList(input.countryCodes),
};
}
function createEmptyTimelineEntriesView() {
return buildSplitAllocationReadModel({
demandRequirements: [],
assignments: [],
});
}
async function findOwnedTimelineResourceId(
ctx: TimelineSelfServiceContext,
): 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 buildSelfServiceTimelineInput(
ctx: TimelineSelfServiceContext,
input: TimelineWindowFiltersInput,
): Promise<TimelineEntriesFilters | null> {
const ownedResourceId = await findOwnedTimelineResourceId(ctx);
if (!ownedResourceId) {
return null;
}
return {
startDate: input.startDate,
endDate: input.endDate,
resourceIds: [ownedResourceId],
projectIds: normalizeStringList(input.projectIds),
clientIds: normalizeStringList(input.clientIds),
};
}
function summarizeTimelineEntries(readModel: {
allocations: Array<{ projectId: string | null; resourceId: string | null }>;
demands: Array<{ projectId: string | null }>;
assignments: Array<{ projectId: string | null; resourceId: string | null }>;
}) {
const projectIds = new Set<string>();
const resourceIds = new Set<string>();
for (const entry of [...readModel.allocations, ...readModel.demands, ...readModel.assignments]) {
if (entry.projectId) {
projectIds.add(entry.projectId);
}
}
for (const assignment of [...readModel.allocations, ...readModel.assignments]) {
if (assignment.resourceId) {
resourceIds.add(assignment.resourceId);
}
}
return {
allocationCount: readModel.allocations.length,
demandCount: readModel.demands.length,
assignmentCount: readModel.assignments.length,
projectCount: projectIds.size,
resourceCount: resourceIds.size,
};
}
function formatHolidayOverlays(
overlays: Array<{
id: string;
resourceId: string;
startDate: Date;
endDate: Date;
note?: string | null;
scope?: string | null;
calendarName?: string | null;
sourceType?: string | null;
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
}>,
) {
return overlays.map((overlay) => ({
id: overlay.id,
resourceId: overlay.resourceId,
startDate: fmtDate(overlay.startDate),
endDate: fmtDate(overlay.endDate),
note: overlay.note ?? null,
scope: overlay.scope ?? null,
calendarName: overlay.calendarName ?? null,
sourceType: overlay.sourceType ?? null,
countryCode: overlay.countryCode ?? null,
countryName: overlay.countryName ?? null,
federalState: overlay.federalState ?? null,
metroCityName: overlay.metroCityName ?? null,
}));
}
function summarizeHolidayOverlays(
overlays: ReturnType<typeof formatHolidayOverlays>,
) {
const resourceIds = new Set<string>();
const byScope = new Map<string, number>();
for (const overlay of overlays) {
resourceIds.add(overlay.resourceId);
const scope = overlay.scope ?? "UNKNOWN";
byScope.set(scope, (byScope.get(scope) ?? 0) + 1);
}
return {
overlayCount: overlays.length,
holidayResourceCount: resourceIds.size,
byScope: [...byScope.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([scope, count]) => ({ scope, count })),
};
}
function rangesOverlap(
leftStart: Date,
leftEnd: Date,
rightStart: Date,
rightEnd: Date,
): boolean {
return leftStart <= rightEnd && rightStart <= leftEnd;
}
function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
export async function loadTimelineEntriesReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
) {
const { startDate, endDate, resourceIds, projectIds, clientIds, chapters, eids, countryCodes } = input;
const effectiveResourceIds = await (async () => {
if (resourceIds && resourceIds.length > 0) return resourceIds;
const hasChapters = chapters && chapters.length > 0;
const hasEids = eids && eids.length > 0;
const hasCountry = countryCodes && countryCodes.length > 0;
if (!hasChapters && !hasEids && !hasCountry) return undefined;
const andConditions: Record<string, unknown>[] = [];
if (hasChapters) andConditions.push({ chapter: { in: chapters } });
if (hasEids) andConditions.push({ eid: { in: eids } });
if (hasCountry) andConditions.push({ country: { code: { in: countryCodes } } });
const matching = await db.resource.findMany({
where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as Prisma.ResourceWhereInput,
select: { id: true },
});
return matching.map((resource) => resource.id);
})();
const effectiveProjectIds = await (async () => {
if (!clientIds || clientIds.length === 0) return projectIds;
const matchingProjects = await db.project.findMany({
where: { clientId: { in: clientIds } },
select: { id: true },
});
const clientProjectIds = matchingProjects.map((project) => project.id);
if (!projectIds || projectIds.length === 0) {
return clientProjectIds;
}
const allowedIds = new Set(clientProjectIds);
return projectIds.filter((projectId) => allowedIds.has(projectId));
})();
const excludeDemands = effectiveResourceIds !== undefined;
const [demandRequirements, assignments] = await Promise.all([
excludeDemands
? Promise.resolve([])
: db.demandRequirement.findMany({
where: {
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}),
},
include: PROJECT_PLANNING_DEMAND_INCLUDE,
orderBy: [{ startDate: "asc" }, { projectId: "asc" }],
}),
db.assignment.findMany({
where: {
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
...(effectiveResourceIds ? { resourceId: { in: effectiveResourceIds } } : {}),
...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}),
},
include: TIMELINE_ASSIGNMENT_INCLUDE,
orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
}),
]);
return buildSplitAllocationReadModel({ demandRequirements, assignments });
}
export async function loadTimelineHolidayOverlays(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
) {
const readModel = await loadTimelineEntriesReadModel(db, input);
return loadTimelineHolidayOverlaysForReadModel(db, input, readModel);
}
async function loadTimelineHolidayOverlaysForReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
) {
const resourceIds = [...new Set(
readModel.assignments
.map((assignment) => assignment.resourceId)
.filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0),
)];
if (input.resourceIds && input.resourceIds.length > 0) {
for (const resourceId of input.resourceIds) {
if (resourceId && !resourceIds.includes(resourceId)) {
resourceIds.push(resourceId);
}
}
}
const hasResourceFilters =
(input.chapters?.length ?? 0) > 0 ||
(input.eids?.length ?? 0) > 0 ||
(input.countryCodes?.length ?? 0) > 0;
if (hasResourceFilters) {
const andConditions: Prisma.ResourceWhereInput[] = [];
if (input.chapters && input.chapters.length > 0) {
andConditions.push({ chapter: { in: input.chapters } });
}
if (input.eids && input.eids.length > 0) {
andConditions.push({ eid: { in: input.eids } });
}
if (input.countryCodes && input.countryCodes.length > 0) {
andConditions.push({ country: { code: { in: input.countryCodes } } });
}
const matchingResources = await db.resource.findMany({
where: andConditions.length === 1 ? andConditions[0]! : { AND: andConditions },
select: { id: true },
});
for (const resource of matchingResources) {
if (!resourceIds.includes(resource.id)) {
resourceIds.push(resource.id);
}
}
}
if (resourceIds.length === 0) {
return [];
}
const resources = await db.resource.findMany({
where: { id: { in: resourceIds } },
select: {
id: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
});
const overlays = await Promise.all(
resources.map(async (resource) => {
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
periodStart: input.startDate,
periodEnd: input.endDate,
countryId: resource.countryId,
countryCode: resource.country?.code ?? null,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name ?? null,
});
return holidays.map((holiday) => {
const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`);
return {
id: `calendar-holiday:${resource.id}:${holiday.date}`,
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
status: "APPROVED" as const,
startDate: holidayDate,
endDate: holidayDate,
note: holiday.name,
scope: holiday.scope,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
countryCode: resource.country?.code ?? null,
countryName: resource.country?.name ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCity?.name ?? null,
};
});
}),
);
return overlays.flat().sort((left, right) => {
if (left.resourceId !== right.resourceId) {
return left.resourceId.localeCompare(right.resourceId);
}
return left.startDate.getTime() - right.startDate.getTime();
});
}
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,
});
}
function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }>(
entry: T,
directory: Awaited<ReturnType<typeof getAnonymizationDirectory>>,
): T {
if (!entry.resource) {
return entry;
}
return {
...entry,
resource: anonymizeResource(entry.resource, directory),
};
}
import { timelineEntryReadProcedures } from "./timeline-entry-read.js";
import { timelineHolidayReadProcedures } from "./timeline-holiday-read.js";
import { timelineProjectReadProcedures } from "./timeline-project-read.js";
export const timelineReadProcedures = {
getEntries: controllerProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
const directory = await getAnonymizationDirectory(ctx.db);
return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory));
}),
getEntriesView: controllerProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const [readModel, directory] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, input),
getAnonymizationDirectory(ctx.db),
]);
return {
...readModel,
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
};
}),
getMyEntriesView: protectedProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input);
if (!selfServiceInput) {
return createEmptyTimelineEntriesView();
}
const [readModel, directory] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, selfServiceInput),
getAnonymizationDirectory(ctx.db),
]);
return {
...readModel,
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
};
}),
getHolidayOverlays: controllerProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)),
getMyHolidayOverlays: protectedProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input);
if (!selfServiceInput) {
return [];
}
return loadTimelineHolidayOverlays(ctx.db, selfServiceInput);
}),
getEntriesDetail: controllerProcedure
.input(
z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
durationDays: z.number().int().min(1).max(366).optional(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { startDate, endDate } = createTimelineDateRange(input);
const filters = createTimelineFilters(input);
const timelineInput = { ...filters, startDate, endDate };
const [readModel, directory] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, timelineInput),
getAnonymizationDirectory(ctx.db),
]);
const holidayOverlays = await loadTimelineHolidayOverlaysForReadModel(
ctx.db,
timelineInput,
readModel,
);
const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);
return {
period: {
startDate: fmtDate(startDate),
endDate: fmtDate(endDate),
},
filters,
summary: {
...summarizeTimelineEntries(readModel),
...summarizeHolidayOverlays(formattedHolidayOverlays),
},
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
demands: readModel.demands,
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
holidayOverlays: formattedHolidayOverlays,
};
}),
getHolidayOverlayDetail: controllerProcedure
.input(
z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
durationDays: z.number().int().min(1).max(366).optional(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { startDate, endDate } = createTimelineDateRange(input);
const filters = createTimelineFilters(input);
const holidayOverlays = await loadTimelineHolidayOverlays(ctx.db, {
...filters,
startDate,
endDate,
});
const formattedOverlays = formatHolidayOverlays(holidayOverlays);
return {
period: {
startDate: fmtDate(startDate),
endDate: fmtDate(endDate),
},
filters,
summary: summarizeHolidayOverlays(formattedOverlays),
overlays: formattedOverlays,
};
}),
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,
};
}),
...timelineEntryReadProcedures,
...timelineHolidayReadProcedures,
...timelineProjectReadProcedures,
};
+2 -1
View File
@@ -6,7 +6,8 @@ import { TRPCError } from "@trpc/server";
import { emitProjectShifted } from "../sse/event-bus.js";
import { createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js";
import { buildAbsenceDays, loadCalculationRules, timelineAllocationMutationProcedures } from "./timeline-allocation-mutations.js";
import { loadProjectShiftContext, timelineReadProcedures } from "./timeline-read.js";
import { timelineReadProcedures } from "./timeline-read.js";
import { loadProjectShiftContext } from "./timeline-project-read.js";
export const timelineRouter = createTRPCRouter({
...timelineReadProcedures,