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 { 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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user