This commit is contained in:
2026-03-14 18:46:06 -05:00
parent f1fd2ed979
commit c0cc546e33
15 changed files with 979 additions and 27 deletions

View File

@@ -1,5 +1,5 @@
import { permissions } from "@mrp/shared";
import { crmContactEntryTypes, crmRecordStatuses } from "@mrp/shared/dist/crm/types.js";
import { crmContactEntryTypes, crmContactRoles, crmRecordStatuses } from "@mrp/shared/dist/crm/types.js";
import { Router } from "express";
import { z } from "zod";
@@ -7,12 +7,15 @@ import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createCustomerContactEntry,
createCustomerContact,
createCustomer,
createVendorContactEntry,
createVendorContact,
createVendor,
getCustomerById,
getVendorById,
listCustomers,
listCustomerHierarchyOptions,
listVendors,
updateCustomer,
updateVendor,
@@ -30,6 +33,13 @@ const crmRecordSchema = z.object({
country: z.string().trim().min(1),
status: z.enum(crmRecordStatuses),
notes: z.string(),
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(),
});
const crmListQuerySchema = z.object({
@@ -45,7 +55,15 @@ const crmContactEntrySchema = z.object({
contactAt: z.string().datetime(),
});
function getRouteParam(value: string | string[] | undefined) {
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) {
return typeof value === "string" ? value : null;
}
@@ -67,6 +85,11 @@ crmRouter.get("/customers", requirePermissions([permissions.crmRead]), async (_r
);
});
crmRouter.get("/customers/hierarchy-options", requirePermissions([permissions.crmRead]), async (request, response) => {
const excludeCustomerId = getRouteParam(request.query.excludeCustomerId);
return ok(response, await listCustomerHierarchyOptions(excludeCustomerId ?? undefined));
});
crmRouter.get("/customers/:customerId", requirePermissions([permissions.crmRead]), async (request, response) => {
const customerId = getRouteParam(request.params.customerId);
if (!customerId) {
@@ -87,7 +110,12 @@ crmRouter.post("/customers", requirePermissions([permissions.crmWrite]), async (
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
}
return ok(response, await createCustomer(parsed.data), 201);
const customer = await createCustomer(parsed.data);
if (!customer) {
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid.");
}
return ok(response, customer, 201);
});
crmRouter.put("/customers/:customerId", requirePermissions([permissions.crmWrite]), async (request, response) => {
@@ -101,9 +129,14 @@ crmRouter.put("/customers/:customerId", requirePermissions([permissions.crmWrite
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
}
const existingCustomer = await getCustomerById(customerId);
if (!existingCustomer) {
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
}
const customer = await updateCustomer(customerId, parsed.data);
if (!customer) {
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid.");
}
return ok(response, customer);
@@ -128,6 +161,25 @@ crmRouter.post("/customers/:customerId/contact-history", requirePermissions([per
return ok(response, entry, 201);
});
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.");
}
const contact = await createCustomerContact(customerId, parsed.data);
if (!contact) {
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
}
return ok(response, contact, 201);
});
crmRouter.get("/vendors", requirePermissions([permissions.crmRead]), async (_request, response) => {
const parsed = crmListQuerySchema.safeParse(_request.query);
if (!parsed.success) {
@@ -204,3 +256,22 @@ crmRouter.post("/vendors/:vendorId/contact-history", requirePermissions([permiss
return ok(response, entry, 201);
});
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.");
}
const contact = await createVendorContact(vendorId, parsed.data);
if (!contact) {
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
}
return ok(response, contact, 201);
});

View File

@@ -1,7 +1,11 @@
import type {
CrmContactDto,
CrmContactInput,
CrmContactRole,
CrmContactEntryDto,
CrmContactEntryInput,
CrmContactEntryType,
CrmCustomerChildDto,
CrmRecordDetailDto,
CrmRecordInput,
CrmRecordStatus,
@@ -37,6 +41,88 @@ function mapDetail(record: Customer | Vendor): CrmRecordDetailDto {
};
}
type CustomerSummaryRecord = Customer & {
parentCustomer: Pick<Customer, "id" | "name"> | null;
};
type CustomerDetailedRecord = Customer & {
parentCustomer: Pick<Customer, "id" | "name"> | null;
childCustomers: Pick<Customer, "id" | "name" | "status">[];
contactEntries: ContactEntryWithAuthor[];
contacts: ContactRecord[];
};
type VendorDetailedRecord = Vendor & {
contactEntries: ContactEntryWithAuthor[];
contacts: ContactRecord[];
};
type ContactRecord = {
id: string;
fullName: string;
role: string;
email: string;
phone: string;
isPrimary: boolean;
createdAt: Date;
};
function mapCustomerChild(record: Pick<Customer, "id" | "name" | "status">): CrmCustomerChildDto {
return {
id: record.id,
name: record.name,
status: record.status as CrmRecordStatus,
};
}
function mapCustomerSummary(record: CustomerSummaryRecord): CrmRecordSummaryDto {
return {
...mapSummary(record),
isReseller: record.isReseller,
parentCustomerId: record.parentCustomer?.id ?? null,
parentCustomerName: record.parentCustomer?.name ?? null,
};
}
function mapCustomerDetail(record: CustomerDetailedRecord): CrmRecordDetailDto {
return {
...mapDetailedRecord(record),
isReseller: record.isReseller,
resellerDiscountPercent: record.resellerDiscountPercent,
parentCustomerId: record.parentCustomer?.id ?? null,
parentCustomerName: record.parentCustomer?.name ?? null,
childCustomers: record.childCustomers.map(mapCustomerChild),
paymentTerms: record.paymentTerms,
currencyCode: record.currencyCode,
taxExempt: record.taxExempt,
creditHold: record.creditHold,
contacts: record.contacts.map(mapCrmContact),
};
}
function mapCrmContact(record: ContactRecord): CrmContactDto {
return {
id: record.id,
fullName: record.fullName,
role: record.role as CrmContactRole,
email: record.email,
phone: record.phone,
isPrimary: record.isPrimary,
createdAt: record.createdAt.toISOString(),
};
}
function mapVendorDetail(record: VendorDetailedRecord): CrmRecordDetailDto {
return {
...mapDetailedRecord(record),
paymentTerms: record.paymentTerms,
currencyCode: record.currencyCode,
taxExempt: record.taxExempt,
creditHold: record.creditHold,
contacts: record.contacts.map(mapCrmContact),
};
}
type ContactEntryWithAuthor = {
id: string;
type: string;
@@ -120,16 +206,43 @@ function buildWhereClause(filters: CrmListFilters) {
export async function listCustomers(filters: CrmListFilters = {}) {
const customers = await prisma.customer.findMany({
where: buildWhereClause(filters),
include: {
parentCustomer: {
select: {
id: true,
name: true,
},
},
},
orderBy: { name: "asc" },
});
return customers.map(mapSummary);
return customers.map(mapCustomerSummary);
}
export async function getCustomerById(customerId: string) {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
include: {
parentCustomer: {
select: {
id: true,
name: true,
},
},
childCustomers: {
select: {
id: true,
name: true,
status: true,
},
orderBy: {
name: "asc",
},
},
contacts: {
orderBy: [{ isPrimary: "desc" }, { fullName: "asc" }],
},
contactEntries: {
include: {
createdBy: true,
@@ -139,15 +252,56 @@ export async function getCustomerById(customerId: string) {
},
});
return customer ? mapDetailedRecord(customer) : null;
return customer ? mapCustomerDetail(customer) : null;
}
export async function createCustomer(payload: CrmRecordInput) {
if (payload.parentCustomerId) {
const parentCustomer = await prisma.customer.findUnique({
where: { id: payload.parentCustomerId },
});
if (!parentCustomer) {
return null;
}
}
const customer = await prisma.customer.create({
data: payload,
data: {
name: payload.name,
email: payload.email,
phone: payload.phone,
addressLine1: payload.addressLine1,
addressLine2: payload.addressLine2,
city: payload.city,
state: payload.state,
postalCode: payload.postalCode,
country: payload.country,
status: payload.status,
notes: payload.notes,
isReseller: payload.isReseller ?? false,
resellerDiscountPercent: payload.resellerDiscountPercent ?? 0,
parentCustomerId: payload.parentCustomerId ?? null,
paymentTerms: payload.paymentTerms ?? null,
currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false,
},
});
return mapDetail(customer);
return {
...mapDetail(customer),
isReseller: customer.isReseller,
resellerDiscountPercent: customer.resellerDiscountPercent,
parentCustomerId: customer.parentCustomerId,
parentCustomerName: null,
childCustomers: [],
paymentTerms: customer.paymentTerms,
currencyCode: customer.currencyCode,
taxExempt: customer.taxExempt,
creditHold: customer.creditHold,
contacts: [],
};
}
export async function updateCustomer(customerId: string, payload: CrmRecordInput) {
@@ -159,12 +313,57 @@ export async function updateCustomer(customerId: string, payload: CrmRecordInput
return null;
}
if (payload.parentCustomerId === customerId) {
return null;
}
if (payload.parentCustomerId) {
const parentCustomer = await prisma.customer.findUnique({
where: { id: payload.parentCustomerId },
});
if (!parentCustomer) {
return null;
}
}
const customer = await prisma.customer.update({
where: { id: customerId },
data: payload,
data: {
name: payload.name,
email: payload.email,
phone: payload.phone,
addressLine1: payload.addressLine1,
addressLine2: payload.addressLine2,
city: payload.city,
state: payload.state,
postalCode: payload.postalCode,
country: payload.country,
status: payload.status,
notes: payload.notes,
isReseller: payload.isReseller ?? false,
resellerDiscountPercent: payload.resellerDiscountPercent ?? 0,
parentCustomerId: payload.parentCustomerId ?? null,
paymentTerms: payload.paymentTerms ?? null,
currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false,
},
});
return mapDetail(customer);
return {
...mapDetail(customer),
isReseller: customer.isReseller,
resellerDiscountPercent: customer.resellerDiscountPercent,
parentCustomerId: customer.parentCustomerId,
parentCustomerName: null,
childCustomers: [],
paymentTerms: customer.paymentTerms,
currencyCode: customer.currencyCode,
taxExempt: customer.taxExempt,
creditHold: customer.creditHold,
contacts: [],
};
}
export async function listVendors(filters: CrmListFilters = {}) {
@@ -176,10 +375,44 @@ export async function listVendors(filters: CrmListFilters = {}) {
return vendors.map(mapSummary);
}
export async function listCustomerHierarchyOptions(excludeCustomerId?: string) {
const customers = await prisma.customer.findMany({
where: excludeCustomerId
? {
isReseller: true,
id: {
not: excludeCustomerId,
},
}
: {
isReseller: true,
},
orderBy: {
name: "asc",
},
select: {
id: true,
name: true,
status: true,
isReseller: true,
},
});
return customers.map((customer) => ({
id: customer.id,
name: customer.name,
status: customer.status as CrmRecordStatus,
isReseller: customer.isReseller,
}));
}
export async function getVendorById(vendorId: string) {
const vendor = await prisma.vendor.findUnique({
where: { id: vendorId },
include: {
contacts: {
orderBy: [{ isPrimary: "desc" }, { fullName: "asc" }],
},
contactEntries: {
include: {
createdBy: true,
@@ -189,15 +422,38 @@ export async function getVendorById(vendorId: string) {
},
});
return vendor ? mapDetailedRecord(vendor) : null;
return vendor ? mapVendorDetail(vendor) : null;
}
export async function createVendor(payload: CrmRecordInput) {
const vendor = await prisma.vendor.create({
data: payload,
data: {
name: payload.name,
email: payload.email,
phone: payload.phone,
addressLine1: payload.addressLine1,
addressLine2: payload.addressLine2,
city: payload.city,
state: payload.state,
postalCode: payload.postalCode,
country: payload.country,
status: payload.status,
notes: payload.notes,
paymentTerms: payload.paymentTerms ?? null,
currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false,
},
});
return mapDetail(vendor);
return {
...mapDetail(vendor),
paymentTerms: vendor.paymentTerms,
currencyCode: vendor.currencyCode,
taxExempt: vendor.taxExempt,
creditHold: vendor.creditHold,
contacts: [],
};
}
export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
@@ -211,10 +467,33 @@ export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
const vendor = await prisma.vendor.update({
where: { id: vendorId },
data: payload,
data: {
name: payload.name,
email: payload.email,
phone: payload.phone,
addressLine1: payload.addressLine1,
addressLine2: payload.addressLine2,
city: payload.city,
state: payload.state,
postalCode: payload.postalCode,
country: payload.country,
status: payload.status,
notes: payload.notes,
paymentTerms: payload.paymentTerms ?? null,
currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false,
},
});
return mapDetail(vendor);
return {
...mapDetail(vendor),
paymentTerms: vendor.paymentTerms,
currencyCode: vendor.currencyCode,
taxExempt: vendor.taxExempt,
creditHold: vendor.creditHold,
contacts: [],
};
}
export async function createCustomerContactEntry(customerId: string, payload: CrmContactEntryInput, createdById?: string) {
@@ -268,3 +547,63 @@ export async function createVendorContactEntry(vendorId: string, payload: CrmCon
return mapContactEntry(entry);
}
export async function createCustomerContact(customerId: string, payload: CrmContactInput) {
const existingCustomer = await prisma.customer.findUnique({
where: { id: customerId },
});
if (!existingCustomer) {
return null;
}
if (payload.isPrimary) {
await prisma.crmContact.updateMany({
where: { customerId, isPrimary: true },
data: { isPrimary: false },
});
}
const contact = await prisma.crmContact.create({
data: {
fullName: payload.fullName,
role: payload.role,
email: payload.email,
phone: payload.phone,
isPrimary: payload.isPrimary,
customerId,
},
});
return mapCrmContact(contact);
}
export async function createVendorContact(vendorId: string, payload: CrmContactInput) {
const existingVendor = await prisma.vendor.findUnique({
where: { id: vendorId },
});
if (!existingVendor) {
return null;
}
if (payload.isPrimary) {
await prisma.crmContact.updateMany({
where: { vendorId, isPrimary: true },
data: { isPrimary: false },
});
}
const contact = await prisma.crmContact.create({
data: {
fullName: payload.fullName,
role: payload.role,
email: payload.email,
phone: payload.phone,
isPrimary: payload.isPrimary,
vendorId,
},
});
return mapCrmContact(contact);
}