|
|
|
|
@@ -1,11 +1,13 @@
|
|
|
|
|
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
|
|
|
|
|
import { crmCustomerContacts, crmCustomers, msOptions, users } from '@/db/schema';
|
|
|
|
|
import { crmCustomerContacts, crmCustomers, crmEnquiries, crmQuotations, msOptions, users } from '@/db/schema';
|
|
|
|
|
import { db } from '@/lib/db';
|
|
|
|
|
import { AuthError } from '@/lib/auth/session';
|
|
|
|
|
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
|
|
|
|
import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
|
|
|
|
import { validateBranchAccess, getUserBranches } from '@/features/foundation/branch-scope/service';
|
|
|
|
|
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
|
|
|
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
|
|
|
|
import { type CrmSecurityContext } from '@/features/crm/security/server/service';
|
|
|
|
|
import type {
|
|
|
|
|
BranchOption,
|
|
|
|
|
CustomerActivityRecord,
|
|
|
|
|
@@ -25,6 +27,8 @@ type CustomerRecordSource = Omit<typeof crmCustomers.$inferSelect, 'customerSubG
|
|
|
|
|
|
|
|
|
|
let customerSubGroupColumnAvailable: boolean | undefined;
|
|
|
|
|
|
|
|
|
|
export type CustomerAccessContext = CrmSecurityContext;
|
|
|
|
|
|
|
|
|
|
const CUSTOMER_OPTION_CATEGORIES = {
|
|
|
|
|
customerType: 'crm_customer_type',
|
|
|
|
|
customerStatus: 'crm_customer_status',
|
|
|
|
|
@@ -269,6 +273,130 @@ async function assertCustomerBelongsToOrganization(id: string, organizationId: s
|
|
|
|
|
return customer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resolveAccessibleCustomerRelationSets(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
accessContext: CustomerAccessContext
|
|
|
|
|
) {
|
|
|
|
|
const [enquiries, quotations] = await Promise.all([
|
|
|
|
|
db
|
|
|
|
|
.select({
|
|
|
|
|
customerId: crmEnquiries.customerId,
|
|
|
|
|
createdBy: crmEnquiries.createdBy,
|
|
|
|
|
assignedToUserId: crmEnquiries.assignedToUserId,
|
|
|
|
|
branchId: crmEnquiries.branchId,
|
|
|
|
|
productType: crmEnquiries.productType
|
|
|
|
|
})
|
|
|
|
|
.from(crmEnquiries)
|
|
|
|
|
.where(
|
|
|
|
|
and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt))
|
|
|
|
|
),
|
|
|
|
|
db
|
|
|
|
|
.select({
|
|
|
|
|
customerId: crmQuotations.customerId,
|
|
|
|
|
createdBy: crmQuotations.createdBy,
|
|
|
|
|
salesmanId: crmQuotations.salesmanId,
|
|
|
|
|
branchId: crmQuotations.branchId,
|
|
|
|
|
quotationType: crmQuotations.quotationType
|
|
|
|
|
})
|
|
|
|
|
.from(crmQuotations)
|
|
|
|
|
.where(
|
|
|
|
|
and(eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt))
|
|
|
|
|
)
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const enquiryCustomerIds = new Set<string>();
|
|
|
|
|
const quotationCustomerIds = new Set<string>();
|
|
|
|
|
|
|
|
|
|
for (const row of enquiries) {
|
|
|
|
|
if (!hasBranchScopeAccess(accessContext, row.branchId)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!hasProductScopeAccess(accessContext, row.productType)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
accessContext.membershipRole === 'admin' ||
|
|
|
|
|
accessContext.ownershipScope === 'organization' ||
|
|
|
|
|
accessContext.ownershipScope === 'team' ||
|
|
|
|
|
accessContext.ownershipScope === 'monitor' ||
|
|
|
|
|
row.createdBy === accessContext.userId ||
|
|
|
|
|
row.assignedToUserId === accessContext.userId
|
|
|
|
|
) {
|
|
|
|
|
enquiryCustomerIds.add(row.customerId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const row of quotations) {
|
|
|
|
|
if (!hasBranchScopeAccess(accessContext, row.branchId)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!hasProductScopeAccess(accessContext, row.quotationType)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
accessContext.membershipRole === 'admin' ||
|
|
|
|
|
accessContext.ownershipScope === 'organization' ||
|
|
|
|
|
accessContext.ownershipScope === 'team' ||
|
|
|
|
|
row.createdBy === accessContext.userId ||
|
|
|
|
|
row.salesmanId === accessContext.userId
|
|
|
|
|
) {
|
|
|
|
|
quotationCustomerIds.add(row.customerId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { enquiryCustomerIds, quotationCustomerIds };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function canAccessCustomerRow(
|
|
|
|
|
row: CustomerRecordSource,
|
|
|
|
|
accessContext: CustomerAccessContext
|
|
|
|
|
) {
|
|
|
|
|
if (!hasBranchScopeAccess(accessContext, row.branchId)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
accessContext.membershipRole === 'admin' ||
|
|
|
|
|
accessContext.ownershipScope === 'organization' ||
|
|
|
|
|
accessContext.ownershipScope === 'team'
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { enquiryCustomerIds, quotationCustomerIds } = await resolveAccessibleCustomerRelationSets(
|
|
|
|
|
row.organizationId,
|
|
|
|
|
accessContext
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (accessContext.ownershipScope === 'monitor') {
|
|
|
|
|
return enquiryCustomerIds.has(row.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
row.createdBy === accessContext.userId ||
|
|
|
|
|
enquiryCustomerIds.has(row.id) ||
|
|
|
|
|
quotationCustomerIds.has(row.id)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function assertCustomerAccess(
|
|
|
|
|
id: string,
|
|
|
|
|
organizationId: string,
|
|
|
|
|
accessContext?: CustomerAccessContext
|
|
|
|
|
) {
|
|
|
|
|
const customer = await assertCustomerBelongsToOrganization(id, organizationId);
|
|
|
|
|
|
|
|
|
|
if (accessContext && !(await canAccessCustomerRow(customer, accessContext))) {
|
|
|
|
|
throw new AuthError('Forbidden', 403);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return customer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function assertContactBelongsToCustomer(
|
|
|
|
|
contactId: string,
|
|
|
|
|
customerId: string,
|
|
|
|
|
@@ -406,7 +534,8 @@ export async function getCustomerReferenceData(
|
|
|
|
|
|
|
|
|
|
export async function listCustomers(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
filters: CustomerFilters
|
|
|
|
|
filters: CustomerFilters,
|
|
|
|
|
accessContext?: CustomerAccessContext
|
|
|
|
|
): Promise<{ items: CustomerListItem[]; totalItems: number }> {
|
|
|
|
|
const includeCustomerSubGroup = await hasCustomerSubGroupColumn();
|
|
|
|
|
const page = filters.page ?? 1;
|
|
|
|
|
@@ -415,16 +544,22 @@ export async function listCustomers(
|
|
|
|
|
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
|
|
|
|
const offset = (page - 1) * limit;
|
|
|
|
|
|
|
|
|
|
const [totalResult] = await db.select({ value: count() }).from(crmCustomers).where(where);
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select(getCustomerRecordSelection(includeCustomerSubGroup))
|
|
|
|
|
.from(crmCustomers)
|
|
|
|
|
.where(where)
|
|
|
|
|
.orderBy(parseSort(filters.sort))
|
|
|
|
|
.limit(limit)
|
|
|
|
|
.offset(offset);
|
|
|
|
|
.orderBy(parseSort(filters.sort));
|
|
|
|
|
|
|
|
|
|
const customerIds = rows.map((row) => row.id);
|
|
|
|
|
const scopedRows = accessContext
|
|
|
|
|
? (
|
|
|
|
|
await Promise.all(
|
|
|
|
|
rows.map(async (row) => ((await canAccessCustomerRow(row, accessContext)) ? row : null))
|
|
|
|
|
)
|
|
|
|
|
).filter((row): row is CustomerRecordSource => row !== null)
|
|
|
|
|
: rows;
|
|
|
|
|
const pagedRows = scopedRows.slice(offset, offset + limit);
|
|
|
|
|
|
|
|
|
|
const customerIds = pagedRows.map((row) => row.id);
|
|
|
|
|
const contactCounts = customerIds.length
|
|
|
|
|
? await db
|
|
|
|
|
.select({
|
|
|
|
|
@@ -444,19 +579,20 @@ export async function listCustomers(
|
|
|
|
|
const countMap = new Map(contactCounts.map((item) => [item.customerId, item.value]));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
items: rows.map((row) => ({
|
|
|
|
|
items: pagedRows.map((row) => ({
|
|
|
|
|
...mapCustomerRecord(row),
|
|
|
|
|
contactCount: countMap.get(row.id) ?? 0
|
|
|
|
|
})),
|
|
|
|
|
totalItems: totalResult?.value ?? 0
|
|
|
|
|
totalItems: scopedRows.length
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getCustomerDetail(
|
|
|
|
|
id: string,
|
|
|
|
|
organizationId: string
|
|
|
|
|
organizationId: string,
|
|
|
|
|
accessContext?: CustomerAccessContext
|
|
|
|
|
): Promise<CustomerRecord> {
|
|
|
|
|
const customer = await assertCustomerBelongsToOrganization(id, organizationId);
|
|
|
|
|
const customer = await assertCustomerAccess(id, organizationId, accessContext);
|
|
|
|
|
return mapCustomerRecord(customer);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -542,10 +678,11 @@ export async function updateCustomer(
|
|
|
|
|
id: string,
|
|
|
|
|
organizationId: string,
|
|
|
|
|
userId: string,
|
|
|
|
|
payload: CustomerMutationPayload
|
|
|
|
|
payload: CustomerMutationPayload,
|
|
|
|
|
accessContext?: CustomerAccessContext
|
|
|
|
|
) {
|
|
|
|
|
await validateCustomerPayload(organizationId, payload);
|
|
|
|
|
await assertCustomerBelongsToOrganization(id, organizationId);
|
|
|
|
|
await assertCustomerAccess(id, organizationId, accessContext);
|
|
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(crmCustomers)
|
|
|
|
|
@@ -580,8 +717,13 @@ export async function updateCustomer(
|
|
|
|
|
return updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function softDeleteCustomer(id: string, organizationId: string, userId: string) {
|
|
|
|
|
await assertCustomerBelongsToOrganization(id, organizationId);
|
|
|
|
|
export async function softDeleteCustomer(
|
|
|
|
|
id: string,
|
|
|
|
|
organizationId: string,
|
|
|
|
|
userId: string,
|
|
|
|
|
accessContext?: CustomerAccessContext
|
|
|
|
|
) {
|
|
|
|
|
await assertCustomerAccess(id, organizationId, accessContext);
|
|
|
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
|
|
|
|
@@ -614,8 +756,12 @@ export async function softDeleteCustomer(id: string, organizationId: string, use
|
|
|
|
|
return updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function listCustomerContacts(id: string, organizationId: string) {
|
|
|
|
|
await assertCustomerBelongsToOrganization(id, organizationId);
|
|
|
|
|
export async function listCustomerContacts(
|
|
|
|
|
id: string,
|
|
|
|
|
organizationId: string,
|
|
|
|
|
accessContext?: CustomerAccessContext
|
|
|
|
|
) {
|
|
|
|
|
await assertCustomerAccess(id, organizationId, accessContext);
|
|
|
|
|
const rows = await db.query.crmCustomerContacts.findMany({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(crmCustomerContacts.customerId, id),
|
|
|
|
|
@@ -625,16 +771,26 @@ export async function listCustomerContacts(id: string, organizationId: string) {
|
|
|
|
|
orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return rows.map(mapCustomerContactRecord);
|
|
|
|
|
return rows
|
|
|
|
|
.filter(
|
|
|
|
|
(row) =>
|
|
|
|
|
!accessContext ||
|
|
|
|
|
row.createdBy === accessContext.userId ||
|
|
|
|
|
accessContext.membershipRole === 'admin' ||
|
|
|
|
|
accessContext.ownershipScope === 'organization' ||
|
|
|
|
|
accessContext.ownershipScope === 'team'
|
|
|
|
|
)
|
|
|
|
|
.map(mapCustomerContactRecord);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createCustomerContact(
|
|
|
|
|
customerId: string,
|
|
|
|
|
organizationId: string,
|
|
|
|
|
userId: string,
|
|
|
|
|
payload: CustomerContactMutationPayload
|
|
|
|
|
payload: CustomerContactMutationPayload,
|
|
|
|
|
accessContext?: CustomerAccessContext
|
|
|
|
|
) {
|
|
|
|
|
await assertCustomerBelongsToOrganization(customerId, organizationId);
|
|
|
|
|
await assertCustomerAccess(customerId, organizationId, accessContext);
|
|
|
|
|
|
|
|
|
|
return db.transaction(async (tx) => {
|
|
|
|
|
if (payload.isPrimary) {
|
|
|
|
|
@@ -683,9 +839,10 @@ export async function updateCustomerContact(
|
|
|
|
|
contactId: string,
|
|
|
|
|
organizationId: string,
|
|
|
|
|
userId: string,
|
|
|
|
|
payload: CustomerContactMutationPayload
|
|
|
|
|
payload: CustomerContactMutationPayload,
|
|
|
|
|
accessContext?: CustomerAccessContext
|
|
|
|
|
) {
|
|
|
|
|
await assertCustomerBelongsToOrganization(customerId, organizationId);
|
|
|
|
|
await assertCustomerAccess(customerId, organizationId, accessContext);
|
|
|
|
|
await assertContactBelongsToCustomer(contactId, customerId, organizationId);
|
|
|
|
|
|
|
|
|
|
return db.transaction(async (tx) => {
|
|
|
|
|
@@ -732,9 +889,10 @@ export async function softDeleteCustomerContact(
|
|
|
|
|
customerId: string,
|
|
|
|
|
contactId: string,
|
|
|
|
|
organizationId: string,
|
|
|
|
|
userId: string
|
|
|
|
|
userId: string,
|
|
|
|
|
accessContext?: CustomerAccessContext
|
|
|
|
|
) {
|
|
|
|
|
await assertCustomerBelongsToOrganization(customerId, organizationId);
|
|
|
|
|
await assertCustomerAccess(customerId, organizationId, accessContext);
|
|
|
|
|
await assertContactBelongsToCustomer(contactId, customerId, organizationId);
|
|
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
|