refactor(api): extract project identifier read procedures
This commit is contained in:
@@ -0,0 +1,105 @@
|
|||||||
|
import { ProjectStatus } from "@capakraken/shared";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { controllerProcedure, planningReadProcedure } from "../trpc.js";
|
||||||
|
import {
|
||||||
|
mapProjectDetail,
|
||||||
|
mapProjectSummary,
|
||||||
|
mapProjectSummaryDetail,
|
||||||
|
readProjectByIdentifierDetailSnapshot,
|
||||||
|
readProjectSummariesSnapshot,
|
||||||
|
readProjectSummaryDetailsSnapshot,
|
||||||
|
resolveProjectIdentifierSnapshot,
|
||||||
|
} from "./project-read-shared.js";
|
||||||
|
|
||||||
|
export const projectIdentifierReadProcedures = {
|
||||||
|
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;
|
||||||
|
}),
|
||||||
|
|
||||||
|
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);
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { ProjectStatus } from "@capakraken/shared";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { fmtEur } from "../lib/format-utils.js";
|
||||||
|
import type { TRPCContext } from "../trpc.js";
|
||||||
|
|
||||||
|
export const PROJECT_SUMMARY_SELECT = {
|
||||||
|
id: true,
|
||||||
|
shortCode: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
client: { select: { name: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const PROJECT_SUMMARY_DETAIL_SELECT = {
|
||||||
|
...PROJECT_SUMMARY_SELECT,
|
||||||
|
budgetCents: true,
|
||||||
|
winProbability: true,
|
||||||
|
_count: { select: { assignments: true, estimates: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const PROJECT_IDENTIFIER_SELECT = {
|
||||||
|
id: true,
|
||||||
|
shortCode: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export 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 formatDate(value: Date | null): string | null {
|
||||||
|
return value ? value.toISOString().slice(0, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { findUniqueOrThrow } from "../db/helpers.js";
|
|||||||
import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } from "../db/pagination.js";
|
import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } from "../db/pagination.js";
|
||||||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||||||
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
||||||
|
import { projectIdentifierReadProcedures } from "./project-identifier-read.js";
|
||||||
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
|
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
|
||||||
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, planningReadProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, planningReadProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||||
import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
||||||
@@ -25,54 +26,15 @@ import {
|
|||||||
calculateEffectiveBookedHours,
|
calculateEffectiveBookedHours,
|
||||||
loadResourceDailyAvailabilityContexts,
|
loadResourceDailyAvailabilityContexts,
|
||||||
} from "../lib/resource-capacity.js";
|
} 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 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(
|
function runProjectBackgroundEffect(
|
||||||
effectName: string,
|
effectName: string,
|
||||||
execute: () => unknown,
|
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({
|
export const projectRouter = createTRPCRouter({
|
||||||
resolveByIdentifier: planningReadProcedure
|
...projectIdentifierReadProcedures,
|
||||||
.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;
|
|
||||||
}),
|
|
||||||
|
|
||||||
list: controllerProcedure
|
list: controllerProcedure
|
||||||
.input(
|
.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
|
getShoringRatio: controllerProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(z.object({ projectId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user