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

@@ -0,0 +1,19 @@
CREATE TABLE "CrmContactEntry" (
"id" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL DEFAULT 'NOTE',
"summary" TEXT NOT NULL,
"body" TEXT NOT NULL,
"contactAt" DATETIME NOT NULL,
"customerId" TEXT,
"vendorId" TEXT,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "CrmContactEntry_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CrmContactEntry_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CrmContactEntry_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE INDEX "CrmContactEntry_customerId_contactAt_idx" ON "CrmContactEntry"("customerId", "contactAt");
CREATE INDEX "CrmContactEntry_vendorId_contactAt_idx" ON "CrmContactEntry"("vendorId", "contactAt");

View File

@@ -18,6 +18,7 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userRoles UserRole[]
contactEntries CrmContactEntry[]
}
model Role {
@@ -115,6 +116,7 @@ model Customer {
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contactEntries CrmContactEntry[]
}
model Vendor {
@@ -132,4 +134,21 @@ model Vendor {
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contactEntries CrmContactEntry[]
}
model CrmContactEntry {
id String @id @default(cuid())
type String @default("NOTE")
summary String
body String
contactAt DateTime
customerId String?
vendorId String?
createdById 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)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
}

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