refactor(api): extract project procedures
This commit is contained in:
@@ -28,6 +28,7 @@ Done
|
|||||||
- `webhook`
|
- `webhook`
|
||||||
- `role`
|
- `role`
|
||||||
- `computation-graph`
|
- `computation-graph`
|
||||||
|
- `project`
|
||||||
|
|
||||||
Ready next
|
Ready next
|
||||||
- none in the conflict-safe backlog
|
- none in the conflict-safe backlog
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 { projectCostReadProcedures } from "./project-cost-read.js";
|
||||||
import { projectCoverProcedures } from "./project-cover.js";
|
import { projectCoverProcedures } from "./project-cover.js";
|
||||||
import { projectIdentifierReadProcedures } from "./project-identifier-read.js";
|
import { projectIdentifierReadProcedures } from "./project-identifier-read.js";
|
||||||
import { createProjectLifecycleProcedures } from "./project-lifecycle.js";
|
import { createProjectLifecycleProcedures } from "./project-lifecycle.js";
|
||||||
import { createProjectMutationProcedures } from "./project-mutations.js";
|
import { createProjectMutationProcedures } from "./project-mutations.js";
|
||||||
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
|
|
||||||
import { createProjectBackgroundEffects } from "./project-background-effects.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";
|
import { controllerProcedure, createTRPCRouter } from "../trpc.js";
|
||||||
|
|
||||||
const projectBackgroundEffects = createProjectBackgroundEffects();
|
const projectBackgroundEffects = createProjectBackgroundEffects();
|
||||||
@@ -27,94 +27,15 @@ export const projectRouter = createTRPCRouter({
|
|||||||
...createProjectMutationProcedures(projectBackgroundEffects),
|
...createProjectMutationProcedures(projectBackgroundEffects),
|
||||||
|
|
||||||
list: controllerProcedure
|
list: controllerProcedure
|
||||||
.input(
|
.input(ProjectListInputSchema)
|
||||||
PaginationInputSchema.extend({
|
.query(({ ctx, input }) => listProjects(ctx, input)),
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
getById: controllerProcedure
|
getById: controllerProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(ProjectIdInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(({ ctx, input }) => getProjectById(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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
getShoringRatio: controllerProcedure
|
getShoringRatio: controllerProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(ProjectShoringRatioInputSchema)
|
||||||
.query(async ({ ctx, input }) => getProjectShoringRatio(ctx.db, input.projectId)),
|
.query(({ ctx, input }) => getProjectShoringRatioData(ctx, input)),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user