fbeab5cd79
AI-Powered Insights (G9): - Rule-based anomaly detection: budget burn rate, staffing gaps, utilization, timeline overruns across all active projects - AI narrative generation via existing Azure OpenAI integration - Cached in project dynamicFields to avoid regeneration - New /analytics/insights page with anomaly feed + project summaries - Sidebar nav: "AI Insights" under Analytics Webhook System + Slack (G10): - Webhook model in Prisma (url, secret, events, isActive) - HMAC-SHA256 signed payloads with 5s timeout fire-and-forget dispatch - Slack-aware: routes hooks.slack.com URLs through Slack formatter - 6 events integrated: allocation.created/updated/deleted, project.created/ status_changed, vacation.approved - Admin UI: /admin/webhooks with CRUD, test button, event checkboxes - webhook router: list, getById, create, update, delete, test PWA Support (G11): - manifest.json with standalone display, brand-colored icons (192+512px) - Service worker: cache-first for static, network-first for API, offline fallback - ServiceWorkerRegistration component with 60-min update checks - InstallPrompt banner with 30-day dismissal memory - Apple Web App meta tags + viewport theme color Performance Monitoring (A15): - Pino structured logging (JSON prod, pretty dev) via LOG_LEVEL env - tRPC logging middleware on all protectedProcedure calls - Request ID (UUID) per call for log correlation - Slow query warnings (>500ms) at warn level - GET /api/perf endpoint: memory, uptime, SSE connections, node version Fix: renamed scenario.apply to scenario.applyScenario (tRPC reserved word) Co-Authored-By: claude-flow <ruv@ruv.net>
563 lines
20 KiB
TypeScript
563 lines
20 KiB
TypeScript
import {
|
|
countPlanningEntries,
|
|
listAssignmentBookings,
|
|
} from "@planarchy/application";
|
|
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@planarchy/shared";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
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, protectedProcedure, requirePermission } from "../trpc.js";
|
|
import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js";
|
|
import { invalidateDashboardCache } from "../lib/cache.js";
|
|
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
|
|
|
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
|
|
|
|
export const projectRouter = createTRPCRouter({
|
|
list: protectedProcedure
|
|
.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: protectedProcedure
|
|
.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,
|
|
};
|
|
}),
|
|
|
|
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("@planarchy/db").Prisma.InputJsonValue,
|
|
dynamicFields: input.dynamicFields as unknown as import("@planarchy/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 },
|
|
},
|
|
});
|
|
|
|
void invalidateDashboardCache();
|
|
void dispatchWebhooks(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("@planarchy/db").Prisma.InputJsonValue } : {}),
|
|
...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@planarchy/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 } : {}),
|
|
} 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 },
|
|
},
|
|
});
|
|
|
|
void invalidateDashboardCache();
|
|
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 },
|
|
});
|
|
void invalidateDashboardCache();
|
|
void dispatchWebhooks(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 } },
|
|
},
|
|
});
|
|
|
|
void invalidateDashboardCache();
|
|
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,
|
|
},
|
|
});
|
|
});
|
|
|
|
void invalidateDashboardCache();
|
|
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,
|
|
},
|
|
});
|
|
});
|
|
|
|
void invalidateDashboardCache();
|
|
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" },
|
|
});
|
|
|
|
if (!isDalleConfigured(settings)) {
|
|
throw new TRPCError({
|
|
code: "PRECONDITION_FAILED",
|
|
message: "DALL-E is not configured. Set up the DALL-E deployment 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;
|
|
|
|
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 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",
|
|
});
|
|
}
|
|
|
|
const 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/'.",
|
|
});
|
|
}
|
|
|
|
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 };
|
|
}),
|
|
|
|
isDalleConfigured: protectedProcedure
|
|
.query(async ({ ctx }) => {
|
|
const settings = await ctx.db.systemSettings.findUnique({
|
|
where: { id: "singleton" },
|
|
});
|
|
return { configured: isDalleConfigured(settings) };
|
|
}),
|
|
});
|