crm finish

This commit is contained in:
2026-03-14 18:58:23 -05:00
parent c0cc546e33
commit df3f1412f6
16 changed files with 679 additions and 38 deletions

View File

@@ -0,0 +1,11 @@
ALTER TABLE "Customer" ADD COLUMN "lifecycleStage" TEXT NOT NULL DEFAULT 'ACTIVE';
ALTER TABLE "Customer" ADD COLUMN "preferredAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Customer" ADD COLUMN "strategicAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Customer" ADD COLUMN "requiresApproval" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Customer" ADD COLUMN "blockedAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "lifecycleStage" TEXT NOT NULL DEFAULT 'ACTIVE';
ALTER TABLE "Vendor" ADD COLUMN "preferredAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "strategicAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "requiresApproval" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "blockedAccount" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -113,6 +113,7 @@ model Customer {
postalCode String
country String
status String @default("ACTIVE")
lifecycleStage String @default("ACTIVE")
isReseller Boolean @default(false)
resellerDiscountPercent Float @default(0)
parentCustomerId String?
@@ -120,6 +121,10 @@ model Customer {
currencyCode String? @default("USD")
taxExempt Boolean @default(false)
creditHold Boolean @default(false)
preferredAccount Boolean @default(false)
strategicAccount Boolean @default(false)
requiresApproval Boolean @default(false)
blockedAccount Boolean @default(false)
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -141,10 +146,15 @@ model Vendor {
postalCode String
country String
status String @default("ACTIVE")
lifecycleStage String @default("ACTIVE")
paymentTerms String?
currencyCode String? @default("USD")
taxExempt Boolean @default(false)
creditHold Boolean @default(false)
preferredAccount Boolean @default(false)
strategicAccount Boolean @default(false)
requiresApproval Boolean @default(false)
blockedAccount Boolean @default(false)
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -1,5 +1,5 @@
import { permissions } from "@mrp/shared";
import { crmContactEntryTypes, crmContactRoles, crmRecordStatuses } from "@mrp/shared/dist/crm/types.js";
import { crmContactEntryTypes, crmContactRoles, crmLifecycleStages, crmRecordStatuses } from "@mrp/shared/dist/crm/types.js";
import { Router } from "express";
import { z } from "zod";
@@ -32,6 +32,7 @@ const crmRecordSchema = z.object({
postalCode: z.string().trim().min(1),
country: z.string().trim().min(1),
status: z.enum(crmRecordStatuses),
lifecycleStage: z.enum(crmLifecycleStages).optional(),
notes: z.string(),
isReseller: z.boolean().optional(),
resellerDiscountPercent: z.number().min(0).max(100).nullable().optional(),
@@ -40,12 +41,18 @@ const crmRecordSchema = z.object({
currencyCode: z.string().max(8).nullable().optional(),
taxExempt: z.boolean().optional(),
creditHold: z.boolean().optional(),
preferredAccount: z.boolean().optional(),
strategicAccount: z.boolean().optional(),
requiresApproval: z.boolean().optional(),
blockedAccount: z.boolean().optional(),
});
const crmListQuerySchema = z.object({
q: z.string().optional(),
state: z.string().optional(),
status: z.enum(crmRecordStatuses).optional(),
lifecycleStage: z.enum(crmLifecycleStages).optional(),
flag: z.enum(["PREFERRED", "STRATEGIC", "REQUIRES_APPROVAL", "BLOCKED"]).optional(),
});
const crmContactEntrySchema = z.object({
@@ -81,6 +88,8 @@ crmRouter.get("/customers", requirePermissions([permissions.crmRead]), async (_r
query: parsed.data.q,
status: parsed.data.status,
state: parsed.data.state,
lifecycleStage: parsed.data.lifecycleStage,
flag: parsed.data.flag,
})
);
});
@@ -192,6 +201,8 @@ crmRouter.get("/vendors", requirePermissions([permissions.crmRead]), async (_req
query: parsed.data.q,
status: parsed.data.status,
state: parsed.data.state,
lifecycleStage: parsed.data.lifecycleStage,
flag: parsed.data.flag,
})
);
});

View File

@@ -8,6 +8,8 @@ import type {
CrmCustomerChildDto,
CrmRecordDetailDto,
CrmRecordInput,
CrmLifecycleStage,
CrmRecordRollupsDto,
CrmRecordStatus,
CrmRecordSummaryDto,
} from "@mrp/shared/dist/crm/types.js";
@@ -25,6 +27,11 @@ function mapSummary(record: Customer | Vendor): CrmRecordSummaryDto {
state: record.state,
country: record.country,
status: record.status as CrmRecordStatus,
lifecycleStage: record.lifecycleStage as CrmLifecycleStage,
preferredAccount: record.preferredAccount,
strategicAccount: record.strategicAccount,
requiresApproval: record.requiresApproval,
blockedAccount: record.blockedAccount,
updatedAt: record.updatedAt.toISOString(),
};
}
@@ -43,6 +50,12 @@ function mapDetail(record: Customer | Vendor): CrmRecordDetailDto {
type CustomerSummaryRecord = Customer & {
parentCustomer: Pick<Customer, "id" | "name"> | null;
_count: {
contactEntries: number;
contacts: number;
childCustomers: number;
};
contactEntries: Array<Pick<ContactEntryWithAuthor, "contactAt">>;
};
type CustomerDetailedRecord = Customer & {
@@ -57,6 +70,14 @@ type VendorDetailedRecord = Vendor & {
contacts: ContactRecord[];
};
type VendorSummaryRecord = Vendor & {
_count: {
contactEntries: number;
contacts: number;
};
contactEntries: Array<Pick<ContactEntryWithAuthor, "contactAt">>;
};
type ContactRecord = {
id: string;
fullName: string;
@@ -75,16 +96,39 @@ function mapCustomerChild(record: Pick<Customer, "id" | "name" | "status">): Crm
};
}
function mapCustomerSummary(record: CustomerSummaryRecord): CrmRecordSummaryDto {
function mapRollups(input: {
lastContactAt?: Date | null;
contactHistoryCount: number;
contactCount: number;
attachmentCount: number;
childCustomerCount?: number;
}): CrmRecordRollupsDto {
return {
lastContactAt: input.lastContactAt ? input.lastContactAt.toISOString() : null,
contactHistoryCount: input.contactHistoryCount,
contactCount: input.contactCount,
attachmentCount: input.attachmentCount,
childCustomerCount: input.childCustomerCount,
};
}
function mapCustomerSummary(record: CustomerSummaryRecord, attachmentCount: number): CrmRecordSummaryDto {
return {
...mapSummary(record),
isReseller: record.isReseller,
parentCustomerId: record.parentCustomer?.id ?? null,
parentCustomerName: record.parentCustomer?.name ?? null,
rollups: mapRollups({
lastContactAt: record.contactEntries[0]?.contactAt ?? null,
contactHistoryCount: record._count.contactEntries,
contactCount: record._count.contacts,
attachmentCount,
childCustomerCount: record._count.childCustomers,
}),
};
}
function mapCustomerDetail(record: CustomerDetailedRecord): CrmRecordDetailDto {
function mapCustomerDetail(record: CustomerDetailedRecord, attachmentCount: number): CrmRecordDetailDto {
return {
...mapDetailedRecord(record),
isReseller: record.isReseller,
@@ -96,7 +140,19 @@ function mapCustomerDetail(record: CustomerDetailedRecord): CrmRecordDetailDto {
currencyCode: record.currencyCode,
taxExempt: record.taxExempt,
creditHold: record.creditHold,
lifecycleStage: record.lifecycleStage as CrmLifecycleStage,
preferredAccount: record.preferredAccount,
strategicAccount: record.strategicAccount,
requiresApproval: record.requiresApproval,
blockedAccount: record.blockedAccount,
contacts: record.contacts.map(mapCrmContact),
rollups: mapRollups({
lastContactAt: record.contactEntries[0]?.contactAt ?? null,
contactHistoryCount: record.contactEntries.length,
contactCount: record.contacts.length,
attachmentCount,
childCustomerCount: record.childCustomers.length,
}),
};
}
@@ -112,14 +168,37 @@ function mapCrmContact(record: ContactRecord): CrmContactDto {
};
}
function mapVendorDetail(record: VendorDetailedRecord): CrmRecordDetailDto {
function mapVendorSummary(record: VendorSummaryRecord, attachmentCount: number): CrmRecordSummaryDto {
return {
...mapSummary(record),
rollups: mapRollups({
lastContactAt: record.contactEntries[0]?.contactAt ?? null,
contactHistoryCount: record._count.contactEntries,
contactCount: record._count.contacts,
attachmentCount,
}),
};
}
function mapVendorDetail(record: VendorDetailedRecord, attachmentCount: number): CrmRecordDetailDto {
return {
...mapDetailedRecord(record),
paymentTerms: record.paymentTerms,
currencyCode: record.currencyCode,
taxExempt: record.taxExempt,
creditHold: record.creditHold,
lifecycleStage: record.lifecycleStage as CrmLifecycleStage,
preferredAccount: record.preferredAccount,
strategicAccount: record.strategicAccount,
requiresApproval: record.requiresApproval,
blockedAccount: record.blockedAccount,
contacts: record.contacts.map(mapCrmContact),
rollups: mapRollups({
lastContactAt: record.contactEntries[0]?.contactAt ?? null,
contactHistoryCount: record.contactEntries.length,
contactCount: record.contacts.length,
attachmentCount,
}),
};
}
@@ -177,15 +256,50 @@ function mapDetailedRecord(record: DetailedRecord): CrmRecordDetailDto {
interface CrmListFilters {
query?: string;
status?: CrmRecordStatus;
lifecycleStage?: CrmLifecycleStage;
state?: string;
flag?: "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
}
async function getAttachmentCountMap(ownerType: string, ownerIds: string[]) {
if (ownerIds.length === 0) {
return new Map<string, number>();
}
const groupedAttachments = await prisma.fileAttachment.groupBy({
by: ["ownerId"],
where: {
ownerType,
ownerId: {
in: ownerIds,
},
},
_count: {
_all: true,
},
});
return new Map(groupedAttachments.map((entry) => [entry.ownerId, entry._count._all]));
}
function buildWhereClause(filters: CrmListFilters) {
const trimmedQuery = filters.query?.trim();
const trimmedState = filters.state?.trim();
const flagFilter =
filters.flag === "PREFERRED"
? { preferredAccount: true }
: filters.flag === "STRATEGIC"
? { strategicAccount: true }
: filters.flag === "REQUIRES_APPROVAL"
? { requiresApproval: true }
: filters.flag === "BLOCKED"
? { blockedAccount: true }
: {};
return {
...(filters.status ? { status: filters.status } : {}),
...(filters.lifecycleStage ? { lifecycleStage: filters.lifecycleStage } : {}),
...flagFilter,
...(trimmedState ? { state: { contains: trimmedState } } : {}),
...(trimmedQuery
? {
@@ -213,11 +327,28 @@ export async function listCustomers(filters: CrmListFilters = {}) {
name: true,
},
},
contactEntries: {
select: {
contactAt: true,
},
orderBy: {
contactAt: "desc",
},
take: 1,
},
_count: {
select: {
contactEntries: true,
contacts: true,
childCustomers: true,
},
},
},
orderBy: { name: "asc" },
});
return customers.map(mapCustomerSummary);
const attachmentCountMap = await getAttachmentCountMap("crm-customer", customers.map((customer) => customer.id));
return customers.map((customer) => mapCustomerSummary(customer, attachmentCountMap.get(customer.id) ?? 0));
}
export async function getCustomerById(customerId: string) {
@@ -252,7 +383,18 @@ export async function getCustomerById(customerId: string) {
},
});
return customer ? mapCustomerDetail(customer) : null;
if (!customer) {
return null;
}
const attachmentCount = await prisma.fileAttachment.count({
where: {
ownerType: "crm-customer",
ownerId: customerId,
},
});
return mapCustomerDetail(customer, attachmentCount);
}
export async function createCustomer(payload: CrmRecordInput) {
@@ -279,6 +421,7 @@ export async function createCustomer(payload: CrmRecordInput) {
country: payload.country,
status: payload.status,
notes: payload.notes,
lifecycleStage: payload.lifecycleStage ?? "ACTIVE",
isReseller: payload.isReseller ?? false,
resellerDiscountPercent: payload.resellerDiscountPercent ?? 0,
parentCustomerId: payload.parentCustomerId ?? null,
@@ -286,6 +429,10 @@ export async function createCustomer(payload: CrmRecordInput) {
currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false,
preferredAccount: payload.preferredAccount ?? false,
strategicAccount: payload.strategicAccount ?? false,
requiresApproval: payload.requiresApproval ?? false,
blockedAccount: payload.blockedAccount ?? false,
},
});
@@ -300,7 +447,19 @@ export async function createCustomer(payload: CrmRecordInput) {
currencyCode: customer.currencyCode,
taxExempt: customer.taxExempt,
creditHold: customer.creditHold,
lifecycleStage: customer.lifecycleStage as CrmLifecycleStage,
preferredAccount: customer.preferredAccount,
strategicAccount: customer.strategicAccount,
requiresApproval: customer.requiresApproval,
blockedAccount: customer.blockedAccount,
contacts: [],
rollups: mapRollups({
lastContactAt: null,
contactHistoryCount: 0,
contactCount: 0,
attachmentCount: 0,
childCustomerCount: 0,
}),
};
}
@@ -341,6 +500,7 @@ export async function updateCustomer(customerId: string, payload: CrmRecordInput
country: payload.country,
status: payload.status,
notes: payload.notes,
lifecycleStage: payload.lifecycleStage ?? "ACTIVE",
isReseller: payload.isReseller ?? false,
resellerDiscountPercent: payload.resellerDiscountPercent ?? 0,
parentCustomerId: payload.parentCustomerId ?? null,
@@ -348,6 +508,10 @@ export async function updateCustomer(customerId: string, payload: CrmRecordInput
currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false,
preferredAccount: payload.preferredAccount ?? false,
strategicAccount: payload.strategicAccount ?? false,
requiresApproval: payload.requiresApproval ?? false,
blockedAccount: payload.blockedAccount ?? false,
},
});
@@ -362,17 +526,47 @@ export async function updateCustomer(customerId: string, payload: CrmRecordInput
currencyCode: customer.currencyCode,
taxExempt: customer.taxExempt,
creditHold: customer.creditHold,
lifecycleStage: customer.lifecycleStage as CrmLifecycleStage,
preferredAccount: customer.preferredAccount,
strategicAccount: customer.strategicAccount,
requiresApproval: customer.requiresApproval,
blockedAccount: customer.blockedAccount,
contacts: [],
rollups: mapRollups({
lastContactAt: null,
contactHistoryCount: 0,
contactCount: 0,
attachmentCount: 0,
childCustomerCount: 0,
}),
};
}
export async function listVendors(filters: CrmListFilters = {}) {
const vendors = await prisma.vendor.findMany({
where: buildWhereClause(filters),
include: {
contactEntries: {
select: {
contactAt: true,
},
orderBy: {
contactAt: "desc",
},
take: 1,
},
_count: {
select: {
contactEntries: true,
contacts: true,
},
},
},
orderBy: { name: "asc" },
});
return vendors.map(mapSummary);
const attachmentCountMap = await getAttachmentCountMap("crm-vendor", vendors.map((vendor) => vendor.id));
return vendors.map((vendor) => mapVendorSummary(vendor, attachmentCountMap.get(vendor.id) ?? 0));
}
export async function listCustomerHierarchyOptions(excludeCustomerId?: string) {
@@ -422,7 +616,18 @@ export async function getVendorById(vendorId: string) {
},
});
return vendor ? mapVendorDetail(vendor) : null;
if (!vendor) {
return null;
}
const attachmentCount = await prisma.fileAttachment.count({
where: {
ownerType: "crm-vendor",
ownerId: vendorId,
},
});
return mapVendorDetail(vendor, attachmentCount);
}
export async function createVendor(payload: CrmRecordInput) {
@@ -438,11 +643,16 @@ export async function createVendor(payload: CrmRecordInput) {
postalCode: payload.postalCode,
country: payload.country,
status: payload.status,
lifecycleStage: payload.lifecycleStage ?? "ACTIVE",
notes: payload.notes,
paymentTerms: payload.paymentTerms ?? null,
currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false,
preferredAccount: payload.preferredAccount ?? false,
strategicAccount: payload.strategicAccount ?? false,
requiresApproval: payload.requiresApproval ?? false,
blockedAccount: payload.blockedAccount ?? false,
},
});
@@ -452,7 +662,18 @@ export async function createVendor(payload: CrmRecordInput) {
currencyCode: vendor.currencyCode,
taxExempt: vendor.taxExempt,
creditHold: vendor.creditHold,
lifecycleStage: vendor.lifecycleStage as CrmLifecycleStage,
preferredAccount: vendor.preferredAccount,
strategicAccount: vendor.strategicAccount,
requiresApproval: vendor.requiresApproval,
blockedAccount: vendor.blockedAccount,
contacts: [],
rollups: mapRollups({
lastContactAt: null,
contactHistoryCount: 0,
contactCount: 0,
attachmentCount: 0,
}),
};
}
@@ -478,11 +699,16 @@ export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
postalCode: payload.postalCode,
country: payload.country,
status: payload.status,
lifecycleStage: payload.lifecycleStage ?? "ACTIVE",
notes: payload.notes,
paymentTerms: payload.paymentTerms ?? null,
currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false,
preferredAccount: payload.preferredAccount ?? false,
strategicAccount: payload.strategicAccount ?? false,
requiresApproval: payload.requiresApproval ?? false,
blockedAccount: payload.blockedAccount ?? false,
},
});
@@ -492,7 +718,18 @@ export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
currencyCode: vendor.currencyCode,
taxExempt: vendor.taxExempt,
creditHold: vendor.creditHold,
lifecycleStage: vendor.lifecycleStage as CrmLifecycleStage,
preferredAccount: vendor.preferredAccount,
strategicAccount: vendor.strategicAccount,
requiresApproval: vendor.requiresApproval,
blockedAccount: vendor.blockedAccount,
contacts: [],
rollups: mapRollups({
lastContactAt: null,
contactHistoryCount: 0,
contactCount: 0,
attachmentCount: 0,
}),
};
}

View File

@@ -5,7 +5,7 @@ import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { createAttachment, getAttachmentContent, getAttachmentMetadata, listAttachmentsByOwner } from "./service.js";
import { createAttachment, deleteAttachment, getAttachmentContent, getAttachmentMetadata, listAttachmentsByOwner } from "./service.js";
const upload = multer({
storage: multer.memoryStorage(),
@@ -71,3 +71,7 @@ filesRouter.get("/:id/content", requirePermissions([permissions.filesRead]), asy
response.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`);
return response.send(content);
});
filesRouter.delete("/:id", requirePermissions([permissions.filesWrite]), async (request, response) => {
return ok(response, await deleteAttachment(String(request.params.id)));
});

View File

@@ -78,3 +78,23 @@ export async function getAttachmentContent(id: string) {
content: await fs.readFile(path.join(paths.dataDir, file.relativePath)),
};
}
export async function deleteAttachment(id: string) {
const file = await prisma.fileAttachment.findUniqueOrThrow({
where: { id },
});
try {
await fs.unlink(path.join(paths.dataDir, file.relativePath));
} catch (error: unknown) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
throw error;
}
}
await prisma.fileAttachment.delete({
where: { id },
});
return mapFile(file);
}