refactor(api): extract project router support

This commit is contained in:
2026-03-31 13:25:20 +02:00
parent 5cf31a0ce1
commit 8e542fd6ba
4 changed files with 315 additions and 237 deletions
@@ -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<string, unknown> = {},
): 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<string, unknown>,
) => void;
};
export function createProjectBackgroundEffects(): ProjectBackgroundEffects {
return {
invalidateDashboardCacheInBackground(): void {
runProjectBackgroundEffect("invalidateDashboardCache", () => invalidateDashboardCache());
},
dispatchProjectWebhookInBackground(
db: TRPCContext["db"],
event: string,
payload: Record<string, unknown>,
): void {
runProjectBackgroundEffect(
"dispatchWebhooks",
() => dispatchWebhooks(db, event, payload),
{ event },
);
},
};
}
@@ -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<typeof CreateProjectSchema>,
): Parameters<TRPCContext["db"]["project"]["create"]>[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<TRPCContext["db"]["project"]["create"]>[0]["data"];
}
function buildProjectUpdateData(
input: z.infer<typeof UpdateProjectSchema>,
): Parameters<TRPCContext["db"]["project"]["update"]>[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<TRPCContext["db"]["project"]["update"]>[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<string, unknown>;
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;
}),
};
}
@@ -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",
);
}
+11 -237
View File
@@ -1,67 +1,30 @@
import { countPlanningEntries } from "@capakraken/application"; import { countPlanningEntries } from "@capakraken/application";
import type { WeekdayAvailability } from "@capakraken/shared"; import { FieldType, ProjectStatus } from "@capakraken/shared";
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { calculateShoringRatio, type ShoringAssignment } from "@capakraken/engine/allocation"; import { paginate, PaginationInputSchema } from "../db/pagination.js";
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 { 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 { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
import { controllerProcedure, createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js"; import { createProjectBackgroundEffects } from "./project-background-effects.js";
import { invalidateDashboardCache } from "../lib/cache.js"; import { getProjectShoringRatio } from "./project-shoring-ratio.js";
import { logger } from "../lib/logger.js"; import { controllerProcedure, createTRPCRouter } from "../trpc.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import type { TRPCContext } from "../trpc.js";
import {
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
function runProjectBackgroundEffect( const projectBackgroundEffects = createProjectBackgroundEffects();
effectName: string,
execute: () => unknown,
metadata: Record<string, unknown> = {},
): 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<string, unknown>,
): void {
runProjectBackgroundEffect(
"dispatchWebhooks",
() => dispatchWebhooks(db, event, payload),
{ event },
);
}
export const projectRouter = createTRPCRouter({ export const projectRouter = createTRPCRouter({
...projectCostReadProcedures, ...projectCostReadProcedures,
...projectCoverProcedures, ...projectCoverProcedures,
...projectIdentifierReadProcedures, ...projectIdentifierReadProcedures,
...createProjectLifecycleProcedures({ ...createProjectLifecycleProcedures({
invalidateDashboardCacheInBackground, invalidateDashboardCacheInBackground: projectBackgroundEffects.invalidateDashboardCacheInBackground,
dispatchProjectWebhookInBackground, dispatchProjectWebhookInBackground: projectBackgroundEffects.dispatchProjectWebhookInBackground,
}), }),
...createProjectMutationProcedures(projectBackgroundEffects),
list: controllerProcedure list: controllerProcedure
.input( .input(
@@ -152,195 +115,6 @@ export const projectRouter = createTRPCRouter({
getShoringRatio: controllerProcedure getShoringRatio: controllerProcedure
.input(z.object({ projectId: z.string() })) .input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => getProjectShoringRatio(ctx.db, input.projectId)),
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<typeof ctx.db.project.create>[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<string, unknown>;
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<typeof ctx.db.project.update>[0]["data"],
});
await ctx.db.auditLog.create({
data: {
entityType: "Project",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: updated },
},
});
invalidateDashboardCacheInBackground();
return updated;
}),
}); });