From 3a8c9ab92032e76ab4436e7a4c65eded5722d4c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 08:54:52 +0200 Subject: [PATCH] refactor(api): extract project identifier read procedures --- .../api/src/router/project-identifier-read.ts | 105 ++++ .../api/src/router/project-read-shared.ts | 355 ++++++++++++++ packages/api/src/router/project.ts | 449 +----------------- 3 files changed, 468 insertions(+), 441 deletions(-) create mode 100644 packages/api/src/router/project-identifier-read.ts create mode 100644 packages/api/src/router/project-read-shared.ts diff --git a/packages/api/src/router/project-identifier-read.ts b/packages/api/src/router/project-identifier-read.ts new file mode 100644 index 0000000..5184175 --- /dev/null +++ b/packages/api/src/router/project-identifier-read.ts @@ -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); + }), +}; diff --git a/packages/api/src/router/project-read-shared.ts b/packages/api/src/router/project-read-shared.ts new file mode 100644 index 0000000..3bdc0a9 --- /dev/null +++ b/packages/api/src/router/project-read-shared.ts @@ -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, + 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, + 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, + 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, + 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, + }; +} diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts index 095c171..60eec70 100644 --- a/packages/api/src/router/project.ts +++ b/packages/api/src/router/project.ts @@ -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, - 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, - 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, - 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, - 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 }) => {