diff --git a/packages/api/src/router/project-background-effects.ts b/packages/api/src/router/project-background-effects.ts new file mode 100644 index 0000000..78d2902 --- /dev/null +++ b/packages/api/src/router/project-background-effects.ts @@ -0,0 +1,48 @@ +import { invalidateDashboardCache } from "../lib/cache.js"; +import { logger } from "../lib/logger.js"; +import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; +import type { TRPCContext } from "../trpc.js"; + +function runProjectBackgroundEffect( + effectName: string, + execute: () => unknown, + metadata: Record = {}, +): void { + void Promise.resolve() + .then(execute) + .catch((error) => { + logger.error( + { err: error, effectName, ...metadata }, + "Project background side effect failed", + ); + }); +} + +export type ProjectBackgroundEffects = { + invalidateDashboardCacheInBackground: () => void; + dispatchProjectWebhookInBackground: ( + db: TRPCContext["db"], + event: string, + payload: Record, + ) => void; +}; + +export function createProjectBackgroundEffects(): ProjectBackgroundEffects { + return { + invalidateDashboardCacheInBackground(): void { + runProjectBackgroundEffect("invalidateDashboardCache", () => invalidateDashboardCache()); + }, + + dispatchProjectWebhookInBackground( + db: TRPCContext["db"], + event: string, + payload: Record, + ): void { + runProjectBackgroundEffect( + "dispatchWebhooks", + () => dispatchWebhooks(db, event, payload), + { event }, + ); + }, + }; +} diff --git a/packages/api/src/router/project-mutations.ts b/packages/api/src/router/project-mutations.ts new file mode 100644 index 0000000..5025958 --- /dev/null +++ b/packages/api/src/router/project-mutations.ts @@ -0,0 +1,145 @@ +import { BlueprintTarget, CreateProjectSchema, PermissionKey, UpdateProjectSchema } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; +import { managerProcedure, requirePermission, type TRPCContext } from "../trpc.js"; +import type { ProjectBackgroundEffects } from "./project-background-effects.js"; + +function buildProjectCreateData( + input: z.infer, +): Parameters[0]["data"] { + return { + shortCode: input.shortCode, + name: input.name, + orderType: input.orderType, + allocationType: input.allocationType, + winProbability: input.winProbability, + budgetCents: input.budgetCents, + startDate: input.startDate, + endDate: input.endDate, + status: input.status, + responsiblePerson: input.responsiblePerson, + ...(input.color !== undefined ? { color: input.color } : {}), + staffingReqs: input.staffingReqs as unknown as import("@capakraken/db").Prisma.InputJsonValue, + dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue, + blueprintId: input.blueprintId, + ...(input.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.utilizationCategoryId || null } : {}), + ...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}), + } as unknown as Parameters[0]["data"]; +} + +function buildProjectUpdateData( + input: z.infer, +): Parameters[0]["data"] { + return { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.orderType !== undefined ? { orderType: input.orderType } : {}), + ...(input.allocationType !== undefined ? { allocationType: input.allocationType } : {}), + ...(input.winProbability !== undefined ? { winProbability: input.winProbability } : {}), + ...(input.budgetCents !== undefined ? { budgetCents: input.budgetCents } : {}), + ...(input.startDate !== undefined ? { startDate: input.startDate } : {}), + ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), + ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.responsiblePerson !== undefined ? { responsiblePerson: input.responsiblePerson } : {}), + ...(input.color !== undefined ? { color: input.color } : {}), + ...(input.staffingReqs !== undefined ? { staffingReqs: input.staffingReqs as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), + ...(input.dynamicFields !== undefined ? { dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), + ...(input.blueprintId !== undefined ? { blueprintId: input.blueprintId } : {}), + ...(input.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.utilizationCategoryId || null } : {}), + ...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}), + ...(input.shoringThreshold !== undefined ? { shoringThreshold: input.shoringThreshold } : {}), + ...(input.onshoreCountryCode !== undefined ? { onshoreCountryCode: input.onshoreCountryCode } : {}), + } as unknown as Parameters[0]["data"]; +} + +export function createProjectMutationProcedures( + backgroundEffects: ProjectBackgroundEffects, +) { + return { + create: managerProcedure + .input(CreateProjectSchema) + .mutation(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); + + const existing = await ctx.db.project.findUnique({ + where: { shortCode: input.shortCode }, + }); + + if (existing) { + throw new TRPCError({ + code: "CONFLICT", + message: `Project with short code "${input.shortCode}" already exists`, + }); + } + + await assertBlueprintDynamicFields({ + db: ctx.db, + blueprintId: input.blueprintId, + dynamicFields: input.dynamicFields, + target: BlueprintTarget.PROJECT, + }); + + const project = await ctx.db.project.create({ + data: buildProjectCreateData(input), + }); + + await ctx.db.auditLog.create({ + data: { + entityType: "Project", + entityId: project.id, + action: "CREATE", + changes: { after: project }, + }, + }); + + backgroundEffects.invalidateDashboardCacheInBackground(); + backgroundEffects.dispatchProjectWebhookInBackground(ctx.db, "project.created", { + id: project.id, + shortCode: project.shortCode, + name: project.name, + status: project.status, + }); + + return project; + }), + + update: managerProcedure + .input(z.object({ id: z.string(), data: UpdateProjectSchema })) + .mutation(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); + + const existing = await findUniqueOrThrow( + ctx.db.project.findUnique({ where: { id: input.id } }), + "Project", + ); + + const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined; + const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record; + + await assertBlueprintDynamicFields({ + db: ctx.db, + blueprintId: nextBlueprintId, + dynamicFields: nextDynamicFields, + target: BlueprintTarget.PROJECT, + }); + + const updated = await ctx.db.project.update({ + where: { id: input.id }, + data: buildProjectUpdateData(input.data), + }); + + await ctx.db.auditLog.create({ + data: { + entityType: "Project", + entityId: input.id, + action: "UPDATE", + changes: { before: existing, after: updated }, + }, + }); + + backgroundEffects.invalidateDashboardCacheInBackground(); + return updated; + }), + }; +} diff --git a/packages/api/src/router/project-shoring-ratio.ts b/packages/api/src/router/project-shoring-ratio.ts new file mode 100644 index 0000000..93f564a --- /dev/null +++ b/packages/api/src/router/project-shoring-ratio.ts @@ -0,0 +1,111 @@ +import { calculateShoringRatio, type ShoringAssignment } from "@capakraken/engine/allocation"; +import type { WeekdayAvailability } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { + calculateEffectiveBookedHours, + loadResourceDailyAvailabilityContexts, +} from "../lib/resource-capacity.js"; +import type { TRPCContext } from "../trpc.js"; + +type ProjectShoringRatioProjectRecord = { + id: string; + name: string; + shoringThreshold: number | null; + onshoreCountryCode: string | null; +}; + +type ProjectShoringRatioAssignmentRecord = { + resourceId: string; + startDate: Date; + endDate: Date; + hoursPerDay: number; + resource: { + id: string; + countryId: string | null; + federalState: string | null; + metroCityId: string | null; + availability: unknown; + country: { id: string; code: string } | null; + metroCity: { id: string; name: string } | null; + }; +}; + +export async function getProjectShoringRatio( + db: TRPCContext["db"], + projectId: string, +) { + const project = await db.project.findUnique({ + where: { id: projectId }, + select: { + id: true, + name: true, + shoringThreshold: true, + onshoreCountryCode: true, + }, + }) as ProjectShoringRatioProjectRecord | null; + + if (!project) { + throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); + } + + const assignments = await db.assignment.findMany({ + where: { projectId, status: { not: "CANCELLED" } }, + include: { + resource: { + include: { + country: { select: { id: true, code: true } }, + metroCity: { select: { id: true, name: true } }, + }, + }, + }, + }) as ProjectShoringRatioAssignmentRecord[]; + + const periodStart = assignments.length > 0 + ? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime()))) + : new Date(); + const periodEnd = assignments.length > 0 + ? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime()))) + : new Date(); + + const contexts = await loadResourceDailyAvailabilityContexts( + db, + assignments.map((assignment) => ({ + id: assignment.resource.id, + availability: assignment.resource.availability as WeekdayAvailability, + countryId: assignment.resource.country?.id ?? assignment.resource.countryId, + countryCode: assignment.resource.country?.code, + federalState: assignment.resource.federalState, + metroCityId: assignment.resource.metroCity?.id ?? assignment.resource.metroCityId, + metroCityName: assignment.resource.metroCity?.name, + })), + periodStart, + periodEnd, + ); + + const mapped: ShoringAssignment[] = assignments.map((assignment) => { + const workingDays = assignment.hoursPerDay > 0 + ? calculateEffectiveBookedHours({ + availability: assignment.resource.availability as WeekdayAvailability, + startDate: assignment.startDate, + endDate: assignment.endDate, + hoursPerDay: assignment.hoursPerDay, + periodStart, + periodEnd, + context: contexts.get(assignment.resourceId ?? assignment.resource.id), + }) / assignment.hoursPerDay + : 0; + + return { + resourceId: assignment.resourceId, + countryCode: assignment.resource.country?.code ?? null, + hoursPerDay: assignment.hoursPerDay, + workingDays: Math.max(0, workingDays), + }; + }); + + return calculateShoringRatio( + mapped, + project.shoringThreshold ?? 55, + project.onshoreCountryCode ?? "DE", + ); +} diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts index 0803fd0..a48e057 100644 --- a/packages/api/src/router/project.ts +++ b/packages/api/src/router/project.ts @@ -1,67 +1,30 @@ import { countPlanningEntries } from "@capakraken/application"; -import type { WeekdayAvailability } from "@capakraken/shared"; -import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared"; +import { FieldType, ProjectStatus } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { calculateShoringRatio, type ShoringAssignment } from "@capakraken/engine/allocation"; -import { findUniqueOrThrow } from "../db/helpers.js"; -import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } from "../db/pagination.js"; -import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; +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 { controllerProcedure, createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js"; -import { invalidateDashboardCache } from "../lib/cache.js"; -import { logger } from "../lib/logger.js"; -import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; -import type { TRPCContext } from "../trpc.js"; -import { - calculateEffectiveBookedHours, - loadResourceDailyAvailabilityContexts, -} from "../lib/resource-capacity.js"; +import { createProjectBackgroundEffects } from "./project-background-effects.js"; +import { getProjectShoringRatio } from "./project-shoring-ratio.js"; +import { controllerProcedure, createTRPCRouter } from "../trpc.js"; -function runProjectBackgroundEffect( - effectName: string, - execute: () => unknown, - metadata: Record = {}, -): void { - void Promise.resolve() - .then(execute) - .catch((error) => { - logger.error( - { err: error, effectName, ...metadata }, - "Project background side effect failed", - ); - }); -} - -function invalidateDashboardCacheInBackground(): void { - runProjectBackgroundEffect("invalidateDashboardCache", () => invalidateDashboardCache()); -} - -function dispatchProjectWebhookInBackground( - db: TRPCContext["db"], - event: string, - payload: Record, -): void { - runProjectBackgroundEffect( - "dispatchWebhooks", - () => dispatchWebhooks(db, event, payload), - { event }, - ); -} +const projectBackgroundEffects = createProjectBackgroundEffects(); export const projectRouter = createTRPCRouter({ ...projectCostReadProcedures, ...projectCoverProcedures, ...projectIdentifierReadProcedures, ...createProjectLifecycleProcedures({ - invalidateDashboardCacheInBackground, - dispatchProjectWebhookInBackground, + invalidateDashboardCacheInBackground: projectBackgroundEffects.invalidateDashboardCacheInBackground, + dispatchProjectWebhookInBackground: projectBackgroundEffects.dispatchProjectWebhookInBackground, }), + ...createProjectMutationProcedures(projectBackgroundEffects), list: controllerProcedure .input( @@ -152,195 +115,6 @@ export const projectRouter = createTRPCRouter({ getShoringRatio: controllerProcedure .input(z.object({ projectId: z.string() })) - .query(async ({ ctx, input }) => { - const project = await ctx.db.project.findUnique({ - where: { id: input.projectId }, - select: { - id: true, - name: true, - shoringThreshold: true, - onshoreCountryCode: true, - }, - }); - - if (!project) { - throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); - } - - const assignments = await ctx.db.assignment.findMany({ - where: { projectId: input.projectId, status: { not: "CANCELLED" } }, - include: { - resource: { - include: { - country: { select: { id: true, code: true } }, - metroCity: { select: { id: true, name: true } }, - }, - }, - }, - }); - const periodStart = assignments.length > 0 - ? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime()))) - : new Date(); - const periodEnd = assignments.length > 0 - ? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime()))) - : new Date(); - const contexts = await loadResourceDailyAvailabilityContexts( - ctx.db, - assignments.map((assignment) => ({ - id: assignment.resource.id, - availability: assignment.resource.availability as unknown as WeekdayAvailability, - countryId: assignment.resource.country?.id ?? assignment.resource.countryId, - countryCode: assignment.resource.country?.code, - federalState: assignment.resource.federalState, - metroCityId: assignment.resource.metroCity?.id ?? assignment.resource.metroCityId, - metroCityName: assignment.resource.metroCity?.name, - })), - periodStart, - periodEnd, - ); - - const mapped: ShoringAssignment[] = assignments.map((a) => { - const workingDays = a.hoursPerDay > 0 - ? calculateEffectiveBookedHours({ - availability: a.resource.availability as unknown as WeekdayAvailability, - startDate: a.startDate, - endDate: a.endDate, - hoursPerDay: a.hoursPerDay, - periodStart, - periodEnd, - context: contexts.get(a.resourceId ?? a.resource.id), - }) / a.hoursPerDay - : 0; - return { - resourceId: a.resourceId, - countryCode: a.resource.country?.code ?? null, - hoursPerDay: a.hoursPerDay, - workingDays: Math.max(0, workingDays), - }; - }); - - return calculateShoringRatio( - mapped, - project.shoringThreshold ?? 55, - project.onshoreCountryCode ?? "DE", - ); - }), - - create: managerProcedure - .input(CreateProjectSchema) - .mutation(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); - const existing = await ctx.db.project.findUnique({ - where: { shortCode: input.shortCode }, - }); - - if (existing) { - throw new TRPCError({ - code: "CONFLICT", - message: `Project with short code "${input.shortCode}" already exists`, - }); - } - - await assertBlueprintDynamicFields({ - db: ctx.db, - blueprintId: input.blueprintId, - dynamicFields: input.dynamicFields, - target: BlueprintTarget.PROJECT, - }); - - const project = await ctx.db.project.create({ - data: { - shortCode: input.shortCode, - name: input.name, - orderType: input.orderType, - allocationType: input.allocationType, - winProbability: input.winProbability, - budgetCents: input.budgetCents, - startDate: input.startDate, - endDate: input.endDate, - status: input.status, - responsiblePerson: input.responsiblePerson, - ...(input.color !== undefined ? { color: input.color } : {}), - staffingReqs: input.staffingReqs as unknown as import("@capakraken/db").Prisma.InputJsonValue, - dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue, - blueprintId: input.blueprintId, - ...(input.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.utilizationCategoryId || null } : {}), - ...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}), - } as unknown as Parameters[0]["data"], - }); - - await ctx.db.auditLog.create({ - data: { - entityType: "Project", - entityId: project.id, - action: "CREATE", - changes: { after: project }, - }, - }); - - invalidateDashboardCacheInBackground(); - dispatchProjectWebhookInBackground(ctx.db, "project.created", { - id: project.id, - shortCode: project.shortCode, - name: project.name, - status: project.status, - }); - return project; - }), - - update: managerProcedure - .input(z.object({ id: z.string(), data: UpdateProjectSchema })) - .mutation(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); - const existing = await findUniqueOrThrow( - ctx.db.project.findUnique({ where: { id: input.id } }), - "Project", - ); - - const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined; - const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record; - - await assertBlueprintDynamicFields({ - db: ctx.db, - blueprintId: nextBlueprintId, - dynamicFields: nextDynamicFields, - target: BlueprintTarget.PROJECT, - }); - - const updated = await ctx.db.project.update({ - where: { id: input.id }, - data: { - ...(input.data.name !== undefined ? { name: input.data.name } : {}), - ...(input.data.orderType !== undefined ? { orderType: input.data.orderType } : {}), - ...(input.data.allocationType !== undefined ? { allocationType: input.data.allocationType } : {}), - ...(input.data.winProbability !== undefined ? { winProbability: input.data.winProbability } : {}), - ...(input.data.budgetCents !== undefined ? { budgetCents: input.data.budgetCents } : {}), - ...(input.data.startDate !== undefined ? { startDate: input.data.startDate } : {}), - ...(input.data.endDate !== undefined ? { endDate: input.data.endDate } : {}), - ...(input.data.status !== undefined ? { status: input.data.status } : {}), - ...(input.data.responsiblePerson !== undefined ? { responsiblePerson: input.data.responsiblePerson } : {}), - ...(input.data.color !== undefined ? { color: input.data.color } : {}), - ...(input.data.staffingReqs !== undefined ? { staffingReqs: input.data.staffingReqs as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), - ...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), - ...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}), - ...(input.data.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.data.utilizationCategoryId || null } : {}), - ...(input.data.clientId !== undefined ? { clientId: input.data.clientId || null } : {}), - ...(input.data.shoringThreshold !== undefined ? { shoringThreshold: input.data.shoringThreshold } : {}), - ...(input.data.onshoreCountryCode !== undefined ? { onshoreCountryCode: input.data.onshoreCountryCode } : {}), - } as unknown as Parameters[0]["data"], - }); - - await ctx.db.auditLog.create({ - data: { - entityType: "Project", - entityId: input.id, - action: "UPDATE", - changes: { before: existing, after: updated }, - }, - }); - - invalidateDashboardCacheInBackground(); - return updated; - }), + .query(async ({ ctx, input }) => getProjectShoringRatio(ctx.db, input.projectId)), });