task-l.3
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
111
src/features/crm/security/server/service.ts
Normal file
111
src/features/crm/security/server/service.ts
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user