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

@@ -0,0 +1,7 @@
ALTER TABLE "Customer" ADD COLUMN "isReseller" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Customer" ADD COLUMN "resellerDiscountPercent" REAL NOT NULL DEFAULT 0;
ALTER TABLE "Customer" ADD COLUMN "parentCustomerId" TEXT REFERENCES "Customer" ("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "Customer_parentCustomerId_idx" ON "Customer"("parentCustomerId");

View File

@@ -0,0 +1,27 @@
ALTER TABLE "Customer" ADD COLUMN "paymentTerms" TEXT;
ALTER TABLE "Customer" ADD COLUMN "currencyCode" TEXT DEFAULT 'USD';
ALTER TABLE "Customer" ADD COLUMN "taxExempt" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Customer" ADD COLUMN "creditHold" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "paymentTerms" TEXT;
ALTER TABLE "Vendor" ADD COLUMN "currencyCode" TEXT DEFAULT 'USD';
ALTER TABLE "Vendor" ADD COLUMN "taxExempt" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "creditHold" BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE "CrmContact" (
"id" TEXT NOT NULL PRIMARY KEY,
"fullName" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'OTHER',
"email" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"isPrimary" BOOLEAN NOT NULL DEFAULT false,
"customerId" TEXT,
"vendorId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "CrmContact_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CrmContact_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "CrmContact_customerId_idx" ON "CrmContact"("customerId");
CREATE INDEX "CrmContact_vendorId_idx" ON "CrmContact"("vendorId");

View File

@@ -113,10 +113,20 @@ model Customer {
postalCode String
country String
status String @default("ACTIVE")
isReseller Boolean @default(false)
resellerDiscountPercent Float @default(0)
parentCustomerId String?
paymentTerms String?
currencyCode String? @default("USD")
taxExempt Boolean @default(false)
creditHold Boolean @default(false)
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contactEntries CrmContactEntry[]
contacts CrmContact[]
parentCustomer Customer? @relation("CustomerHierarchy", fields: [parentCustomerId], references: [id], onDelete: SetNull)
childCustomers Customer[] @relation("CustomerHierarchy")
}
model Vendor {
@@ -131,10 +141,15 @@ model Vendor {
postalCode String
country String
status String @default("ACTIVE")
paymentTerms String?
currencyCode String? @default("USD")
taxExempt Boolean @default(false)
creditHold Boolean @default(false)
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contactEntries CrmContactEntry[]
contacts CrmContact[]
}
model CrmContactEntry {
@@ -152,3 +167,18 @@ model CrmContactEntry {
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
}
model CrmContact {
id String @id @default(cuid())
fullName String
role String @default("OTHER")
email String
phone String
isPrimary Boolean @default(false)
customerId String?
vendorId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer? @relation(fields: [customerId], references: [id], onDelete: Cascade)
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: Cascade)
}

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);
}