diff --git a/docs/api-router-procedure-support-backlog.md b/docs/api-router-procedure-support-backlog.md index b795230..b6fc56c 100644 --- a/docs/api-router-procedure-support-backlog.md +++ b/docs/api-router-procedure-support-backlog.md @@ -28,6 +28,7 @@ Done - `webhook` - `role` - `computation-graph` +- `project` Ready next - none in the conflict-safe backlog diff --git a/packages/api/src/__tests__/project-procedure-support.test.ts b/packages/api/src/__tests__/project-procedure-support.test.ts new file mode 100644 index 0000000..af4cb92 --- /dev/null +++ b/packages/api/src/__tests__/project-procedure-support.test.ts @@ -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(); + 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) { + 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", + ); + }); +}); diff --git a/packages/api/src/router/project-procedure-support.ts b/packages/api/src/router/project-procedure-support.ts new file mode 100644 index 0000000..ecf059f --- /dev/null +++ b/packages/api/src/router/project-procedure-support.ts @@ -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; + +export async function listProjects( + ctx: ProjectProcedureContext, + input: z.infer, +) { + 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, +) { + 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, +) { + return getProjectShoringRatio(ctx.db, input.projectId); +} diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts index a48e057..79cc3a7 100644 --- a/packages/api/src/router/project.ts +++ b/packages/api/src/router/project.ts @@ -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)), });