This commit is contained in:
2026-03-14 16:08:29 -05:00
parent 84bd962744
commit 9c8298c5e3
17 changed files with 975 additions and 102 deletions

View File

@@ -0,0 +1,7 @@
ALTER TABLE "Customer" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'ACTIVE';
ALTER TABLE "Vendor" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'ACTIVE';
CREATE INDEX "Customer_status_idx" ON "Customer"("status");
CREATE INDEX "Vendor_status_idx" ON "Vendor"("status");

View File

@@ -111,6 +111,7 @@ model Customer {
state String
postalCode String
country String
status String @default("ACTIVE")
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -127,6 +128,7 @@ model Vendor {
state String
postalCode String
country String
status String @default("ACTIVE")
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -1,17 +1,158 @@
import { permissions } from "@mrp/shared";
import { crmRecordStatuses, permissions } from "@mrp/shared";
import { Router } from "express";
import { z } from "zod";
import { ok } from "../../lib/http.js";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { listCustomers, listVendors } from "./service.js";
import {
createCustomer,
createVendor,
getCustomerById,
getVendorById,
listCustomers,
listVendors,
updateCustomer,
updateVendor,
} from "./service.js";
const crmRecordSchema = z.object({
name: z.string().trim().min(1),
email: z.string().trim().email(),
phone: z.string().trim().min(1),
addressLine1: z.string().trim().min(1),
addressLine2: z.string(),
city: z.string().trim().min(1),
state: z.string().trim().min(1),
postalCode: z.string().trim().min(1),
country: z.string().trim().min(1),
status: z.enum(crmRecordStatuses),
notes: z.string(),
});
const crmListQuerySchema = z.object({
q: z.string().optional(),
state: z.string().optional(),
status: z.enum(crmRecordStatuses).optional(),
});
function getRouteParam(value: string | string[] | undefined) {
return typeof value === "string" ? value : null;
}
export const crmRouter = Router();
crmRouter.get("/customers", requirePermissions([permissions.crmRead]), async (_request, response) => {
return ok(response, await listCustomers());
const parsed = crmListQuerySchema.safeParse(_request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "CRM filters are invalid.");
}
return ok(
response,
await listCustomers({
query: parsed.data.q,
status: parsed.data.status,
state: parsed.data.state,
})
);
});
crmRouter.get("/customers/:customerId", requirePermissions([permissions.crmRead]), async (request, response) => {
const customerId = getRouteParam(request.params.customerId);
if (!customerId) {
return fail(response, 400, "INVALID_INPUT", "Customer id is invalid.");
}
const customer = await getCustomerById(customerId);
if (!customer) {
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
}
return ok(response, customer);
});
crmRouter.post("/customers", requirePermissions([permissions.crmWrite]), async (request, response) => {
const parsed = crmRecordSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
}
return ok(response, await createCustomer(parsed.data), 201);
});
crmRouter.put("/customers/:customerId", 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 = crmRecordSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
}
const customer = await updateCustomer(customerId, parsed.data);
if (!customer) {
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
}
return ok(response, customer);
});
crmRouter.get("/vendors", requirePermissions([permissions.crmRead]), async (_request, response) => {
return ok(response, await listVendors());
const parsed = crmListQuerySchema.safeParse(_request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "CRM filters are invalid.");
}
return ok(
response,
await listVendors({
query: parsed.data.q,
status: parsed.data.status,
state: parsed.data.state,
})
);
});
crmRouter.get("/vendors/:vendorId", requirePermissions([permissions.crmRead]), async (request, response) => {
const vendorId = getRouteParam(request.params.vendorId);
if (!vendorId) {
return fail(response, 400, "INVALID_INPUT", "Vendor id is invalid.");
}
const vendor = await getVendorById(vendorId);
if (!vendor) {
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
}
return ok(response, vendor);
});
crmRouter.post("/vendors", requirePermissions([permissions.crmWrite]), async (request, response) => {
const parsed = crmRecordSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid.");
}
return ok(response, await createVendor(parsed.data), 201);
});
crmRouter.put("/vendors/:vendorId", 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 = crmRecordSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid.");
}
const vendor = await updateVendor(vendorId, parsed.data);
if (!vendor) {
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
}
return ok(response, vendor);
});

View File

@@ -1,14 +1,147 @@
import type {
CrmRecordDetailDto,
CrmRecordInput,
CrmRecordStatus,
CrmRecordSummaryDto,
} from "@mrp/shared/dist/crm/types.js";
import type { Customer, Vendor } from "@prisma/client";
import { prisma } from "../../lib/prisma.js";
export async function listCustomers() {
return prisma.customer.findMany({
orderBy: { name: "asc" },
});
function mapSummary(record: Customer | Vendor): CrmRecordSummaryDto {
return {
id: record.id,
name: record.name,
email: record.email,
phone: record.phone,
city: record.city,
state: record.state,
country: record.country,
status: record.status as CrmRecordStatus,
updatedAt: record.updatedAt.toISOString(),
};
}
export async function listVendors() {
return prisma.vendor.findMany({
orderBy: { name: "asc" },
});
function mapDetail(record: Customer | Vendor): CrmRecordDetailDto {
return {
...mapSummary(record),
addressLine1: record.addressLine1,
addressLine2: record.addressLine2,
postalCode: record.postalCode,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
};
}
interface CrmListFilters {
query?: string;
status?: CrmRecordStatus;
state?: string;
}
function buildWhereClause(filters: CrmListFilters) {
const trimmedQuery = filters.query?.trim();
const trimmedState = filters.state?.trim();
return {
...(filters.status ? { status: filters.status } : {}),
...(trimmedState ? { state: { contains: trimmedState } } : {}),
...(trimmedQuery
? {
OR: [
{ name: { contains: trimmedQuery } },
{ email: { contains: trimmedQuery } },
{ phone: { contains: trimmedQuery } },
{ city: { contains: trimmedQuery } },
{ state: { contains: trimmedQuery } },
{ postalCode: { contains: trimmedQuery } },
{ country: { contains: trimmedQuery } },
],
}
: {}),
};
}
export async function listCustomers(filters: CrmListFilters = {}) {
const customers = await prisma.customer.findMany({
where: buildWhereClause(filters),
orderBy: { name: "asc" },
});
return customers.map(mapSummary);
}
export async function getCustomerById(customerId: string) {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
});
return customer ? mapDetail(customer) : null;
}
export async function createCustomer(payload: CrmRecordInput) {
const customer = await prisma.customer.create({
data: payload,
});
return mapDetail(customer);
}
export async function updateCustomer(customerId: string, payload: CrmRecordInput) {
const existingCustomer = await prisma.customer.findUnique({
where: { id: customerId },
});
if (!existingCustomer) {
return null;
}
const customer = await prisma.customer.update({
where: { id: customerId },
data: payload,
});
return mapDetail(customer);
}
export async function listVendors(filters: CrmListFilters = {}) {
const vendors = await prisma.vendor.findMany({
where: buildWhereClause(filters),
orderBy: { name: "asc" },
});
return vendors.map(mapSummary);
}
export async function getVendorById(vendorId: string) {
const vendor = await prisma.vendor.findUnique({
where: { id: vendorId },
});
return vendor ? mapDetail(vendor) : null;
}
export async function createVendor(payload: CrmRecordInput) {
const vendor = await prisma.vendor.create({
data: payload,
});
return mapDetail(vendor);
}
export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
const existingVendor = await prisma.vendor.findUnique({
where: { id: vendorId },
});
if (!existingVendor) {
return null;
}
const vendor = await prisma.vendor.update({
where: { id: vendorId },
data: payload,
});
return mapDetail(vendor);
}