From 3a15f72f70360c840e2fead62b007a12a09fe1c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 08:58:22 +0200 Subject: [PATCH] refactor(api): extract project cost read procedures --- packages/api/src/router/project-cost-read.ts | 74 +++++++++++++++++++ packages/api/src/router/project.ts | 75 +------------------- 2 files changed, 77 insertions(+), 72 deletions(-) create mode 100644 packages/api/src/router/project-cost-read.ts diff --git a/packages/api/src/router/project-cost-read.ts b/packages/api/src/router/project-cost-read.ts new file mode 100644 index 0000000..cd2ecd9 --- /dev/null +++ b/packages/api/src/router/project-cost-read.ts @@ -0,0 +1,74 @@ +import { listAssignmentBookings } from "@capakraken/application"; +import { ProjectStatus } from "@capakraken/shared"; +import { z } from "zod"; +import { CursorInputSchema, paginateCursor } from "../db/pagination.js"; +import { controllerProcedure } from "../trpc.js"; + +export const projectCostReadProcedures = { + listWithCosts: controllerProcedure + .input( + CursorInputSchema.extend({ + status: z.nativeEnum(ProjectStatus).optional(), + search: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const { status, search, cursor } = input; + const where = { + ...(status ? { status } : {}), + ...(search + ? { + OR: [ + { name: { contains: search, mode: "insensitive" as const } }, + { shortCode: { contains: search, mode: "insensitive" as const } }, + ], + } + : {}), + }; + const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where; + + const result = await paginateCursor( + ({ take }) => + ctx.db.project.findMany({ + where: whereWithCursor, + take, + orderBy: [{ startDate: "asc" }, { id: "asc" }], + }), + input, + ); + + const projectIds = result.items.map((project) => project.id); + const bookings = projectIds.length + ? await listAssignmentBookings(ctx.db, { + startDate: new Date("1900-01-01T00:00:00.000Z"), + endDate: new Date("2100-12-31T23:59:59.999Z"), + projectIds, + }) + : []; + + const projects = result.items.map((project) => { + const projectBookings = bookings.filter((booking) => booking.projectId === project.id); + let totalCostCents = 0; + let totalPersonDays = 0; + for (const booking of projectBookings) { + const days = + (new Date(booking.endDate).getTime() - new Date(booking.startDate).getTime()) / + (1000 * 60 * 60 * 24) + + 1; + totalCostCents += booking.dailyCostCents * days; + totalPersonDays += (booking.hoursPerDay * days) / 8; + } + const utilizationPercent = project.budgetCents > 0 + ? Math.round((totalCostCents / project.budgetCents) * 100) + : 0; + return { + ...project, + totalCostCents: Math.round(totalCostCents), + totalPersonDays: Math.round(totalPersonDays * 10) / 10, + utilizationPercent, + }; + }); + + return { projects, nextCursor: result.nextCursor }; + }), +}; diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts index 9c1979e..4e2dd31 100644 --- a/packages/api/src/router/project.ts +++ b/packages/api/src/router/project.ts @@ -1,7 +1,4 @@ -import { - countPlanningEntries, - listAssignmentBookings, -} from "@capakraken/application"; +import { countPlanningEntries } from "@capakraken/application"; import type { WeekdayAvailability } from "@capakraken/shared"; import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; @@ -11,6 +8,7 @@ import { findUniqueOrThrow } from "../db/helpers.js"; import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } from "../db/pagination.js"; import { assertBlueprintDynamicFields } from "./blueprint-validation.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 { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; @@ -56,6 +54,7 @@ function dispatchProjectWebhookInBackground( } export const projectRouter = createTRPCRouter({ + ...projectCostReadProcedures, ...projectCoverProcedures, ...projectIdentifierReadProcedures, @@ -385,74 +384,6 @@ export const projectRouter = createTRPCRouter({ return { count: updated.length }; }), - listWithCosts: controllerProcedure - .input( - CursorInputSchema.extend({ - status: z.nativeEnum(ProjectStatus).optional(), - search: z.string().optional(), - }), - ) - .query(async ({ ctx, input }) => { - const { status, search, cursor } = input; - const where = { - ...(status ? { status } : {}), - ...(search - ? { - OR: [ - { name: { contains: search, mode: "insensitive" as const } }, - { shortCode: { contains: search, mode: "insensitive" as const } }, - ], - } - : {}), - }; - const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where; - - const result = await paginateCursor( - ({ take }) => - ctx.db.project.findMany({ - where: whereWithCursor, - take, - orderBy: [{ startDate: "asc" }, { id: "asc" }], - }), - input, - ); - - const projectIds = result.items.map((project) => project.id); - const bookings = projectIds.length - ? await listAssignmentBookings(ctx.db, { - startDate: new Date("1900-01-01T00:00:00.000Z"), - endDate: new Date("2100-12-31T23:59:59.999Z"), - projectIds, - }) - : []; - - // Compute cost + person days per project - const projects = result.items.map((p) => { - const projectBookings = bookings.filter((booking) => booking.projectId === p.id); - let totalCostCents = 0; - let totalPersonDays = 0; - for (const a of projectBookings) { - const days = - (new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) / - (1000 * 60 * 60 * 24) + - 1; - totalCostCents += a.dailyCostCents * days; - totalPersonDays += (a.hoursPerDay * days) / 8; - } - const utilizationPercent = p.budgetCents > 0 - ? Math.round((totalCostCents / p.budgetCents) * 100) - : 0; - return { - ...p, - totalCostCents: Math.round(totalCostCents), - totalPersonDays: Math.round(totalPersonDays * 10) / 10, - utilizationPercent, - }; - }); - - return { projects, nextCursor: result.nextCursor }; - }), - delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => {