import type { ProjectCustomerOptionDto, ProjectDetailDto, ProjectDocumentOptionDto, ProjectInput, ProjectMilestoneDto, ProjectMilestoneInput, ProjectOwnerOptionDto, ProjectPriority, ProjectRollupDto, ProjectShipmentOptionDto, ProjectStatus, ProjectSummaryDto, WorkOrderStatus, } from "@mrp/shared"; import { logAuditEvent } from "../../lib/audit.js"; import { prisma } from "../../lib/prisma.js"; const projectModel = (prisma as any).project; type ProjectMilestoneRecord = { id: string; title: string; status: string; dueDate: Date | null; completedAt: Date | null; notes: string; sortOrder: number; }; type ProjectRecord = { id: string; projectNumber: string; name: string; status: string; priority: string; dueDate: Date | null; notes: string; createdAt: Date; updatedAt: Date; customer: { id: string; name: string; email: string; phone: string; }; owner: { id: string; firstName: string; lastName: string; } | null; salesQuote: { id: string; documentNumber: string; } | null; salesOrder: { id: string; documentNumber: string; } | null; shipment: { id: string; shipmentNumber: string; } | null; milestones: ProjectMilestoneRecord[]; workOrders: Array<{ id: string; status: string; dueDate: Date | null; }>; }; function getOwnerName(owner: ProjectRecord["owner"]) { return owner ? `${owner.firstName} ${owner.lastName}`.trim() : null; } function mapProjectMilestone(record: ProjectMilestoneRecord): ProjectMilestoneDto { return { id: record.id, title: record.title, status: record.status as ProjectMilestoneDto["status"], dueDate: record.dueDate ? record.dueDate.toISOString() : null, completedAt: record.completedAt ? record.completedAt.toISOString() : null, notes: record.notes, sortOrder: record.sortOrder, }; } function buildProjectRollups(record: ProjectRecord): ProjectRollupDto { const now = Date.now(); const milestoneCount = record.milestones.length; const completedMilestoneCount = record.milestones.filter((milestone) => milestone.status === "COMPLETE").length; const overdueMilestoneCount = record.milestones.filter( (milestone) => milestone.status !== "COMPLETE" && milestone.dueDate && milestone.dueDate.getTime() < now ).length; const workOrderCount = record.workOrders.length; const completedWorkOrderCount = record.workOrders.filter((workOrder) => workOrder.status === "COMPLETE").length; const activeStatuses = new Set(["RELEASED", "IN_PROGRESS", "ON_HOLD"]); const closedStatuses = new Set(["COMPLETE", "CANCELLED"]); const activeWorkOrderCount = record.workOrders.filter((workOrder) => activeStatuses.has(workOrder.status as WorkOrderStatus)).length; const overdueWorkOrderCount = record.workOrders.filter( (workOrder) => !closedStatuses.has(workOrder.status as WorkOrderStatus) && workOrder.dueDate && workOrder.dueDate.getTime() < now ).length; return { milestoneCount, completedMilestoneCount, openMilestoneCount: milestoneCount - completedMilestoneCount, overdueMilestoneCount, workOrderCount, activeWorkOrderCount, completedWorkOrderCount, overdueWorkOrderCount, }; } function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto { return { id: record.id, projectNumber: record.projectNumber, name: record.name, status: record.status as ProjectStatus, priority: record.priority as ProjectPriority, customerId: record.customer.id, customerName: record.customer.name, ownerId: record.owner?.id ?? null, ownerName: getOwnerName(record.owner), dueDate: record.dueDate ? record.dueDate.toISOString() : null, updatedAt: record.updatedAt.toISOString(), rollups: buildProjectRollups(record), }; } function mapProjectDetail(record: ProjectRecord): ProjectDetailDto { return { ...mapProjectSummary(record), notes: record.notes, createdAt: record.createdAt.toISOString(), salesQuoteId: record.salesQuote?.id ?? null, salesQuoteNumber: record.salesQuote?.documentNumber ?? null, salesOrderId: record.salesOrder?.id ?? null, salesOrderNumber: record.salesOrder?.documentNumber ?? null, shipmentId: record.shipment?.id ?? null, shipmentNumber: record.shipment?.shipmentNumber ?? null, customerEmail: record.customer.email, customerPhone: record.customer.phone, milestones: record.milestones.map(mapProjectMilestone), }; } function buildInclude() { return { customer: { select: { id: true, name: true, email: true, phone: true, }, }, owner: { select: { id: true, firstName: true, lastName: true, }, }, salesQuote: { select: { id: true, documentNumber: true, }, }, salesOrder: { select: { id: true, documentNumber: true, }, }, shipment: { select: { id: true, shipmentNumber: true, }, }, milestones: { select: { id: true, title: true, status: true, dueDate: true, completedAt: true, notes: true, sortOrder: true, }, orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], }, workOrders: { select: { id: true, status: true, dueDate: true, }, }, }; } async function nextProjectNumber() { const next = (await projectModel.count()) + 1; return `PRJ-${String(next).padStart(5, "0")}`; } async function validateProjectInput(payload: ProjectInput) { const customer = await prisma.customer.findUnique({ where: { id: payload.customerId }, select: { id: true }, }); if (!customer) { return { ok: false as const, reason: "Customer was not found." }; } if (payload.ownerId) { const owner = await prisma.user.findUnique({ where: { id: payload.ownerId }, select: { id: true, isActive: true }, }); if (!owner?.isActive) { return { ok: false as const, reason: "Project owner was not found." }; } } if (payload.salesQuoteId) { const quote = await prisma.salesQuote.findUnique({ where: { id: payload.salesQuoteId }, select: { id: true, customerId: true }, }); if (!quote) { return { ok: false as const, reason: "Linked quote was not found." }; } if (quote.customerId !== payload.customerId) { return { ok: false as const, reason: "Linked quote must belong to the selected customer." }; } } if (payload.salesOrderId) { const order = await prisma.salesOrder.findUnique({ where: { id: payload.salesOrderId }, select: { id: true, customerId: true }, }); if (!order) { return { ok: false as const, reason: "Linked sales order was not found." }; } if (order.customerId !== payload.customerId) { return { ok: false as const, reason: "Linked sales order must belong to the selected customer." }; } } if (payload.shipmentId) { const shipment = await prisma.shipment.findUnique({ where: { id: payload.shipmentId }, include: { salesOrder: { select: { customerId: true, }, }, }, }); if (!shipment) { return { ok: false as const, reason: "Linked shipment was not found." }; } if (shipment.salesOrder.customerId !== payload.customerId) { return { ok: false as const, reason: "Linked shipment must belong to the selected customer." }; } } return { ok: true as const }; } function normalizeMilestoneInput(milestone: ProjectMilestoneInput, index: number) { const title = milestone.title.trim(); const notes = milestone.notes.trim(); const sortOrder = Number.isFinite(milestone.sortOrder) ? milestone.sortOrder : index * 10; return { id: milestone.id?.trim() || null, title, status: milestone.status, dueDate: milestone.dueDate ? new Date(milestone.dueDate) : null, notes, sortOrder, }; } async function syncProjectMilestones(transaction: any, projectId: string, milestones: ProjectMilestoneInput[]) { const milestoneModel = (transaction as any).projectMilestone; const existingMilestones = await milestoneModel.findMany({ where: { projectId }, select: { id: true, completedAt: true, }, }); const existingById = new Map( existingMilestones.map((milestone: { id: string; completedAt: Date | null }) => [milestone.id, { completedAt: milestone.completedAt }]) ); const normalized = milestones.map(normalizeMilestoneInput); const retainedIds = normalized.flatMap((milestone) => (milestone.id && existingById.has(milestone.id) ? [milestone.id] : [])); await milestoneModel.deleteMany({ where: { projectId, ...(retainedIds.length > 0 ? { id: { notIn: retainedIds } } : {}), }, }); if (retainedIds.length === 0) { await milestoneModel.deleteMany({ where: { projectId } }); } for (const milestone of normalized) { const existing = milestone.id ? existingById.get(milestone.id) : null; const completedAt = milestone.status === "COMPLETE" ? existing?.completedAt ?? new Date() : null; if (milestone.id && existing) { await milestoneModel.update({ where: { id: milestone.id }, data: { title: milestone.title, status: milestone.status, dueDate: milestone.dueDate, completedAt, notes: milestone.notes, sortOrder: milestone.sortOrder, }, }); continue; } await milestoneModel.create({ data: { projectId, title: milestone.title, status: milestone.status, dueDate: milestone.dueDate, completedAt, notes: milestone.notes, sortOrder: milestone.sortOrder, }, }); } } export async function listProjectCustomerOptions(): Promise { const customers = await prisma.customer.findMany({ where: { status: { not: "INACTIVE", }, }, select: { id: true, name: true, email: true, }, orderBy: [{ name: "asc" }], }); return customers; } export async function listProjectOwnerOptions(): Promise { const users = await prisma.user.findMany({ where: { isActive: true, }, select: { id: true, firstName: true, lastName: true, email: true, }, orderBy: [{ firstName: "asc" }, { lastName: "asc" }], }); return users.map((user) => ({ id: user.id, fullName: `${user.firstName} ${user.lastName}`.trim(), email: user.email, })); } export async function listProjectQuoteOptions(customerId?: string | null): Promise { const quotes = await prisma.salesQuote.findMany({ where: { ...(customerId ? { customerId } : {}), }, include: { customer: { select: { name: true, }, }, }, orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }], }); return quotes.map((quote) => ({ id: quote.id, documentNumber: quote.documentNumber, customerName: quote.customer.name, status: quote.status, })); } export async function listProjectOrderOptions(customerId?: string | null): Promise { const orders = await prisma.salesOrder.findMany({ where: { ...(customerId ? { customerId } : {}), }, include: { customer: { select: { name: true, }, }, }, orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }], }); return orders.map((order) => ({ id: order.id, documentNumber: order.documentNumber, customerName: order.customer.name, status: order.status, })); } export async function listProjectShipmentOptions(customerId?: string | null): Promise { const shipments = await prisma.shipment.findMany({ where: { ...(customerId ? { salesOrder: { customerId } } : {}), }, include: { salesOrder: { include: { customer: { select: { name: true, }, }, }, }, }, orderBy: [{ createdAt: "desc" }], }); return shipments.map((shipment) => ({ id: shipment.id, shipmentNumber: shipment.shipmentNumber, salesOrderNumber: shipment.salesOrder.documentNumber, customerName: shipment.salesOrder.customer.name, status: shipment.status, })); } export async function listProjects(filters: { q?: string; status?: ProjectStatus; priority?: ProjectPriority; customerId?: string; ownerId?: string; } = {}) { const query = filters.q?.trim(); const projects = await projectModel.findMany({ where: { ...(filters.status ? { status: filters.status } : {}), ...(filters.priority ? { priority: filters.priority } : {}), ...(filters.customerId ? { customerId: filters.customerId } : {}), ...(filters.ownerId ? { ownerId: filters.ownerId } : {}), ...(query ? { OR: [ { projectNumber: { contains: query } }, { name: { contains: query } }, { customer: { name: { contains: query } } }, ], } : {}), }, include: buildInclude(), orderBy: [{ dueDate: "asc" }, { updatedAt: "desc" }], }); return projects.map((project: unknown) => mapProjectSummary(project as ProjectRecord)); } export async function getProjectById(projectId: string) { const project = await projectModel.findUnique({ where: { id: projectId }, include: buildInclude(), }); return project ? mapProjectDetail(project as ProjectRecord) : null; } export async function createProject(payload: ProjectInput, actorId?: string | null) { const validated = await validateProjectInput(payload); if (!validated.ok) { return { ok: false as const, reason: validated.reason }; } const projectNumber = await nextProjectNumber(); const created = await prisma.$transaction(async (transaction) => { const transactionProjectModel = (transaction as any).project; const createdProject = await transactionProjectModel.create({ data: { projectNumber, name: payload.name.trim(), status: payload.status, priority: payload.priority, customerId: payload.customerId, salesQuoteId: payload.salesQuoteId, salesOrderId: payload.salesOrderId, shipmentId: payload.shipmentId, ownerId: payload.ownerId, dueDate: payload.dueDate ? new Date(payload.dueDate) : null, notes: payload.notes, }, select: { id: true, }, }); await syncProjectMilestones(transaction, createdProject.id, payload.milestones ?? []); return createdProject; }); const project = await getProjectById(created.id); if (project) { await logAuditEvent({ actorId, entityType: "project", entityId: created.id, action: "created", summary: `Created project ${project.projectNumber}.`, metadata: { projectNumber: project.projectNumber, customerId: project.customerId, status: project.status, priority: project.priority, milestoneCount: project.rollups.milestoneCount, }, }); } return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." }; } export async function updateProject(projectId: string, payload: ProjectInput, actorId?: string | null) { const existing = await projectModel.findUnique({ where: { id: projectId }, select: { id: true }, }); if (!existing) { return { ok: false as const, reason: "Project was not found." }; } const validated = await validateProjectInput(payload); if (!validated.ok) { return { ok: false as const, reason: validated.reason }; } await prisma.$transaction(async (transaction) => { const transactionProjectModel = (transaction as any).project; await transactionProjectModel.update({ where: { id: projectId }, data: { name: payload.name.trim(), status: payload.status, priority: payload.priority, customerId: payload.customerId, salesQuoteId: payload.salesQuoteId, salesOrderId: payload.salesOrderId, shipmentId: payload.shipmentId, ownerId: payload.ownerId, dueDate: payload.dueDate ? new Date(payload.dueDate) : null, notes: payload.notes, }, select: { id: true, }, }); await syncProjectMilestones(transaction, projectId, payload.milestones ?? []); }); const project = await getProjectById(projectId); if (project) { await logAuditEvent({ actorId, entityType: "project", entityId: projectId, action: "updated", summary: `Updated project ${project.projectNumber}.`, metadata: { projectNumber: project.projectNumber, customerId: project.customerId, status: project.status, priority: project.priority, milestoneCount: project.rollups.milestoneCount, }, }); } return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." }; }