2026-03-14 16:50:03 -05:00
|
|
|
import { permissions } from "@mrp/shared";
|
2026-03-14 18:58:23 -05:00
|
|
|
import { crmContactEntryTypes, crmContactRoles, crmLifecycleStages, crmRecordStatuses } from "@mrp/shared/dist/crm/types.js";
|
2026-03-14 14:44:40 -05:00
|
|
|
import { Router } from "express";
|
2026-03-14 16:08:29 -05:00
|
|
|
import { z } from "zod";
|
2026-03-14 14:44:40 -05:00
|
|
|
|
2026-03-14 16:08:29 -05:00
|
|
|
import { fail, ok } from "../../lib/http.js";
|
2026-03-14 14:44:40 -05:00
|
|
|
import { requirePermissions } from "../../lib/rbac.js";
|
2026-03-14 16:08:29 -05:00
|
|
|
import {
|
2026-03-14 16:50:03 -05:00
|
|
|
createCustomerContactEntry,
|
2026-03-14 18:46:06 -05:00
|
|
|
createCustomerContact,
|
2026-03-14 16:08:29 -05:00
|
|
|
createCustomer,
|
2026-03-14 16:50:03 -05:00
|
|
|
createVendorContactEntry,
|
2026-03-14 18:46:06 -05:00
|
|
|
createVendorContact,
|
2026-03-14 16:08:29 -05:00
|
|
|
createVendor,
|
|
|
|
|
getCustomerById,
|
|
|
|
|
getVendorById,
|
|
|
|
|
listCustomers,
|
2026-03-14 18:46:06 -05:00
|
|
|
listCustomerHierarchyOptions,
|
2026-03-14 16:08:29 -05:00
|
|
|
listVendors,
|
|
|
|
|
updateCustomer,
|
|
|
|
|
updateVendor,
|
|
|
|
|
} from "./service.js";
|
|
|
|
|
|
|
|
|
|
const crmRecordSchema = z.object({
|
|
|
|
|
name: z.string().trim().min(1),
|
|
|
|
|
email: z.string().trim().email(),
|
|
|
|
|
phone: z.string().trim().min(1),
|
|
|
|
|
addressLine1: z.string().trim().min(1),
|
|
|
|
|
addressLine2: z.string(),
|
|
|
|
|
city: z.string().trim().min(1),
|
|
|
|
|
state: z.string().trim().min(1),
|
|
|
|
|
postalCode: z.string().trim().min(1),
|
|
|
|
|
country: z.string().trim().min(1),
|
|
|
|
|
status: z.enum(crmRecordStatuses),
|
2026-03-14 18:58:23 -05:00
|
|
|
lifecycleStage: z.enum(crmLifecycleStages).optional(),
|
2026-03-14 16:08:29 -05:00
|
|
|
notes: z.string(),
|
2026-03-14 18:46:06 -05:00
|
|
|
isReseller: z.boolean().optional(),
|
|
|
|
|
resellerDiscountPercent: z.number().min(0).max(100).nullable().optional(),
|
|
|
|
|
parentCustomerId: z.string().nullable().optional(),
|
|
|
|
|
paymentTerms: z.string().nullable().optional(),
|
|
|
|
|
currencyCode: z.string().max(8).nullable().optional(),
|
|
|
|
|
taxExempt: z.boolean().optional(),
|
|
|
|
|
creditHold: z.boolean().optional(),
|
2026-03-14 18:58:23 -05:00
|
|
|
preferredAccount: z.boolean().optional(),
|
|
|
|
|
strategicAccount: z.boolean().optional(),
|
|
|
|
|
requiresApproval: z.boolean().optional(),
|
|
|
|
|
blockedAccount: z.boolean().optional(),
|
2026-03-14 16:08:29 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const crmListQuerySchema = z.object({
|
|
|
|
|
q: z.string().optional(),
|
|
|
|
|
state: z.string().optional(),
|
|
|
|
|
status: z.enum(crmRecordStatuses).optional(),
|
2026-03-14 18:58:23 -05:00
|
|
|
lifecycleStage: z.enum(crmLifecycleStages).optional(),
|
|
|
|
|
flag: z.enum(["PREFERRED", "STRATEGIC", "REQUIRES_APPROVAL", "BLOCKED"]).optional(),
|
2026-03-14 16:08:29 -05:00
|
|
|
});
|
|
|
|
|
|
2026-03-14 16:50:03 -05:00
|
|
|
const crmContactEntrySchema = z.object({
|
|
|
|
|
type: z.enum(crmContactEntryTypes),
|
|
|
|
|
summary: z.string().trim().min(1).max(160),
|
|
|
|
|
body: z.string().trim().min(1).max(4000),
|
|
|
|
|
contactAt: z.string().datetime(),
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-14 18:46:06 -05:00
|
|
|
const crmContactSchema = z.object({
|
|
|
|
|
fullName: z.string().trim().min(1).max(160),
|
|
|
|
|
role: z.enum(crmContactRoles),
|
|
|
|
|
email: z.string().trim().email(),
|
|
|
|
|
phone: z.string().trim().min(1).max(64),
|
|
|
|
|
isPrimary: z.boolean(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function getRouteParam(value: unknown) {
|
2026-03-14 16:08:29 -05:00
|
|
|
return typeof value === "string" ? value : null;
|
|
|
|
|
}
|
2026-03-14 14:44:40 -05:00
|
|
|
|
|
|
|
|
export const crmRouter = Router();
|
|
|
|
|
|
|
|
|
|
crmRouter.get("/customers", requirePermissions([permissions.crmRead]), async (_request, response) => {
|
2026-03-14 16:08:29 -05:00
|
|
|
const parsed = crmListQuerySchema.safeParse(_request.query);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "CRM filters are invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(
|
|
|
|
|
response,
|
|
|
|
|
await listCustomers({
|
|
|
|
|
query: parsed.data.q,
|
|
|
|
|
status: parsed.data.status,
|
|
|
|
|
state: parsed.data.state,
|
2026-03-14 18:58:23 -05:00
|
|
|
lifecycleStage: parsed.data.lifecycleStage,
|
|
|
|
|
flag: parsed.data.flag,
|
2026-03-14 16:08:29 -05:00
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-14 18:46:06 -05:00
|
|
|
crmRouter.get("/customers/hierarchy-options", requirePermissions([permissions.crmRead]), async (request, response) => {
|
|
|
|
|
const excludeCustomerId = getRouteParam(request.query.excludeCustomerId);
|
|
|
|
|
return ok(response, await listCustomerHierarchyOptions(excludeCustomerId ?? undefined));
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-14 16:08:29 -05:00
|
|
|
crmRouter.get("/customers/:customerId", requirePermissions([permissions.crmRead]), async (request, response) => {
|
|
|
|
|
const customerId = getRouteParam(request.params.customerId);
|
|
|
|
|
if (!customerId) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Customer id is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const customer = await getCustomerById(customerId);
|
|
|
|
|
if (!customer) {
|
|
|
|
|
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, customer);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
crmRouter.post("/customers", requirePermissions([permissions.crmWrite]), async (request, response) => {
|
|
|
|
|
const parsed = crmRecordSchema.safeParse(request.body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:57:41 -05:00
|
|
|
const customer = await createCustomer(parsed.data, request.authUser?.id);
|
2026-03-14 18:46:06 -05:00
|
|
|
if (!customer) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, customer, 201);
|
2026-03-14 16:08:29 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
crmRouter.put("/customers/:customerId", requirePermissions([permissions.crmWrite]), async (request, response) => {
|
|
|
|
|
const customerId = getRouteParam(request.params.customerId);
|
|
|
|
|
if (!customerId) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Customer id is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = crmRecordSchema.safeParse(request.body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 18:46:06 -05:00
|
|
|
const existingCustomer = await getCustomerById(customerId);
|
|
|
|
|
if (!existingCustomer) {
|
|
|
|
|
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:57:41 -05:00
|
|
|
const customer = await updateCustomer(customerId, parsed.data, request.authUser?.id);
|
2026-03-14 16:08:29 -05:00
|
|
|
if (!customer) {
|
2026-03-14 18:46:06 -05:00
|
|
|
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid.");
|
2026-03-14 16:08:29 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, customer);
|
2026-03-14 14:44:40 -05:00
|
|
|
});
|
|
|
|
|
|
2026-03-14 16:50:03 -05:00
|
|
|
crmRouter.post("/customers/:customerId/contact-history", requirePermissions([permissions.crmWrite]), async (request, response) => {
|
|
|
|
|
const customerId = getRouteParam(request.params.customerId);
|
|
|
|
|
if (!customerId) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Customer id is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = crmContactEntrySchema.safeParse(request.body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Contact history entry is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const entry = await createCustomerContactEntry(customerId, parsed.data, request.authUser?.id);
|
|
|
|
|
if (!entry) {
|
|
|
|
|
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, entry, 201);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-14 18:46:06 -05:00
|
|
|
crmRouter.post("/customers/:customerId/contacts", requirePermissions([permissions.crmWrite]), async (request, response) => {
|
|
|
|
|
const customerId = getRouteParam(request.params.customerId);
|
|
|
|
|
if (!customerId) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Customer id is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = crmContactSchema.safeParse(request.body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "CRM contact is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:57:41 -05:00
|
|
|
const contact = await createCustomerContact(customerId, parsed.data, request.authUser?.id);
|
2026-03-14 18:46:06 -05:00
|
|
|
if (!contact) {
|
|
|
|
|
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, contact, 201);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-14 14:44:40 -05:00
|
|
|
crmRouter.get("/vendors", requirePermissions([permissions.crmRead]), async (_request, response) => {
|
2026-03-14 16:08:29 -05:00
|
|
|
const parsed = crmListQuerySchema.safeParse(_request.query);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "CRM filters are invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(
|
|
|
|
|
response,
|
|
|
|
|
await listVendors({
|
|
|
|
|
query: parsed.data.q,
|
|
|
|
|
status: parsed.data.status,
|
|
|
|
|
state: parsed.data.state,
|
2026-03-14 18:58:23 -05:00
|
|
|
lifecycleStage: parsed.data.lifecycleStage,
|
|
|
|
|
flag: parsed.data.flag,
|
2026-03-14 16:08:29 -05:00
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
crmRouter.get("/vendors/:vendorId", requirePermissions([permissions.crmRead]), async (request, response) => {
|
|
|
|
|
const vendorId = getRouteParam(request.params.vendorId);
|
|
|
|
|
if (!vendorId) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Vendor id is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const vendor = await getVendorById(vendorId);
|
|
|
|
|
if (!vendor) {
|
|
|
|
|
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, vendor);
|
2026-03-14 14:44:40 -05:00
|
|
|
});
|
|
|
|
|
|
2026-03-14 16:08:29 -05:00
|
|
|
crmRouter.post("/vendors", requirePermissions([permissions.crmWrite]), async (request, response) => {
|
|
|
|
|
const parsed = crmRecordSchema.safeParse(request.body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:57:41 -05:00
|
|
|
return ok(response, await createVendor(parsed.data, request.authUser?.id), 201);
|
2026-03-14 16:08:29 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
crmRouter.put("/vendors/:vendorId", requirePermissions([permissions.crmWrite]), async (request, response) => {
|
|
|
|
|
const vendorId = getRouteParam(request.params.vendorId);
|
|
|
|
|
if (!vendorId) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Vendor id is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = crmRecordSchema.safeParse(request.body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:57:41 -05:00
|
|
|
const vendor = await updateVendor(vendorId, parsed.data, request.authUser?.id);
|
2026-03-14 16:08:29 -05:00
|
|
|
if (!vendor) {
|
|
|
|
|
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, vendor);
|
|
|
|
|
});
|
2026-03-14 16:50:03 -05:00
|
|
|
|
|
|
|
|
crmRouter.post("/vendors/:vendorId/contact-history", requirePermissions([permissions.crmWrite]), async (request, response) => {
|
|
|
|
|
const vendorId = getRouteParam(request.params.vendorId);
|
|
|
|
|
if (!vendorId) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Vendor id is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = crmContactEntrySchema.safeParse(request.body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Contact history entry is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const entry = await createVendorContactEntry(vendorId, parsed.data, request.authUser?.id);
|
|
|
|
|
if (!entry) {
|
|
|
|
|
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, entry, 201);
|
|
|
|
|
});
|
2026-03-14 18:46:06 -05:00
|
|
|
|
|
|
|
|
crmRouter.post("/vendors/:vendorId/contacts", requirePermissions([permissions.crmWrite]), async (request, response) => {
|
|
|
|
|
const vendorId = getRouteParam(request.params.vendorId);
|
|
|
|
|
if (!vendorId) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "Vendor id is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = crmContactSchema.safeParse(request.body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return fail(response, 400, "INVALID_INPUT", "CRM contact is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:57:41 -05:00
|
|
|
const contact = await createVendorContact(vendorId, parsed.data, request.authUser?.id);
|
2026-03-14 18:46:06 -05:00
|
|
|
if (!contact) {
|
|
|
|
|
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ok(response, contact, 201);
|
|
|
|
|
});
|