2026-03-17 07:34:08 -05:00
|
|
|
import { permissions, projectMilestoneStatuses, projectPriorities, projectStatuses } from "@mrp/shared";
|
2026-03-15 10:13:53 -05:00
|
|
|
import { Router } from "express";
|
|
|
|
|
import { z } from "zod";
|
|
|
|
|
|
|
|
|
|
import { fail, ok } from "../../lib/http.js";
|
|
|
|
|
import { requirePermissions } from "../../lib/rbac.js";
|
|
|
|
|
import {
|
|
|
|
|
createProject,
|
|
|
|
|
getProjectById,
|
|
|
|
|
listProjectCustomerOptions,
|
|
|
|
|
listProjectOrderOptions,
|
|
|
|
|
listProjectOwnerOptions,
|
|
|
|
|
listProjects,
|
|
|
|
|
listProjectQuoteOptions,
|
|
|
|
|
listProjectShipmentOptions,
|
|
|
|
|
updateProject,
|
2026-03-18 12:05:28 -05:00
|
|
|
updateProjectMilestoneStatus,
|
2026-03-15 10:13:53 -05:00
|
|
|
} from "./service.js";
|
|
|
|
|
|
|
|
|
|
const projectSchema = z.object({
|
|
|
|
|
name: z.string().trim().min(1).max(160),
|
|
|
|
|
status: z.enum(projectStatuses),
|
|
|
|
|
priority: z.enum(projectPriorities),
|
|
|
|
|
customerId: z.string().trim().min(1),
|
|
|
|
|
salesQuoteId: z.string().trim().min(1).nullable(),
|
|
|
|
|
salesOrderId: z.string().trim().min(1).nullable(),
|
|
|
|
|
shipmentId: z.string().trim().min(1).nullable(),
|
|
|
|
|
ownerId: z.string().trim().min(1).nullable(),
|
|
|
|
|
dueDate: z.string().datetime().nullable(),
|
|
|
|
|
notes: z.string(),
|
2026-03-17 07:34:08 -05:00
|
|
|
milestones: z.array(
|
|
|
|
|
z.object({
|
|
|
|
|
id: z.string().trim().min(1).nullable().optional(),
|
|
|
|
|
title: z.string().trim().min(1).max(160),
|
|
|
|
|
status: z.enum(projectMilestoneStatuses),
|
|
|
|
|
dueDate: z.string().datetime().nullable(),
|
|
|
|
|
notes: z.string(),
|
|
|
|
|
sortOrder: z.number().int(),
|
|
|
|
|
})
|
|
|
|
|
),
|
2026-03-15 10:13:53 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const projectListQuerySchema = z.object({
|
|
|
|
|
q: z.string().optional(),
|
|
|
|
|
status: z.enum(projectStatuses).optional(),
|
|
|
|
|
priority: z.enum(projectPriorities).optional(),
|
|
|
|
|
customerId: z.string().optional(),
|
|
|
|
|
ownerId: z.string().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const projectOptionQuerySchema = z.object({
|
|
|
|
|
customerId: z.string().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-18 12:05:28 -05:00
|
|
|
const milestoneStatusSchema = z.object({
|
|
|
|
|
status: z.enum(projectMilestoneStatuses),
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-15 10:13:53 -05:00
|
|
|
function getRouteParam(value: unknown) {
|
|
|
|
|
return typeof value === "string" ? value : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const projectsRouter = Router();
|
|
|
|
|
|
|
|
|
|
projectsRouter.get("/customers/options", requirePermissions([permissions.projectsRead]), async (_request, response) => {
|
|
|
|
|
return ok(response, await listProjectCustomerOptions());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
projectsRouter.get("/owners/options", requirePermissions([permissions.projectsRead]), async (_request, response) => {
|
|
|
|
|
return ok(response, await listProjectOwnerOptions());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
projectsRouter.get("/quotes/options", requirePermissions([permissions.projectsRead]), async (request, response) => {
|
|
|
|
|
const parsed = projectOptionQuerySchema.safeParse(request.query);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Project quote filters are invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, await listProjectQuoteOptions(parsed.data.customerId));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
projectsRouter.get("/orders/options", requirePermissions([permissions.projectsRead]), async (request, response) => {
|
|
|
|
|
const parsed = projectOptionQuerySchema.safeParse(request.query);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Project order filters are invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, await listProjectOrderOptions(parsed.data.customerId));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
projectsRouter.get("/shipments/options", requirePermissions([permissions.projectsRead]), async (request, response) => {
|
|
|
|
|
const parsed = projectOptionQuerySchema.safeParse(request.query);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Project shipment filters are invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, await listProjectShipmentOptions(parsed.data.customerId));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
projectsRouter.get("/", requirePermissions([permissions.projectsRead]), async (request, response) => {
|
|
|
|
|
const parsed = projectListQuerySchema.safeParse(request.query);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Project filters are invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, await listProjects(parsed.data));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
projectsRouter.get("/:projectId", requirePermissions([permissions.projectsRead]), async (request, response) => {
|
|
|
|
|
const projectId = getRouteParam(request.params.projectId);
|
|
|
|
|
if (!projectId) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Project id is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const project = await getProjectById(projectId);
|
|
|
|
|
if (!project) {
|
|
|
|
|
return fail(response, 404, "PROJECT_NOT_FOUND", "Project was not found.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, project);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
projectsRouter.post("/", requirePermissions([permissions.projectsWrite]), async (request, response) => {
|
|
|
|
|
const parsed = projectSchema.safeParse(request.body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Project payload is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:11:21 -05:00
|
|
|
const result = await createProject(parsed.data, request.authUser?.id);
|
2026-03-15 10:13:53 -05:00
|
|
|
if (!result.ok) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", result.reason);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, result.project, 201);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
projectsRouter.put("/:projectId", requirePermissions([permissions.projectsWrite]), async (request, response) => {
|
|
|
|
|
const projectId = getRouteParam(request.params.projectId);
|
|
|
|
|
if (!projectId) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Project id is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = projectSchema.safeParse(request.body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Project payload is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:11:21 -05:00
|
|
|
const result = await updateProject(projectId, parsed.data, request.authUser?.id);
|
2026-03-15 10:13:53 -05:00
|
|
|
if (!result.ok) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", result.reason);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, result.project);
|
|
|
|
|
});
|
2026-03-18 12:05:28 -05:00
|
|
|
|
|
|
|
|
projectsRouter.patch("/:projectId/milestones/:milestoneId/status", requirePermissions([permissions.projectsWrite]), async (request, response) => {
|
|
|
|
|
const projectId = getRouteParam(request.params.projectId);
|
|
|
|
|
const milestoneId = getRouteParam(request.params.milestoneId);
|
|
|
|
|
if (!projectId || !milestoneId) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Project or milestone id is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = milestoneStatusSchema.safeParse(request.body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Project milestone status payload is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await updateProjectMilestoneStatus(projectId, milestoneId, parsed.data, request.authUser?.id);
|
|
|
|
|
if (!result.ok) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", result.reason);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, result.project);
|
|
|
|
|
});
|