crm2
This commit is contained in:
@@ -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