This commit is contained in:
phaichayon
2026-06-22 13:36:00 +07:00
parent 827fd13fa7
commit 42f403a7ed
23 changed files with 1425 additions and 68 deletions

View File

@@ -23,6 +23,7 @@ import { db } from '@/lib/db';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getUserBranches } from '@/features/foundation/branch-scope/service';
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
import { canViewQuotationPricing } from '@/features/crm/security/server/service';
import { listApprovalRequests } from '@/features/foundation/approval/server/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import {
@@ -113,9 +114,7 @@ function average(values: number[]) {
}
function canViewCommercialData(context: DashboardContext) {
return (
context.membershipRole === 'admin' || context.permissions.includes(PERMISSIONS.crmQuotationRead)
);
return context.membershipRole === 'admin' || canViewQuotationPricing(context);
}
function canViewApprovalData(context: DashboardContext) {

View File

@@ -16,8 +16,13 @@ import {
} 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 { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
import {
type CrmSecurityContext,
canAccessScopedCrmRecord
} from '@/features/crm/security/server/service';
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { syncEnquiryPipelineStageFromQuotationStatus } from '@/features/crm/enquiries/server/service';
@@ -70,6 +75,8 @@ const QUOTATION_OPTION_CATEGORIES = {
const REVISION_ALLOWED_STATUS_CODES = new Set(['accepted', 'rejected', 'sent', 'approved']);
export type QuotationAccessContext = CrmSecurityContext;
function mapOption(
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
): QuotationOption {
@@ -450,7 +457,11 @@ async function assertEnquiryBelongsToOrganization(id: string, organizationId: st
return enquiry;
}
async function assertQuotationBelongsToOrganization(id: string, organizationId: string) {
async function assertQuotationBelongsToOrganization(
id: string,
organizationId: string,
accessContext?: QuotationAccessContext
) {
const [quotation] = await db
.select()
.from(crmQuotations)
@@ -467,9 +478,45 @@ async function assertQuotationBelongsToOrganization(id: string, organizationId:
throw new AuthError('Quotation not found', 404);
}
if (accessContext && !canAccessQuotationRow(quotation, accessContext)) {
throw new AuthError('Forbidden', 403);
}
return quotation;
}
function canAccessQuotationRow(
row: typeof crmQuotations.$inferSelect,
accessContext: QuotationAccessContext
) {
return canAccessScopedCrmRecord(accessContext, {
branchId: row.branchId,
productType: row.quotationType,
createdBy: row.createdBy,
ownerUserId: row.salesmanId
});
}
function buildQuotationAccessScopedFilters(accessContext: QuotationAccessContext): SQL[] {
const filters: SQL[] = [];
if (accessContext.branchScopeMode === 'assigned' && accessContext.branchScopeIds.length > 0) {
filters.push(inArray(crmQuotations.branchId, accessContext.branchScopeIds));
}
if (accessContext.productScopeMode === 'assigned' && accessContext.productTypeScopeIds.length > 0) {
filters.push(inArray(crmQuotations.quotationType, accessContext.productTypeScopeIds));
}
if (accessContext.membershipRole !== 'admin' && accessContext.ownershipScope === 'own') {
filters.push(
or(eq(crmQuotations.salesmanId, accessContext.userId), eq(crmQuotations.createdBy, accessContext.userId))!
);
}
return filters;
}
async function assertQuotationItemBelongsToQuotation(
quotationId: string,
itemId: string,
@@ -607,7 +654,11 @@ async function assertSalesmanBelongsToOrganization(userId: string, organizationI
}
}
async function validateQuotationPayload(organizationId: string, payload: QuotationMutationPayload) {
async function validateQuotationPayload(
organizationId: string,
payload: QuotationMutationPayload,
accessContext?: QuotationAccessContext
) {
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
if (payload.contactId) {
@@ -626,6 +677,10 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati
await validateBranchAccess(payload.branchId);
}
if (accessContext && !hasBranchScopeAccess(accessContext, payload.branchId)) {
throw new AuthError('Forbidden branch scope', 403);
}
if (payload.salesmanId) {
await assertSalesmanBelongsToOrganization(payload.salesmanId, organizationId);
}
@@ -652,6 +707,10 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati
payload.sentVia ?? null
);
if (accessContext && !hasProductScopeAccess(accessContext, payload.quotationType)) {
throw new AuthError('Forbidden product scope', 403);
}
for (const projectParty of payload.projectParties ?? []) {
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
await assertMasterOptionValue(
@@ -873,7 +932,11 @@ async function validateQuotationFollowupPayload(
}
}
function buildQuotationFilters(organizationId: string, filters: QuotationFilters): SQL[] {
function buildQuotationFilters(
organizationId: string,
filters: QuotationFilters,
accessContext?: QuotationAccessContext
): SQL[] {
const statuses = splitFilterValue(filters.status);
const quotationTypes = splitFilterValue(filters.quotationType);
const branches = splitFilterValue(filters.branch);
@@ -883,6 +946,7 @@ function buildQuotationFilters(organizationId: string, filters: QuotationFilters
return [
eq(crmQuotations.organizationId, organizationId),
isNull(crmQuotations.deletedAt),
...(accessContext ? buildQuotationAccessScopedFilters(accessContext) : []),
...(statuses.length ? [inArray(crmQuotations.status, statuses)] : []),
...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []),
...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []),
@@ -1058,11 +1122,12 @@ export async function getQuotationReferenceData(
export async function listQuotations(
organizationId: string,
filters: QuotationFilters
filters: QuotationFilters,
accessContext?: QuotationAccessContext
): Promise<{ items: QuotationListItem[]; totalItems: number }> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 10;
const whereFilters = buildQuotationFilters(organizationId, filters);
const whereFilters = buildQuotationFilters(organizationId, filters, accessContext);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const offset = (page - 1) * limit;
@@ -1125,9 +1190,10 @@ export async function listQuotations(
export async function getQuotationDetail(
id: string,
organizationId: string
organizationId: string,
accessContext?: QuotationAccessContext
): Promise<QuotationRecord> {
const quotation = await assertQuotationBelongsToOrganization(id, organizationId);
const quotation = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
const approvedArtifactId =
quotation.approvedArtifactId ??
(quotation.approvedPdfUrl?.startsWith('artifact:')
@@ -1213,9 +1279,10 @@ export async function getQuotationActivity(
export async function createQuotation(
organizationId: string,
userId: string,
payload: QuotationMutationPayload
payload: QuotationMutationPayload,
accessContext?: QuotationAccessContext
) {
await validateQuotationPayload(organizationId, payload);
await validateQuotationPayload(organizationId, payload, accessContext);
const quotationStatusCode = await resolveOptionCodeById(
organizationId,
QUOTATION_OPTION_CATEGORIES.status,
@@ -1299,15 +1366,16 @@ export async function updateQuotation(
id: string,
organizationId: string,
userId: string,
payload: QuotationMutationPayload
payload: QuotationMutationPayload,
accessContext?: QuotationAccessContext
) {
await validateQuotationPayload(organizationId, payload);
await validateQuotationPayload(organizationId, payload, accessContext);
const quotationStatusCode = await resolveOptionCodeById(
organizationId,
QUOTATION_OPTION_CATEGORIES.status,
payload.status
);
const existing = await assertQuotationBelongsToOrganization(id, organizationId);
const existing = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
const enquiry = payload.enquiryId
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
: null;
@@ -1370,8 +1438,13 @@ export async function updateQuotation(
return updated;
}
export async function softDeleteQuotation(id: string, organizationId: string, userId: string) {
await assertQuotationBelongsToOrganization(id, organizationId);
export async function softDeleteQuotation(
id: string,
organizationId: string,
userId: string,
accessContext?: QuotationAccessContext
) {
await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
const now = new Date();
const [updated] = await db

View File

@@ -0,0 +1,111 @@
import { auditAction } from '@/features/foundation/audit-log/service';
import { hasCrmPermission, hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
import { PERMISSIONS } from '@/lib/auth/rbac';
export interface CrmSecurityContext {
organizationId: string;
userId: string;
membershipRole: string;
businessRole: string;
businessRoles?: string[];
permissions: string[];
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: string;
branchScopeMode: string;
productScopeMode: string;
approvalAuthority?: string | null;
}
export function buildCrmSecurityContext(input: {
organizationId: string;
userId: string;
membership: {
role: string;
businessRole: string;
businessRoles?: string[];
permissions?: string[] | null;
branchScopeIds?: string[] | null;
productTypeScopeIds?: string[] | null;
ownershipScope?: string | null;
branchScopeMode?: string | null;
productScopeMode?: string | null;
approvalAuthority?: string | null;
};
}): CrmSecurityContext {
return {
organizationId: input.organizationId,
userId: input.userId,
membershipRole: input.membership.role,
businessRole: input.membership.businessRole,
businessRoles: input.membership.businessRoles ?? [input.membership.businessRole],
permissions: input.membership.permissions ?? [],
branchScopeIds: input.membership.branchScopeIds ?? [],
productTypeScopeIds: input.membership.productTypeScopeIds ?? [],
ownershipScope: input.membership.ownershipScope ?? 'organization',
branchScopeMode: input.membership.branchScopeMode ?? 'all',
productScopeMode: input.membership.productScopeMode ?? 'all',
approvalAuthority: input.membership.approvalAuthority ?? null
};
}
export function canViewQuotationPricing(context: Pick<CrmSecurityContext, 'permissions'>) {
return hasCrmPermission(context, PERMISSIONS.crmQuotationPricingRead);
}
export function canAccessScopedCrmRecord(
context: Pick<
CrmSecurityContext,
| 'membershipRole'
| 'userId'
| 'ownershipScope'
| 'branchScopeMode'
| 'branchScopeIds'
| 'productScopeMode'
| 'productTypeScopeIds'
>,
row: {
branchId?: string | null;
productType?: string | null;
createdBy?: string | null;
ownerUserId?: string | null;
}
) {
if (!hasBranchScopeAccess(context, row.branchId)) {
return false;
}
if (!hasProductScopeAccess(context, row.productType)) {
return false;
}
if (context.membershipRole === 'admin' || context.ownershipScope !== 'own') {
return true;
}
return row.createdBy === context.userId || row.ownerUserId === context.userId;
}
export async function auditCrmSecurityEvent(input: {
organizationId: string;
userId: string;
action: 'access_denied' | 'scope_violation' | 'permission_violation' | 'pricing_hidden';
entityType: string;
entityId: string;
reason: string;
metadata?: Record<string, unknown>;
}) {
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_security_access',
entityId: `${input.entityType}:${input.entityId}`,
action: input.action,
afterData: {
targetEntityType: input.entityType,
targetEntityId: input.entityId,
reason: input.reason,
...input.metadata
}
});
}

View File

@@ -11,6 +11,8 @@ import {
users
} from '@/db/schema';
import { db } from '@/lib/db';
import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access';
import { type SystemRole } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { auditAction } from '@/features/foundation/audit-log/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
@@ -611,7 +613,17 @@ async function assertActorCanHandleStep(organizationId: string, userId: string,
throw new AuthError('Organization membership required', 403);
}
if (membership.role === 'admin' || membership.businessRole === roleCode) {
const resolvedAccess = await resolveCrmMembershipAccess({
systemRole: userRow.systemRole as SystemRole,
organizationId,
membership
});
if (
membership.role === 'admin' ||
resolvedAccess.businessRoles.includes(roleCode) ||
resolvedAccess.businessRole === roleCode
) {
return;
}