crm2
This commit is contained in:
@@ -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");
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user