refactor(api): split timeline read router by concern
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user