refactor(api): extract project router support
This commit is contained in:
@@ -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<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 },
|
||||
);
|
||||
}
|
||||
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<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;
|
||||
}),
|
||||
.query(async ({ ctx, input }) => getProjectShoringRatio(ctx.db, input.projectId)),
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user