Files
CapaKraken/packages/api/src/router/project.ts
T

1173 lines
38 KiB
TypeScript

import {
countPlanningEntries,
listAssignmentBookings,
} from "@capakraken/application";
import type { WeekdayAvailability } from "@capakraken/shared";
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { calculateShoringRatio, type ShoringAssignment } from "@capakraken/engine/allocation";
import { findUniqueOrThrow } from "../db/helpers.js";
import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } from "../db/pagination.js";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.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";
import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js";
import { invalidateDashboardCache } from "../lib/cache.js";
import { logger } from "../lib/logger.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { validateImageDataUrl } from "../lib/image-validation.js";
import type { TRPCContext } from "../trpc.js";
import {
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { fmtEur } from "../lib/format-utils.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,
metadata: Record<string, unknown> = {},
): void {
void Promise.resolve()
.then(execute)
.catch((error) => {
logger.error(
{ err: error, effectName, ...metadata },
"Project background side effect failed",
);
});
}
function invalidateDashboardCacheInBackground(): void {
runProjectBackgroundEffect("invalidateDashboardCache", () => invalidateDashboardCache());
}
function dispatchProjectWebhookInBackground(
db: TRPCContext["db"],
event: string,
payload: Record<string, unknown>,
): void {
runProjectBackgroundEffect(
"dispatchWebhooks",
() => dispatchWebhooks(db, event, payload),
{ event },
);
}
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;
}),
list: controllerProcedure
.input(
PaginationInputSchema.extend({
status: z.nativeEnum(ProjectStatus).optional(),
search: z.string().optional(),
// Custom field JSONB filters
customFieldFilters: z.array(z.object({
key: z.string(),
value: z.string(),
type: z.nativeEnum(FieldType),
})).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { status, search, cursor, customFieldFilters } = input;
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {
...(status ? { status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
...(cfConditions.length > 0 ? { AND: cfConditions } : {}),
};
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
const result = await paginate(
({ skip, take }) =>
ctx.db.project.findMany({
where: whereWithCursor,
skip,
take,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
}),
() => ctx.db.project.count({ where }),
input,
);
const { countsByProjectId } = await countPlanningEntries(ctx.db, {
projectIds: result.items.map((project) => project.id),
});
return {
projects: result.items.map((project) => ({
...project,
_count: {
allocations: countsByProjectId.get(project.id) ?? 0,
},
})),
total: result.total,
page: result.page,
limit: result.limit,
nextCursor: result.nextCursor,
};
}),
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const [project, planningRead] = await Promise.all([
ctx.db.project.findUnique({
where: { id: input.id },
include: { blueprint: true },
}),
loadProjectPlanningReadModel(ctx.db, { projectId: input.id }),
]);
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
return {
...project,
allocations: planningRead.readModel.assignments,
demands: planningRead.readModel.demands,
assignments: planningRead.readModel.assignments,
};
}),
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 }) => {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
name: true,
shoringThreshold: true,
onshoreCountryCode: true,
},
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const assignments = await ctx.db.assignment.findMany({
where: { projectId: input.projectId, status: { not: "CANCELLED" } },
include: {
resource: {
include: {
country: { select: { id: true, code: true } },
metroCity: { select: { id: true, name: true } },
},
},
},
});
const periodStart = assignments.length > 0
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
: new Date();
const periodEnd = assignments.length > 0
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
: new Date();
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
assignments.map((assignment) => ({
id: assignment.resource.id,
availability: assignment.resource.availability as unknown as WeekdayAvailability,
countryId: assignment.resource.country?.id ?? assignment.resource.countryId,
countryCode: assignment.resource.country?.code,
federalState: assignment.resource.federalState,
metroCityId: assignment.resource.metroCity?.id ?? assignment.resource.metroCityId,
metroCityName: assignment.resource.metroCity?.name,
})),
periodStart,
periodEnd,
);
const mapped: ShoringAssignment[] = assignments.map((a) => {
const workingDays = a.hoursPerDay > 0
? calculateEffectiveBookedHours({
availability: a.resource.availability as unknown as WeekdayAvailability,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
periodStart,
periodEnd,
context: contexts.get(a.resourceId ?? a.resource.id),
}) / a.hoursPerDay
: 0;
return {
resourceId: a.resourceId,
countryCode: a.resource.country?.code ?? null,
hoursPerDay: a.hoursPerDay,
workingDays: Math.max(0, workingDays),
};
});
return calculateShoringRatio(
mapped,
project.shoringThreshold ?? 55,
project.onshoreCountryCode ?? "DE",
);
}),
create: managerProcedure
.input(CreateProjectSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const existing = await ctx.db.project.findUnique({
where: { shortCode: input.shortCode },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: `Project with short code "${input.shortCode}" already exists`,
});
}
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: input.blueprintId,
dynamicFields: input.dynamicFields,
target: BlueprintTarget.PROJECT,
});
const project = await ctx.db.project.create({
data: {
shortCode: input.shortCode,
name: input.name,
orderType: input.orderType,
allocationType: input.allocationType,
winProbability: input.winProbability,
budgetCents: input.budgetCents,
startDate: input.startDate,
endDate: input.endDate,
status: input.status,
responsiblePerson: input.responsiblePerson,
...(input.color !== undefined ? { color: input.color } : {}),
staffingReqs: input.staffingReqs as unknown as import("@capakraken/db").Prisma.InputJsonValue,
dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue,
blueprintId: input.blueprintId,
...(input.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.utilizationCategoryId || null } : {}),
...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}),
} as unknown as Parameters<typeof ctx.db.project.create>[0]["data"],
});
await ctx.db.auditLog.create({
data: {
entityType: "Project",
entityId: project.id,
action: "CREATE",
changes: { after: project },
},
});
invalidateDashboardCacheInBackground();
dispatchProjectWebhookInBackground(ctx.db, "project.created", {
id: project.id,
shortCode: project.shortCode,
name: project.name,
status: project.status,
});
return project;
}),
update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateProjectSchema }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const existing = await findUniqueOrThrow(
ctx.db.project.findUnique({ where: { id: input.id } }),
"Project",
);
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: nextBlueprintId,
dynamicFields: nextDynamicFields,
target: BlueprintTarget.PROJECT,
});
const updated = await ctx.db.project.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.orderType !== undefined ? { orderType: input.data.orderType } : {}),
...(input.data.allocationType !== undefined ? { allocationType: input.data.allocationType } : {}),
...(input.data.winProbability !== undefined ? { winProbability: input.data.winProbability } : {}),
...(input.data.budgetCents !== undefined ? { budgetCents: input.data.budgetCents } : {}),
...(input.data.startDate !== undefined ? { startDate: input.data.startDate } : {}),
...(input.data.endDate !== undefined ? { endDate: input.data.endDate } : {}),
...(input.data.status !== undefined ? { status: input.data.status } : {}),
...(input.data.responsiblePerson !== undefined ? { responsiblePerson: input.data.responsiblePerson } : {}),
...(input.data.color !== undefined ? { color: input.data.color } : {}),
...(input.data.staffingReqs !== undefined ? { staffingReqs: input.data.staffingReqs as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
...(input.data.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.data.utilizationCategoryId || null } : {}),
...(input.data.clientId !== undefined ? { clientId: input.data.clientId || null } : {}),
...(input.data.shoringThreshold !== undefined ? { shoringThreshold: input.data.shoringThreshold } : {}),
...(input.data.onshoreCountryCode !== undefined ? { onshoreCountryCode: input.data.onshoreCountryCode } : {}),
} as unknown as Parameters<typeof ctx.db.project.update>[0]["data"],
});
await ctx.db.auditLog.create({
data: {
entityType: "Project",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: updated },
},
});
invalidateDashboardCacheInBackground();
return updated;
}),
updateStatus: managerProcedure
.input(z.object({ id: z.string(), status: z.nativeEnum(ProjectStatus) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const result = await ctx.db.project.update({
where: { id: input.id },
data: { status: input.status },
});
invalidateDashboardCacheInBackground();
dispatchProjectWebhookInBackground(ctx.db, "project.status_changed", {
id: result.id,
shortCode: result.shortCode,
name: result.name,
status: result.status,
});
return result;
}),
batchUpdateStatus: managerProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(100),
status: z.nativeEnum(ProjectStatus),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const updated = await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.project.update({ where: { id }, data: { status: input.status } }),
),
);
await ctx.db.auditLog.create({
data: {
entityType: "Project",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { status: input.status, ids: input.ids } },
},
});
invalidateDashboardCacheInBackground();
return { count: updated.length };
}),
listWithCosts: controllerProcedure
.input(
CursorInputSchema.extend({
status: z.nativeEnum(ProjectStatus).optional(),
search: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const { status, search, cursor } = input;
const where = {
...(status ? { status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
};
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
const result = await paginateCursor(
({ take }) =>
ctx.db.project.findMany({
where: whereWithCursor,
take,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
}),
input,
);
const projectIds = result.items.map((project) => project.id);
const bookings = projectIds.length
? await listAssignmentBookings(ctx.db, {
startDate: new Date("1900-01-01T00:00:00.000Z"),
endDate: new Date("2100-12-31T23:59:59.999Z"),
projectIds,
})
: [];
// Compute cost + person days per project
const projects = result.items.map((p) => {
const projectBookings = bookings.filter((booking) => booking.projectId === p.id);
let totalCostCents = 0;
let totalPersonDays = 0;
for (const a of projectBookings) {
const days =
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1;
totalCostCents += a.dailyCostCents * days;
totalPersonDays += (a.hoursPerDay * days) / 8;
}
const utilizationPercent = p.budgetCents > 0
? Math.round((totalCostCents / p.budgetCents) * 100)
: 0;
return {
...p,
totalCostCents: Math.round(totalCostCents),
totalPersonDays: Math.round(totalPersonDays * 10) / 10,
utilizationPercent,
};
});
return { projects, nextCursor: result.nextCursor };
}),
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const project = await ctx.db.project.findUnique({
where: { id: input.id },
select: { id: true, name: true, shortCode: true },
});
if (!project) throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
// Delete all related records in a transaction
await ctx.db.$transaction(async (tx) => {
// Delete assignments (which reference demandRequirements)
await tx.assignment.deleteMany({ where: { projectId: input.id } });
// Delete demand requirements
await tx.demandRequirement.deleteMany({ where: { projectId: input.id } });
// Unlink calculation rules
await tx.calculationRule.updateMany({
where: { projectId: input.id },
data: { projectId: null },
});
// Delete the project
await tx.project.delete({ where: { id: input.id } });
// Audit log
await tx.auditLog.create({
data: {
entityType: "Project",
entityId: input.id,
action: "DELETE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: { before: { id: project.id, name: project.name, shortCode: project.shortCode } } as never,
},
});
});
invalidateDashboardCacheInBackground();
return { id: input.id, name: project.name };
}),
batchDelete: adminProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(50),
}),
)
.mutation(async ({ ctx, input }) => {
const projects = await ctx.db.project.findMany({
where: { id: { in: input.ids } },
select: { id: true, name: true, shortCode: true },
});
if (projects.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "No projects found" });
}
await ctx.db.$transaction(async (tx) => {
const ids = projects.map((p) => p.id);
await tx.assignment.deleteMany({ where: { projectId: { in: ids } } });
await tx.demandRequirement.deleteMany({ where: { projectId: { in: ids } } });
await tx.calculationRule.updateMany({
where: { projectId: { in: ids } },
data: { projectId: null },
});
await tx.project.deleteMany({ where: { id: { in: ids } } });
await tx.auditLog.create({
data: {
entityType: "Project",
entityId: ids.join(","),
action: "DELETE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: { before: projects } as never,
},
});
});
invalidateDashboardCacheInBackground();
return { count: projects.length };
}),
// ─── Cover Art ──────────────────────────────────────────────────────────────
generateCover: managerProcedure
.input(z.object({
projectId: z.string(),
prompt: z.string().max(500).optional(),
}))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const project = await findUniqueOrThrow(
ctx.db.project.findUnique({
where: { id: input.projectId },
include: { client: { select: { name: true } } },
}),
"Project",
);
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
});
const imageProvider = settings?.imageProvider ?? "dalle";
const useGemini = imageProvider === "gemini" && isGeminiConfigured(settings);
const useDalle = imageProvider === "dalle" && isDalleConfigured(settings);
if (!useGemini && !useDalle) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "No image provider configured. Set up DALL-E or Gemini in Admin → Settings.",
});
}
const clientName = project.client?.name ? ` for ${project.client.name}` : "";
const basePrompt = `Professional cover art for a 3D automotive visualization project: "${project.name}"${clientName}. Style: cinematic, modern, photorealistic CGI rendering, dramatic lighting, studio environment. No text or typography in the image.`;
const finalPrompt = input.prompt
? `${basePrompt} Additional direction: ${input.prompt}`
: basePrompt;
let coverImageUrl: string;
if (useGemini) {
try {
coverImageUrl = await generateGeminiImage(
settings!.geminiApiKey!,
finalPrompt,
settings!.geminiModel ?? undefined,
);
} catch (err) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Gemini error: ${parseGeminiError(err)}`,
});
}
} else {
const dalleClient = createDalleClient(settings!);
const model = settings!.aiProvider === "azure" ? settings!.azureDalleDeployment! : "dall-e-3";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let response: any;
try {
response = await loggedAiCall("dalle", model, finalPrompt.length, () =>
dalleClient.images.generate({
model,
prompt: finalPrompt,
size: "1024x1024",
n: 1,
response_format: "b64_json",
}),
);
} catch (err) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `DALL-E error: ${parseAiError(err)}`,
});
}
const b64 = response.data?.[0]?.b64_json;
if (!b64) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "No image data returned from DALL-E",
});
}
coverImageUrl = `data:image/png;base64,${b64}`;
}
await ctx.db.project.update({
where: { id: input.projectId },
data: { coverImageUrl },
});
return { coverImageUrl };
}),
uploadCover: managerProcedure
.input(z.object({
projectId: z.string(),
imageDataUrl: z.string(),
}))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
if (!input.imageDataUrl.startsWith("data:image/")) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid image format. Must be a data URL starting with 'data:image/'.",
});
}
// Validate magic bytes match declared MIME type
const magicCheck = validateImageDataUrl(input.imageDataUrl);
if (!magicCheck.valid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `File validation failed: ${magicCheck.reason}`,
});
}
if (input.imageDataUrl.length > MAX_COVER_SIZE) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Image too large. Maximum compressed size is 4 MB.",
});
}
await findUniqueOrThrow(
ctx.db.project.findUnique({ where: { id: input.projectId } }),
"Project",
);
await ctx.db.project.update({
where: { id: input.projectId },
data: { coverImageUrl: input.imageDataUrl },
});
return { coverImageUrl: input.imageDataUrl };
}),
removeCover: managerProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
await findUniqueOrThrow(
ctx.db.project.findUnique({ where: { id: input.projectId } }),
"Project",
);
await ctx.db.project.update({
where: { id: input.projectId },
data: { coverImageUrl: null },
});
return { ok: true };
}),
updateCoverFocus: managerProcedure
.input(z.object({
projectId: z.string(),
coverFocusY: z.number().int().min(0).max(100),
}))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
await ctx.db.project.update({
where: { id: input.projectId },
data: { coverFocusY: input.coverFocusY },
});
return { ok: true };
}),
isImageGenConfigured: protectedProcedure
.query(async ({ ctx }) => {
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
});
const imageProvider = settings?.imageProvider ?? "dalle";
const configured = imageProvider === "gemini"
? isGeminiConfigured(settings)
: isDalleConfigured(settings);
return { configured, provider: imageProvider };
}),
/** @deprecated Use isImageGenConfigured instead */
isDalleConfigured: protectedProcedure
.query(async ({ ctx }) => {
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
});
const imageProvider = settings?.imageProvider ?? "dalle";
const configured = imageProvider === "gemini"
? isGeminiConfigured(settings)
: isDalleConfigured(settings);
return { configured };
}),
});