refactor(api): extract project procedures

This commit is contained in:
2026-03-31 21:28:56 +02:00
parent b1799e4f54
commit e34c22f3b0
4 changed files with 334 additions and 93 deletions
@@ -0,0 +1,204 @@
import { FieldType, ProjectStatus } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
countPlanningEntries,
loadProjectPlanningReadModel,
getProjectShoringRatio,
} = vi.hoisted(() => ({
countPlanningEntries: vi.fn(),
loadProjectPlanningReadModel: vi.fn(),
getProjectShoringRatio: vi.fn(),
}));
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
countPlanningEntries,
};
});
vi.mock("../router/project-planning-read-model.js", () => ({
loadProjectPlanningReadModel,
}));
vi.mock("../router/project-shoring-ratio.js", () => ({
getProjectShoringRatio,
}));
import {
getProjectById,
getProjectShoringRatioData,
listProjects,
} from "../router/project-procedure-support.js";
function createContext(db: Record<string, unknown>) {
return { db: db as never };
}
describe("project-procedure-support", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("lists projects with planning counts and dynamic field filters", async () => {
const findMany = vi.fn().mockResolvedValue([
{
id: "project_1",
name: "Platform Refresh",
shortCode: "PRJ-1",
status: ProjectStatus.ACTIVE,
startDate: new Date("2026-01-01T00:00:00.000Z"),
},
]);
const count = vi.fn().mockResolvedValue(1);
countPlanningEntries.mockResolvedValue({
countsByProjectId: new Map([["project_1", 3]]),
});
const result = await listProjects(
createContext({
project: {
findMany,
count,
},
}),
{
limit: 50,
page: 1,
status: ProjectStatus.ACTIVE,
search: "Platform",
customFieldFilters: [
{ key: "market", value: "de", type: FieldType.TEXT },
],
},
);
expect(result.total).toBe(1);
expect(result.projects).toEqual([
expect.objectContaining({
id: "project_1",
_count: { allocations: 3 },
}),
]);
expect(findMany).toHaveBeenCalledWith({
where: {
status: ProjectStatus.ACTIVE,
OR: [
{ name: { contains: "Platform", mode: "insensitive" } },
{ shortCode: { contains: "Platform", mode: "insensitive" } },
],
AND: [
{
dynamicFields: {
path: ["market"],
string_contains: "de",
},
},
],
},
skip: 0,
take: 51,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
});
expect(count).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
status: ProjectStatus.ACTIVE,
}),
}));
expect(countPlanningEntries).toHaveBeenCalledWith(
expect.objectContaining({
project: expect.objectContaining({
findMany,
count,
}),
}),
{ projectIds: ["project_1"] },
);
});
it("returns a project detail enriched with planning read model data", async () => {
const findUnique = vi.fn().mockResolvedValue({
id: "project_1",
name: "Platform Refresh",
blueprint: null,
});
loadProjectPlanningReadModel.mockResolvedValue({
readModel: {
assignments: [{ id: "assignment_1" }],
demands: [{ id: "demand_1" }],
},
});
const result = await getProjectById(
createContext({
project: { findUnique },
}),
{ id: "project_1" },
);
expect(result).toEqual({
id: "project_1",
name: "Platform Refresh",
blueprint: null,
allocations: [{ id: "assignment_1" }],
demands: [{ id: "demand_1" }],
assignments: [{ id: "assignment_1" }],
});
expect(findUnique).toHaveBeenCalledWith({
where: { id: "project_1" },
include: { blueprint: true },
});
expect(loadProjectPlanningReadModel).toHaveBeenCalledWith(
expect.objectContaining({
project: expect.objectContaining({ findUnique }),
}),
{ projectId: "project_1" },
);
});
it("throws not found when the requested project is missing", async () => {
loadProjectPlanningReadModel.mockResolvedValue({
readModel: { assignments: [], demands: [] },
});
await expect(
getProjectById(
createContext({
project: { findUnique: vi.fn().mockResolvedValue(null) },
}),
{ id: "missing" },
),
).rejects.toThrowError(new TRPCError({
code: "NOT_FOUND",
message: "Project not found",
}));
});
it("delegates shoring ratio reads to the dedicated shoring helper", async () => {
getProjectShoringRatio.mockResolvedValue({
totalHours: 40,
onshoreRatio: 60,
offshoreRatio: 40,
});
const result = await getProjectShoringRatioData(
createContext({
project: { findUnique: vi.fn() },
}),
{ projectId: "project_1" },
);
expect(result).toEqual({
totalHours: 40,
onshoreRatio: 60,
offshoreRatio: 40,
});
expect(getProjectShoringRatio).toHaveBeenCalledWith(
expect.any(Object),
"project_1",
);
});
});
@@ -0,0 +1,115 @@
import { countPlanningEntries } from "@capakraken/application";
import { FieldType, ProjectStatus } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { paginate, PaginationInputSchema } from "../db/pagination.js";
import type { TRPCContext } from "../trpc.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
import { getProjectShoringRatio } from "./project-shoring-ratio.js";
export const ProjectListInputSchema = PaginationInputSchema.extend({
status: z.nativeEnum(ProjectStatus).optional(),
search: z.string().optional(),
customFieldFilters: z.array(z.object({
key: z.string(),
value: z.string(),
type: z.nativeEnum(FieldType),
})).optional(),
});
export const ProjectIdInputSchema = z.object({
id: z.string(),
});
export const ProjectShoringRatioInputSchema = z.object({
projectId: z.string(),
});
type ProjectProcedureContext = Pick<TRPCContext, "db">;
export async function listProjects(
ctx: ProjectProcedureContext,
input: z.infer<typeof ProjectListInputSchema>,
) {
const { status, search, cursor, customFieldFilters } = input;
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters)
.map((dynamicFields) => ({ dynamicFields }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {
...(status ? { status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
...(cfConditions.length > 0 ? { AND: cfConditions } : {}),
};
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
const result = await paginate(
({ skip, take }) =>
ctx.db.project.findMany({
where: whereWithCursor,
skip,
take,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
}),
() => ctx.db.project.count({ where }),
input,
);
const { countsByProjectId } = await countPlanningEntries(ctx.db, {
projectIds: result.items.map((project) => project.id),
});
return {
projects: result.items.map((project) => ({
...project,
_count: {
allocations: countsByProjectId.get(project.id) ?? 0,
},
})),
total: result.total,
page: result.page,
limit: result.limit,
nextCursor: result.nextCursor,
};
}
export async function getProjectById(
ctx: ProjectProcedureContext,
input: z.infer<typeof ProjectIdInputSchema>,
) {
const [project, planningRead] = await Promise.all([
ctx.db.project.findUnique({
where: { id: input.id },
include: { blueprint: true },
}),
loadProjectPlanningReadModel(ctx.db, { projectId: input.id }),
]);
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
return {
...project,
allocations: planningRead.readModel.assignments,
demands: planningRead.readModel.demands,
assignments: planningRead.readModel.assignments,
};
}
export async function getProjectShoringRatioData(
ctx: ProjectProcedureContext,
input: z.infer<typeof ProjectShoringRatioInputSchema>,
) {
return getProjectShoringRatio(ctx.db, input.projectId);
}
+14 -93
View File
@@ -1,17 +1,17 @@
import { countPlanningEntries } from "@capakraken/application";
import { FieldType, ProjectStatus } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { paginate, PaginationInputSchema } from "../db/pagination.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import { projectCostReadProcedures } from "./project-cost-read.js";
import { projectCoverProcedures } from "./project-cover.js";
import { projectIdentifierReadProcedures } from "./project-identifier-read.js";
import { createProjectLifecycleProcedures } from "./project-lifecycle.js";
import { createProjectMutationProcedures } from "./project-mutations.js";
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
import { createProjectBackgroundEffects } from "./project-background-effects.js";
import { getProjectShoringRatio } from "./project-shoring-ratio.js";
import {
getProjectById,
getProjectShoringRatioData,
listProjects,
ProjectIdInputSchema,
ProjectListInputSchema,
ProjectShoringRatioInputSchema,
} from "./project-procedure-support.js";
import { controllerProcedure, createTRPCRouter } from "../trpc.js";
const projectBackgroundEffects = createProjectBackgroundEffects();
@@ -27,94 +27,15 @@ export const projectRouter = createTRPCRouter({
...createProjectMutationProcedures(projectBackgroundEffects),
list: controllerProcedure
.input(
PaginationInputSchema.extend({
status: z.nativeEnum(ProjectStatus).optional(),
search: z.string().optional(),
// Custom field JSONB filters
customFieldFilters: z.array(z.object({
key: z.string(),
value: z.string(),
type: z.nativeEnum(FieldType),
})).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { status, search, cursor, customFieldFilters } = input;
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {
...(status ? { status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
...(cfConditions.length > 0 ? { AND: cfConditions } : {}),
};
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
const result = await paginate(
({ skip, take }) =>
ctx.db.project.findMany({
where: whereWithCursor,
skip,
take,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
}),
() => ctx.db.project.count({ where }),
input,
);
const { countsByProjectId } = await countPlanningEntries(ctx.db, {
projectIds: result.items.map((project) => project.id),
});
return {
projects: result.items.map((project) => ({
...project,
_count: {
allocations: countsByProjectId.get(project.id) ?? 0,
},
})),
total: result.total,
page: result.page,
limit: result.limit,
nextCursor: result.nextCursor,
};
}),
.input(ProjectListInputSchema)
.query(({ ctx, input }) => listProjects(ctx, input)),
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const [project, planningRead] = await Promise.all([
ctx.db.project.findUnique({
where: { id: input.id },
include: { blueprint: true },
}),
loadProjectPlanningReadModel(ctx.db, { projectId: input.id }),
]);
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
return {
...project,
allocations: planningRead.readModel.assignments,
demands: planningRead.readModel.demands,
assignments: planningRead.readModel.assignments,
};
}),
.input(ProjectIdInputSchema)
.query(({ ctx, input }) => getProjectById(ctx, input)),
getShoringRatio: controllerProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => getProjectShoringRatio(ctx.db, input.projectId)),
.input(ProjectShoringRatioInputSchema)
.query(({ ctx, input }) => getProjectShoringRatioData(ctx, input)),
});