refactor(api): extract computation graph detail procedures

This commit is contained in:
2026-03-31 09:24:26 +02:00
parent 5f559e613d
commit 459245fb0f
2 changed files with 174 additions and 114 deletions
@@ -0,0 +1,167 @@
import { z } from "zod";
import { controllerProcedure } from "../trpc.js";
type GraphNodeLike = {
id: string;
domain: string;
};
type GraphLinkLike = {
source: string;
target: string;
};
type ResourceGraphSnapshot = {
nodes: GraphNodeLike[];
links: GraphLinkLike[];
meta: {
resourceEid: string;
resourceName: string;
[key: string]: unknown;
};
};
type ProjectGraphSnapshot = {
nodes: GraphNodeLike[];
links: GraphLinkLike[];
meta: {
projectCode: string;
projectName: string;
[key: string]: unknown;
};
};
function filterGraphData<
TNode extends GraphNodeLike,
TLink extends GraphLinkLike,
>(input: {
nodes: TNode[];
links: TLink[];
domain?: string;
includeLinks?: boolean;
}) {
const requestedDomain = input.domain?.trim().toUpperCase();
const nodes = requestedDomain
? input.nodes.filter((node) => node.domain === requestedDomain)
: input.nodes;
const selectedNodeIds = new Set(nodes.map((node) => node.id));
const links = input.includeLinks
? input.links.filter((link) => selectedNodeIds.has(link.source) && selectedNodeIds.has(link.target))
: [];
return {
requestedDomain: requestedDomain ?? null,
includedLinks: input.includeLinks ?? false,
selectedNodeCount: nodes.length,
selectedLinkCount: links.length,
nodes,
...(input.includeLinks ? { links } : {}),
};
}
function getAvailableDomains<TNode extends { domain: string }>(nodes: TNode[]) {
return [...new Set(nodes.map((node) => node.domain))];
}
function formatResourceGraphDetail(input: {
resourceId: string;
graph: ResourceGraphSnapshot;
domain?: string;
includeLinks?: boolean;
}) {
return {
resource: {
id: input.resourceId,
eid: input.graph.meta.resourceEid,
displayName: input.graph.meta.resourceName,
},
availableDomains: getAvailableDomains(input.graph.nodes),
totalNodeCount: input.graph.nodes.length,
totalLinkCount: input.graph.links.length,
...filterGraphData({
nodes: input.graph.nodes,
links: input.graph.links,
...(input.domain ? { domain: input.domain } : {}),
...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}),
}),
meta: input.graph.meta,
};
}
function formatProjectGraphDetail(input: {
projectId: string;
graph: ProjectGraphSnapshot;
domain?: string;
includeLinks?: boolean;
}) {
return {
project: {
id: input.projectId,
shortCode: input.graph.meta.projectCode,
name: input.graph.meta.projectName,
},
availableDomains: getAvailableDomains(input.graph.nodes),
totalNodeCount: input.graph.nodes.length,
totalLinkCount: input.graph.links.length,
...filterGraphData({
nodes: input.graph.nodes,
links: input.graph.links,
...(input.domain ? { domain: input.domain } : {}),
...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}),
}),
meta: input.graph.meta,
};
}
export function createComputationGraphDetailProcedures(input: {
resourceGraphInputSchema: z.AnyZodObject;
projectGraphInputSchema: z.AnyZodObject;
readResourceGraphSnapshot: (
ctx: any,
input: any,
) => Promise<ResourceGraphSnapshot>;
readProjectGraphSnapshot: (
ctx: any,
input: any,
) => Promise<ProjectGraphSnapshot>;
}) {
const resourceGraphDetailInputSchema = input.resourceGraphInputSchema.extend({
domain: z.string().trim().min(1).optional(),
includeLinks: z.boolean().optional(),
});
const projectGraphDetailInputSchema = input.projectGraphInputSchema.extend({
domain: z.string().trim().min(1).optional(),
includeLinks: z.boolean().optional(),
});
return {
getResourceDataDetail: controllerProcedure
.input(resourceGraphDetailInputSchema)
.query(async ({ ctx, input: procedureInput }) => {
const graph = await input.readResourceGraphSnapshot(ctx, procedureInput);
return formatResourceGraphDetail({
resourceId: procedureInput.resourceId,
graph,
...(typeof procedureInput.domain === "string" ? { domain: procedureInput.domain } : {}),
...(typeof procedureInput.includeLinks === "boolean"
? { includeLinks: procedureInput.includeLinks }
: {}),
});
}),
getProjectDataDetail: controllerProcedure
.input(projectGraphDetailInputSchema)
.query(async ({ ctx, input: procedureInput }) => {
const graph = await input.readProjectGraphSnapshot(ctx, procedureInput);
return formatProjectGraphDetail({
projectId: procedureInput.projectId,
graph,
...(typeof procedureInput.domain === "string" ? { domain: procedureInput.domain } : {}),
...(typeof procedureInput.includeLinks === "boolean"
? { includeLinks: procedureInput.includeLinks }
: {}),
});
}),
};
}
+7 -114
View File
@@ -25,6 +25,7 @@ import {
countEffectiveWorkingDays, countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts, loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js"; } from "../lib/resource-capacity.js";
import { createComputationGraphDetailProcedures } from "./computation-graph-detail.js";
// ─── Graph Types (mirrored from client for API response) ──────────────────── // ─── Graph Types (mirrored from client for API response) ────────────────────
@@ -86,60 +87,26 @@ function sumAvailabilityHoursForDates(
return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0); return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0);
} }
function filterGraphData<
TNode extends { id: string; domain: string },
TLink extends { source: string; target: string },
>(input: {
nodes: TNode[];
links: TLink[];
domain?: string;
includeLinks?: boolean;
}) {
const requestedDomain = input.domain?.trim().toUpperCase();
const nodes = requestedDomain
? input.nodes.filter((node) => node.domain === requestedDomain)
: input.nodes;
const selectedNodeIds = new Set(nodes.map((node) => node.id));
const links = input.includeLinks
? input.links.filter((link) => selectedNodeIds.has(link.source) && selectedNodeIds.has(link.target))
: [];
return {
requestedDomain: requestedDomain ?? null,
includedLinks: input.includeLinks ?? false,
selectedNodeCount: nodes.length,
selectedLinkCount: links.length,
nodes,
...(input.includeLinks ? { links } : {}),
};
}
function getAvailableDomains(nodes: Array<{ domain: Domain }>): Domain[] {
return [...new Set(nodes.map((node) => node.domain))];
}
const resourceGraphInputSchema = z.object({ const resourceGraphInputSchema = z.object({
resourceId: z.string(), resourceId: z.string(),
month: z.string().regex(/^\d{4}-\d{2}$/), month: z.string().regex(/^\d{4}-\d{2}$/),
}); });
const resourceGraphDetailInputSchema = resourceGraphInputSchema.extend({
domain: z.string().trim().min(1).optional(),
includeLinks: z.boolean().optional(),
});
const projectGraphInputSchema = z.object({ const projectGraphInputSchema = z.object({
projectId: z.string(), projectId: z.string(),
}); });
const projectGraphDetailInputSchema = projectGraphInputSchema.extend({ const computationGraphDetailProcedures = createComputationGraphDetailProcedures({
domain: z.string().trim().min(1).optional(), resourceGraphInputSchema,
includeLinks: z.boolean().optional(), projectGraphInputSchema,
readResourceGraphSnapshot,
readProjectGraphSnapshot,
}); });
// ─── Router ───────────────────────────────────────────────────────────────── // ─── Router ─────────────────────────────────────────────────────────────────
export const computationGraphRouter = createTRPCRouter({ export const computationGraphRouter = createTRPCRouter({
...computationGraphDetailProcedures,
/** /**
* Resource View: SAH, Allocation, Rules, Chargeability, Budget * Resource View: SAH, Allocation, Rules, Chargeability, Budget
* for a single resource in a single month. * for a single resource in a single month.
@@ -148,36 +115,12 @@ export const computationGraphRouter = createTRPCRouter({
.input(resourceGraphInputSchema) .input(resourceGraphInputSchema)
.query(({ ctx, input }) => readResourceGraphSnapshot(ctx, input)), .query(({ ctx, input }) => readResourceGraphSnapshot(ctx, input)),
getResourceDataDetail: controllerProcedure
.input(resourceGraphDetailInputSchema)
.query(async ({ ctx, input }) => {
const graph = await readResourceGraphSnapshot(ctx, input);
return formatResourceGraphDetail({
resourceId: input.resourceId,
graph,
...(input.domain ? { domain: input.domain } : {}),
...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}),
});
}),
/** /**
* Project View: Estimate, Commercial, Experience, Effort, Spread, Budget * Project View: Estimate, Commercial, Experience, Effort, Spread, Budget
*/ */
getProjectData: controllerProcedure getProjectData: controllerProcedure
.input(projectGraphInputSchema) .input(projectGraphInputSchema)
.query(({ ctx, input }) => readProjectGraphSnapshot(ctx, input)), .query(({ ctx, input }) => readProjectGraphSnapshot(ctx, input)),
getProjectDataDetail: controllerProcedure
.input(projectGraphDetailInputSchema)
.query(async ({ ctx, input }) => {
const graph = await readProjectGraphSnapshot(ctx, input);
return formatProjectGraphDetail({
projectId: input.projectId,
graph,
...(input.domain ? { domain: input.domain } : {}),
...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}),
});
}),
}); });
async function readResourceGraphSnapshot( async function readResourceGraphSnapshot(
@@ -1118,53 +1061,3 @@ async function readProjectGraphSnapshot(
}, },
}; };
} }
function formatResourceGraphDetail(input: {
resourceId: string;
graph: Awaited<ReturnType<typeof readResourceGraphSnapshot>>;
domain?: string;
includeLinks?: boolean;
}) {
return {
resource: {
id: input.resourceId,
eid: input.graph.meta.resourceEid,
displayName: input.graph.meta.resourceName,
},
availableDomains: getAvailableDomains(input.graph.nodes),
totalNodeCount: input.graph.nodes.length,
totalLinkCount: input.graph.links.length,
...filterGraphData({
nodes: input.graph.nodes,
links: input.graph.links,
...(input.domain ? { domain: input.domain } : {}),
...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}),
}),
meta: input.graph.meta,
};
}
function formatProjectGraphDetail(input: {
projectId: string;
graph: Awaited<ReturnType<typeof readProjectGraphSnapshot>>;
domain?: string;
includeLinks?: boolean;
}) {
return {
project: {
id: input.projectId,
shortCode: input.graph.meta.projectCode,
name: input.graph.meta.projectName,
},
availableDomains: getAvailableDomains(input.graph.nodes),
totalNodeCount: input.graph.nodes.length,
totalLinkCount: input.graph.links.length,
...filterGraphData({
nodes: input.graph.nodes,
links: input.graph.links,
...(input.domain ? { domain: input.domain } : {}),
...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}),
}),
meta: input.graph.meta,
};
}