refactor(api): extract timeline entry query support
This commit is contained in:
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
loadTimelineEntryRecords,
|
||||||
|
resolveTimelineEntryProjectIds,
|
||||||
|
resolveTimelineEntryResourceIds,
|
||||||
|
} from "../router/timeline-entry-query-support.js";
|
||||||
|
|
||||||
|
describe("timeline entry query support", () => {
|
||||||
|
it("resolves resource ids from direct filters before chapter and geography filters", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveTimelineEntryResourceIds(db as never, {
|
||||||
|
resourceIds: ["resource_1"],
|
||||||
|
chapters: ["Comp"],
|
||||||
|
eids: ["E-001"],
|
||||||
|
countryCodes: ["DE"],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(["resource_1"]);
|
||||||
|
expect(db.resource.findMany).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
db.resource.findMany.mockResolvedValueOnce([{ id: "resource_2" }, { id: "resource_3" }]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveTimelineEntryResourceIds(db as never, {
|
||||||
|
chapters: ["Comp"],
|
||||||
|
eids: ["E-001"],
|
||||||
|
countryCodes: ["DE"],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(["resource_2", "resource_3"]);
|
||||||
|
expect(db.resource.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{ chapter: { in: ["Comp"] } },
|
||||||
|
{ eid: { in: ["E-001"] } },
|
||||||
|
{ country: { code: { in: ["DE"] } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves project ids from client filters and intersects explicit project filters", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ id: "project_1" }, { id: "project_2" }]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveTimelineEntryProjectIds(db as never, {
|
||||||
|
clientIds: ["client_1"],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(["project_1", "project_2"]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveTimelineEntryProjectIds(db as never, {
|
||||||
|
clientIds: ["client_1"],
|
||||||
|
projectIds: ["project_2", "project_3"],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(["project_2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads assignments and skips demands when resource filters are active", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ id: "resource_1" }]),
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ id: "project_1" }]),
|
||||||
|
},
|
||||||
|
demandRequirement: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ id: "assignment_1" }]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loadTimelineEntryRecords(db as never, {
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
chapters: ["Comp"],
|
||||||
|
clientIds: ["client_1"],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
demandRequirements: [],
|
||||||
|
assignments: [{ id: "assignment_1" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.demandRequirement.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(db.assignment.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
resourceId: { in: ["resource_1"] },
|
||||||
|
projectId: { in: ["project_1"] },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Prisma } from "@capakraken/db";
|
||||||
|
import {
|
||||||
|
PROJECT_PLANNING_DEMAND_INCLUDE,
|
||||||
|
TIMELINE_ASSIGNMENT_INCLUDE,
|
||||||
|
} from "./project-planning-read-model.js";
|
||||||
|
import type { TimelineEntriesDbClient, TimelineEntriesFilters } from "./timeline-read-shared.js";
|
||||||
|
|
||||||
|
export async function resolveTimelineEntryResourceIds(
|
||||||
|
db: TimelineEntriesDbClient,
|
||||||
|
input: Pick<TimelineEntriesFilters, "resourceIds" | "chapters" | "eids" | "countryCodes">,
|
||||||
|
): Promise<string[] | undefined> {
|
||||||
|
if (input.resourceIds && input.resourceIds.length > 0) {
|
||||||
|
return input.resourceIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChapters = !!input.chapters?.length;
|
||||||
|
const hasEids = !!input.eids?.length;
|
||||||
|
const hasCountry = !!input.countryCodes?.length;
|
||||||
|
if (!hasChapters && !hasEids && !hasCountry) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const andConditions: Record<string, unknown>[] = [];
|
||||||
|
if (hasChapters) {
|
||||||
|
andConditions.push({ chapter: { in: input.chapters } });
|
||||||
|
}
|
||||||
|
if (hasEids) {
|
||||||
|
andConditions.push({ eid: { in: input.eids } });
|
||||||
|
}
|
||||||
|
if (hasCountry) {
|
||||||
|
andConditions.push({ country: { code: { in: input.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveTimelineEntryProjectIds(
|
||||||
|
db: TimelineEntriesDbClient,
|
||||||
|
input: Pick<TimelineEntriesFilters, "projectIds" | "clientIds">,
|
||||||
|
): Promise<string[] | undefined> {
|
||||||
|
if (!input.clientIds?.length) {
|
||||||
|
return input.projectIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingProjects = await db.project.findMany({
|
||||||
|
where: { clientId: { in: input.clientIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const clientProjectIds = matchingProjects.map((project) => project.id);
|
||||||
|
|
||||||
|
if (!input.projectIds?.length) {
|
||||||
|
return clientProjectIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedIds = new Set(clientProjectIds);
|
||||||
|
return input.projectIds.filter((projectId) => allowedIds.has(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTimelineEntryRecords(
|
||||||
|
db: TimelineEntriesDbClient,
|
||||||
|
input: TimelineEntriesFilters,
|
||||||
|
) {
|
||||||
|
const effectiveResourceIds = await resolveTimelineEntryResourceIds(db, input);
|
||||||
|
const effectiveProjectIds = await resolveTimelineEntryProjectIds(db, input);
|
||||||
|
const excludeDemands = effectiveResourceIds !== undefined;
|
||||||
|
|
||||||
|
const [demandRequirements, assignments] = await Promise.all([
|
||||||
|
excludeDemands
|
||||||
|
? Promise.resolve([])
|
||||||
|
: db.demandRequirement.findMany({
|
||||||
|
where: {
|
||||||
|
status: { not: "CANCELLED" },
|
||||||
|
startDate: { lte: input.endDate },
|
||||||
|
endDate: { gte: input.startDate },
|
||||||
|
...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}),
|
||||||
|
},
|
||||||
|
include: PROJECT_PLANNING_DEMAND_INCLUDE,
|
||||||
|
orderBy: [{ startDate: "asc" }, { projectId: "asc" }],
|
||||||
|
}),
|
||||||
|
db.assignment.findMany({
|
||||||
|
where: {
|
||||||
|
status: { not: "CANCELLED" },
|
||||||
|
startDate: { lte: input.endDate },
|
||||||
|
endDate: { gte: input.startDate },
|
||||||
|
...(effectiveResourceIds ? { resourceId: { in: effectiveResourceIds } } : {}),
|
||||||
|
...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}),
|
||||||
|
},
|
||||||
|
include: TIMELINE_ASSIGNMENT_INCLUDE,
|
||||||
|
orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { demandRequirements, assignments };
|
||||||
|
}
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import { buildSplitAllocationReadModel } from "@capakraken/application";
|
import { buildSplitAllocationReadModel } from "@capakraken/application";
|
||||||
import { Prisma } from "@capakraken/db";
|
|
||||||
import type { PrismaClient } from "@capakraken/db";
|
import type { PrismaClient } from "@capakraken/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||||
import type { TRPCContext } from "../trpc.js";
|
import type { TRPCContext } from "../trpc.js";
|
||||||
import {
|
import { loadTimelineEntryRecords } from "./timeline-entry-query-support.js";
|
||||||
PROJECT_PLANNING_DEMAND_INCLUDE,
|
|
||||||
TIMELINE_ASSIGNMENT_INCLUDE,
|
|
||||||
} from "./project-planning-read-model.js";
|
|
||||||
|
|
||||||
export type ShiftDbClient = Pick<
|
export type ShiftDbClient = Pick<
|
||||||
PrismaClient,
|
PrismaClient,
|
||||||
@@ -318,71 +314,6 @@ export async function loadTimelineEntriesReadModel(
|
|||||||
db: TimelineEntriesDbClient,
|
db: TimelineEntriesDbClient,
|
||||||
input: TimelineEntriesFilters,
|
input: TimelineEntriesFilters,
|
||||||
) {
|
) {
|
||||||
const { startDate, endDate, resourceIds, projectIds, clientIds, chapters, eids, countryCodes } = input;
|
const { demandRequirements, assignments } = await loadTimelineEntryRecords(db, 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 });
|
return buildSplitAllocationReadModel({ demandRequirements, assignments });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user