refactor(api): extract project identifier read procedures

This commit is contained in:
2026-03-31 08:54:52 +02:00
parent 741952e1e1
commit 3a8c9ab920
3 changed files with 468 additions and 441 deletions
+8 -441
View File
@@ -11,6 +11,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 { projectIdentifierReadProcedures } from "./project-identifier-read.js";
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, planningReadProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
@@ -25,54 +26,15 @@ import {
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { fmtEur } from "../lib/format-utils.js";
import {
PROJECT_DETAIL_SELECT,
PROJECT_IDENTIFIER_SELECT,
PROJECT_SUMMARY_DETAIL_SELECT,
PROJECT_SUMMARY_SELECT,
} from "./project-read-shared.js";
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
const PROJECT_SUMMARY_SELECT = {
id: true,
shortCode: true,
name: true,
status: true,
startDate: true,
endDate: true,
client: { select: { name: true } },
} as const;
const PROJECT_SUMMARY_DETAIL_SELECT = {
...PROJECT_SUMMARY_SELECT,
budgetCents: true,
winProbability: true,
_count: { select: { assignments: true, estimates: true } },
} as const;
const PROJECT_IDENTIFIER_SELECT = {
id: true,
shortCode: true,
name: true,
status: true,
startDate: true,
endDate: true,
} as const;
const PROJECT_DETAIL_SELECT = {
...PROJECT_IDENTIFIER_SELECT,
id: true,
shortCode: true,
name: true,
status: true,
orderType: true,
allocationType: true,
budgetCents: true,
winProbability: true,
startDate: true,
endDate: true,
responsiblePerson: true,
client: { select: { name: true } },
utilizationCategory: { select: { code: true, name: true } },
_count: { select: { assignments: true, estimates: true } },
} as const;
function runProjectBackgroundEffect(
effectName: string,
execute: () => unknown,
@@ -104,392 +66,8 @@ function dispatchProjectWebhookInBackground(
);
}
function formatDate(value: Date | null): string | null {
return value ? value.toISOString().slice(0, 10) : null;
}
function mapProjectSummary(project: {
id: string;
shortCode: string;
name: string;
status: string;
startDate: Date | null;
endDate: Date | null;
client: { name: string } | null;
}) {
return {
id: project.id,
code: project.shortCode,
name: project.name,
status: project.status,
start: formatDate(project.startDate),
end: formatDate(project.endDate),
client: project.client?.name ?? null,
};
}
function mapProjectSummaryDetail(project: {
id: string;
shortCode: string;
name: string;
status: string;
budgetCents: number | null;
winProbability: number;
startDate: Date | null;
endDate: Date | null;
client: { name: string } | null;
_count: { assignments: number; estimates: number };
}) {
return {
id: project.id,
code: project.shortCode,
name: project.name,
status: project.status,
budget: project.budgetCents && project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set",
winProbability: `${project.winProbability}%`,
start: formatDate(project.startDate),
end: formatDate(project.endDate),
client: project.client?.name ?? null,
assignmentCount: project._count.assignments,
estimateCount: project._count.estimates,
};
}
function mapProjectDetail(
project: {
id: string;
shortCode: string;
name: string;
status: string;
orderType: string;
allocationType: string;
budgetCents: number | null;
winProbability: number;
startDate: Date | null;
endDate: Date | null;
responsiblePerson: string | null;
client: { name: string } | null;
utilizationCategory: { code: string; name: string } | null;
_count: { assignments: number; estimates: number };
},
topAssignments: Array<{
resource: { displayName: string; eid: string };
role: string | null;
status: string;
hoursPerDay: number;
startDate: Date;
endDate: Date;
}>,
) {
return {
id: project.id,
code: project.shortCode,
name: project.name,
status: project.status,
orderType: project.orderType,
allocationType: project.allocationType,
budget: project.budgetCents && project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set",
budgetCents: project.budgetCents,
winProbability: `${project.winProbability}%`,
start: formatDate(project.startDate),
end: formatDate(project.endDate),
responsible: project.responsiblePerson,
client: project.client?.name ?? null,
category: project.utilizationCategory?.name ?? null,
assignmentCount: project._count.assignments,
estimateCount: project._count.estimates,
topAllocations: topAssignments.map((assignment) => ({
resource: assignment.resource.displayName,
eid: assignment.resource.eid,
role: assignment.role ?? null,
status: assignment.status,
hoursPerDay: assignment.hoursPerDay,
start: formatDate(assignment.startDate),
end: formatDate(assignment.endDate),
})),
};
}
async function readProjectSummariesSnapshot(
ctx: Pick<TRPCContext, "db">,
input: {
search?: string | undefined;
status?: ProjectStatus | undefined;
limit: number;
},
) {
const buildWhere = (search: string | undefined) => ({
...(input.status ? { status: input.status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
});
let projects = await ctx.db.project.findMany({
where: buildWhere(input.search),
select: PROJECT_SUMMARY_SELECT,
take: input.limit,
orderBy: { name: "asc" },
});
if (projects.length === 0 && input.search) {
const words = input.search.split(/[\s,._\-/]+/).filter((word) => word.length >= 2);
if (words.length > 1) {
const candidates = await ctx.db.project.findMany({
where: {
...(input.status ? { status: input.status } : {}),
OR: words.flatMap((word) => ([
{ name: { contains: word, mode: "insensitive" as const } },
{ shortCode: { contains: word, mode: "insensitive" as const } },
])),
},
select: PROJECT_SUMMARY_SELECT,
take: input.limit * 2,
orderBy: { name: "asc" },
});
projects = candidates
.map((project) => {
const haystack = `${project.name} ${project.shortCode}`.toLowerCase();
const matchCount = words.filter((word) => haystack.includes(word.toLowerCase())).length;
return { project, matchCount };
})
.sort((left, right) => right.matchCount - left.matchCount)
.slice(0, input.limit)
.map((entry) => entry.project);
}
}
return {
items: projects,
exactMatch: input.search
? projects.some((project) =>
project.name.toLowerCase().includes(input.search!.toLowerCase())
|| project.shortCode.toLowerCase().includes(input.search!.toLowerCase()))
: true,
};
}
async function readProjectSummaryDetailsSnapshot(
ctx: Pick<TRPCContext, "db">,
input: {
search?: string | undefined;
status?: ProjectStatus | undefined;
limit: number;
},
) {
const buildWhere = (search: string | undefined) => ({
...(input.status ? { status: input.status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
});
let projects = await ctx.db.project.findMany({
where: buildWhere(input.search),
select: PROJECT_SUMMARY_DETAIL_SELECT,
take: input.limit,
orderBy: { name: "asc" },
});
if (projects.length === 0 && input.search) {
const words = input.search.split(/[\s,._\-/]+/).filter((word) => word.length >= 2);
if (words.length > 1) {
const candidates = await ctx.db.project.findMany({
where: {
...(input.status ? { status: input.status } : {}),
OR: words.flatMap((word) => ([
{ name: { contains: word, mode: "insensitive" as const } },
{ shortCode: { contains: word, mode: "insensitive" as const } },
])),
},
select: PROJECT_SUMMARY_DETAIL_SELECT,
take: input.limit * 2,
orderBy: { name: "asc" },
});
projects = candidates
.map((project) => {
const haystack = `${project.name} ${project.shortCode}`.toLowerCase();
const matchCount = words.filter((word) => haystack.includes(word.toLowerCase())).length;
return { project, matchCount };
})
.sort((left, right) => right.matchCount - left.matchCount)
.slice(0, input.limit)
.map((entry) => entry.project);
}
}
return {
items: projects,
exactMatch: input.search
? projects.some((project) =>
project.name.toLowerCase().includes(input.search!.toLowerCase())
|| project.shortCode.toLowerCase().includes(input.search!.toLowerCase()))
: true,
};
}
async function resolveProjectIdentifierSnapshot(
ctx: Pick<TRPCContext, "db">,
identifier: string,
) {
let project = await ctx.db.project.findUnique({
where: { id: identifier },
select: PROJECT_IDENTIFIER_SELECT,
});
if (!project) {
project = await ctx.db.project.findUnique({
where: { shortCode: identifier },
select: PROJECT_IDENTIFIER_SELECT,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
select: PROJECT_IDENTIFIER_SELECT,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { contains: identifier, mode: "insensitive" } },
select: PROJECT_IDENTIFIER_SELECT,
});
}
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
return project;
}
async function readProjectByIdentifierDetailSnapshot(
ctx: Pick<TRPCContext, "db">,
identifier: string,
) {
const projectIdentity = await resolveProjectIdentifierSnapshot(ctx, identifier);
const project = await ctx.db.project.findUnique({
where: { id: projectIdentity.id },
select: PROJECT_DETAIL_SELECT,
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const topAssignments = await ctx.db.assignment.findMany({
where: {
projectId: project.id,
status: { not: "CANCELLED" },
},
select: {
resource: { select: { displayName: true, eid: true } },
role: true,
status: true,
hoursPerDay: true,
startDate: true,
endDate: true,
},
take: 10,
orderBy: { startDate: "desc" },
});
return {
...project,
topAssignments,
};
}
export const projectRouter = createTRPCRouter({
resolveByIdentifier: planningReadProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => {
const select = {
id: true,
shortCode: true,
name: true,
status: true,
responsiblePerson: true,
startDate: true,
endDate: true,
} as const;
let project = await ctx.db.project.findUnique({
where: { id: input.identifier },
select,
});
if (!project) {
project = await ctx.db.project.findUnique({
where: { shortCode: input.identifier },
select,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { equals: input.identifier, mode: "insensitive" } },
select,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { contains: input.identifier, mode: "insensitive" } },
select,
});
}
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
return project;
}),
searchSummaries: planningReadProcedure
.input(z.object({
search: z.string().optional(),
status: z.nativeEnum(ProjectStatus).optional(),
limit: z.number().int().min(1).max(50).default(20),
}))
.query(async ({ ctx, input }) => {
const { items, exactMatch } = await readProjectSummariesSnapshot(ctx, input);
const formatted = items.map(mapProjectSummary);
if (items.length > 0 && input.search && !exactMatch) {
return {
suggestions: formatted,
note: `No exact match for "${input.search}". These projects match some of the search terms:`,
};
}
return formatted;
}),
searchSummariesDetail: controllerProcedure
.input(z.object({
search: z.string().optional(),
status: z.nativeEnum(ProjectStatus).optional(),
limit: z.number().int().min(1).max(50).default(20),
}))
.query(async ({ ctx, input }) => {
const { items, exactMatch } = await readProjectSummaryDetailsSnapshot(ctx, input);
const formatted = items.map(mapProjectSummaryDetail);
if (items.length > 0 && input.search && !exactMatch) {
return {
suggestions: formatted,
note: `No exact match for "${input.search}". These projects match some of the search terms:`,
};
}
return formatted;
}),
...projectIdentifierReadProcedures,
list: controllerProcedure
.input(
@@ -578,17 +156,6 @@ export const projectRouter = createTRPCRouter({
};
}),
getByIdentifier: planningReadProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => resolveProjectIdentifierSnapshot(ctx, input.identifier)),
getByIdentifierDetail: controllerProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => {
const project = await readProjectByIdentifierDetailSnapshot(ctx, input.identifier);
return mapProjectDetail(project, project.topAssignments);
}),
getShoringRatio: controllerProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {