task-l.3.1

This commit is contained in:
phaichayon
2026-06-22 14:18:26 +07:00
parent 42f403a7ed
commit 1b901efc51
21 changed files with 1320 additions and 102 deletions

View File

@@ -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

View File

@@ -686,7 +686,7 @@ async function buildApprovalAnalytics(
page: 1,
limit: 50,
status: 'pending'
});
}, context);
const todayStart = startOfDay(new Date());
const todayEnd = endOfDay(new Date());
const rows = await db

View File

@@ -1348,7 +1348,8 @@ export async function softDeleteEnquiryFollowup(
export async function listCustomerEnquiryRelations(
customerId: string,
organizationId: string
organizationId: string,
accessContext?: EnquiryAccessContext
): Promise<EnquiryCustomerRelationItem[]> {
const rows = await db
.select()
@@ -1363,14 +1364,16 @@ export async function listCustomerEnquiryRelations(
.orderBy(desc(crmEnquiries.updatedAt))
.limit(10);
return rows.map((row) => ({
id: row.id,
code: row.code,
title: row.title,
status: row.status,
productType: row.productType,
updatedAt: row.updatedAt.toISOString()
}));
return rows
.filter((row) => !accessContext || canAccessEnquiryRow(row, accessContext))
.map((row) => ({
id: row.id,
code: row.code,
title: row.title,
status: row.status,
productType: row.productType,
updatedAt: row.updatedAt.toISOString()
}));
}
export async function syncEnquiryPipelineStageFromQuotationStatus(

View File

@@ -2319,7 +2319,8 @@ export async function listEnquiryQuotationRelations(
export async function listCustomerQuotationRelations(
customerId: string,
organizationId: string
organizationId: string,
accessContext?: QuotationAccessContext
): Promise<QuotationRelationItem[]> {
const rows = await db
.select()
@@ -2333,12 +2334,14 @@ export async function listCustomerQuotationRelations(
)
.orderBy(desc(crmQuotations.updatedAt));
return rows.map((row) => ({
id: row.id,
code: row.code,
status: row.status,
quotationType: row.quotationType,
totalAmount: row.totalAmount,
updatedAt: row.updatedAt.toISOString()
}));
return rows
.filter((row) => !accessContext || canAccessQuotationRow(row, accessContext))
.map((row) => ({
id: row.id,
code: row.code,
status: row.status,
quotationType: row.quotationType,
totalAmount: row.totalAmount,
updatedAt: row.updatedAt.toISOString()
}));
}

View File

@@ -89,7 +89,14 @@ export function canAccessScopedCrmRecord(
export async function auditCrmSecurityEvent(input: {
organizationId: string;
userId: string;
action: 'access_denied' | 'scope_violation' | 'permission_violation' | 'pricing_hidden';
action:
| 'access_denied'
| 'scope_violation'
| 'permission_violation'
| 'pricing_hidden'
| 'customer_scope_violation'
| 'contact_scope_violation'
| 'approval_scope_violation';
entityType: string;
entityId: string;
reason: string;