This commit is contained in:
2026-03-14 16:50:03 -05:00
parent a8d0533f4a
commit 70f55c98b5
12 changed files with 477 additions and 11 deletions

View File

@@ -1,11 +1,14 @@
import { crmRecordStatuses, permissions } from "@mrp/shared";
import { permissions } from "@mrp/shared";
import { crmContactEntryTypes, crmRecordStatuses } from "@mrp/shared/dist/crm/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createCustomerContactEntry,
createCustomer,
createVendorContactEntry,
createVendor,
getCustomerById,
getVendorById,
@@ -35,6 +38,13 @@ const crmListQuerySchema = z.object({
status: z.enum(crmRecordStatuses).optional(),
});
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(),
});
function getRouteParam(value: string | string[] | undefined) {
return typeof value === "string" ? value : null;
}
@@ -99,6 +109,25 @@ crmRouter.put("/customers/:customerId", requirePermissions([permissions.crmWrite
return ok(response, customer);
});
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);
});
crmRouter.get("/vendors", requirePermissions([permissions.crmRead]), async (_request, response) => {
const parsed = crmListQuerySchema.safeParse(_request.query);
if (!parsed.success) {
@@ -156,3 +185,22 @@ crmRouter.put("/vendors/:vendorId", requirePermissions([permissions.crmWrite]),
return ok(response, vendor);
});
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);
});

View File

@@ -1,4 +1,7 @@
import type {
CrmContactEntryDto,
CrmContactEntryInput,
CrmContactEntryType,
CrmRecordDetailDto,
CrmRecordInput,
CrmRecordStatus,
@@ -30,6 +33,58 @@ function mapDetail(record: Customer | Vendor): CrmRecordDetailDto {
postalCode: record.postalCode,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
contactHistory: [],
};
}
type ContactEntryWithAuthor = {
id: string;
type: string;
summary: string;
body: string;
contactAt: Date;
createdAt: Date;
createdBy: {
id: string;
firstName: string;
lastName: string;
email: string;
} | null;
};
type DetailedRecord = (Customer | Vendor) & {
contactEntries: ContactEntryWithAuthor[];
};
function mapContactEntry(entry: ContactEntryWithAuthor): CrmContactEntryDto {
return {
id: entry.id,
type: entry.type as CrmContactEntryType,
summary: entry.summary,
body: entry.body,
contactAt: entry.contactAt.toISOString(),
createdAt: entry.createdAt.toISOString(),
createdBy: entry.createdBy
? {
id: entry.createdBy.id,
name: `${entry.createdBy.firstName} ${entry.createdBy.lastName}`.trim(),
email: entry.createdBy.email,
}
: {
id: null,
name: "System",
email: null,
},
};
}
function mapDetailedRecord(record: DetailedRecord): CrmRecordDetailDto {
return {
...mapDetail(record),
contactHistory: record.contactEntries
.slice()
.sort((left, right) => right.contactAt.getTime() - left.contactAt.getTime())
.map(mapContactEntry),
};
}
@@ -74,9 +129,17 @@ export async function listCustomers(filters: CrmListFilters = {}) {
export async function getCustomerById(customerId: string) {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
include: {
contactEntries: {
include: {
createdBy: true,
},
orderBy: [{ contactAt: "desc" }, { createdAt: "desc" }],
},
},
});
return customer ? mapDetail(customer) : null;
return customer ? mapDetailedRecord(customer) : null;
}
export async function createCustomer(payload: CrmRecordInput) {
@@ -116,9 +179,17 @@ export async function listVendors(filters: CrmListFilters = {}) {
export async function getVendorById(vendorId: string) {
const vendor = await prisma.vendor.findUnique({
where: { id: vendorId },
include: {
contactEntries: {
include: {
createdBy: true,
},
orderBy: [{ contactAt: "desc" }, { createdAt: "desc" }],
},
},
});
return vendor ? mapDetail(vendor) : null;
return vendor ? mapDetailedRecord(vendor) : null;
}
export async function createVendor(payload: CrmRecordInput) {
@@ -145,3 +216,55 @@ export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
return mapDetail(vendor);
}
export async function createCustomerContactEntry(customerId: string, payload: CrmContactEntryInput, createdById?: string) {
const existingCustomer = await prisma.customer.findUnique({
where: { id: customerId },
});
if (!existingCustomer) {
return null;
}
const entry = await prisma.crmContactEntry.create({
data: {
type: payload.type,
summary: payload.summary,
body: payload.body,
contactAt: new Date(payload.contactAt),
customerId,
createdById,
},
include: {
createdBy: true,
},
});
return mapContactEntry(entry);
}
export async function createVendorContactEntry(vendorId: string, payload: CrmContactEntryInput, createdById?: string) {
const existingVendor = await prisma.vendor.findUnique({
where: { id: vendorId },
});
if (!existingVendor) {
return null;
}
const entry = await prisma.crmContactEntry.create({
data: {
type: payload.type,
summary: payload.summary,
body: payload.body,
contactAt: new Date(payload.contactAt),
vendorId,
createdById,
},
include: {
createdBy: true,
},
});
return mapContactEntry(entry);
}