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

@@ -1,6 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditAction } from '@/features/foundation/audit-log/service';
import { getCrmDashboardData } from '@/features/crm/dashboard/server/service';
import {
auditCrmSecurityEvent,
buildCrmSecurityContext,
canViewQuotationPricing
} from '@/features/crm/security/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -34,19 +39,27 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const section = searchParams.get('section') ?? 'sales-ranking';
const format = searchParams.get('format') ?? 'csv';
const data = await getCrmDashboardData(
{
const securityContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
if (section === 'revenue-analytics' && !canViewQuotationPricing(securityContext)) {
await auditCrmSecurityEvent({
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
},
action: 'permission_violation',
entityType: 'crm_dashboard_export',
entityId: section,
reason: 'Revenue export requires quotation pricing visibility',
metadata: { format }
});
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const data = await getCrmDashboardData(
securityContext,
{
dateFrom: searchParams.get('dateFrom'),
dateTo: searchParams.get('dateTo'),

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCrmDashboardData } from '@/features/crm/dashboard/server/service';
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -9,19 +10,13 @@ export async function GET(request: NextRequest) {
permission: PERMISSIONS.crmDashboardRead
});
const searchParams = request.nextUrl.searchParams;
const securityContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const data = await getCrmDashboardData(
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
},
securityContext,
{
dateFrom: searchParams.get('dateFrom'),
dateTo: searchParams.get('dateTo'),

View File

@@ -1,5 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { downloadArtifact } from '@/features/foundation/document-artifact/server/service';
import {
downloadArtifact,
getArtifact
} from '@/features/foundation/document-artifact/server/service';
import {
auditCrmSecurityEvent,
buildCrmSecurityContext,
canViewQuotationPricing
} from '@/features/crm/security/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -10,9 +18,33 @@ type Params = {
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentArtifactDownload
});
const securityContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const artifact = await getArtifact(id, organization.id);
if (
artifact.entityType === 'quotation' &&
artifact.artifactType === 'approved_pdf' &&
!canViewQuotationPricing(securityContext)
) {
await auditCrmSecurityEvent({
organizationId: organization.id,
userId: session.user.id,
action: 'pricing_hidden',
entityType: artifact.entityType,
entityId: artifact.entityId,
reason: 'Approved quotation artifact download requires pricing visibility',
metadata: { artifactId: artifact.id }
});
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const { object } = await downloadArtifact({
artifactId: id,
organizationId: organization.id,

View File

@@ -1,5 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditAction } from '@/features/foundation/audit-log/service';
import {
auditCrmSecurityEvent,
buildCrmSecurityContext,
canViewQuotationPricing
} from '@/features/crm/security/server/service';
import {
generateApprovedQuotationPdf,
getStoredApprovedQuotationPdf
@@ -15,9 +20,27 @@ type Params = {
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationPdfDownload
});
const securityContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
if (!canViewQuotationPricing(securityContext)) {
await auditCrmSecurityEvent({
organizationId: organization.id,
userId: session.user.id,
action: 'pricing_hidden',
entityType: 'crm_quotation',
entityId: id,
reason: 'Approved quotation PDF download requires pricing visibility'
});
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const pdf = await getStoredApprovedQuotationPdf(id, organization.id, {
userId: session.user.id,
auditDownload: true
@@ -40,12 +63,30 @@ export async function GET(_request: NextRequest, { params }: Params) {
export async function POST(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationPdfGenerateApproved
});
const before = await getQuotationDetail(id, organization.id);
const securityContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
if (!canViewQuotationPricing(securityContext)) {
await auditCrmSecurityEvent({
organizationId: organization.id,
userId: session.user.id,
action: 'pricing_hidden',
entityType: 'crm_quotation',
entityId: id,
reason: 'Approved quotation PDF generation requires pricing visibility'
});
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const before = await getQuotationDetail(id, organization.id, securityContext);
const pdf = await generateApprovedQuotationPdf(id, organization.id, session.user.id);
const after = await getQuotationDetail(id, organization.id);
const after = await getQuotationDetail(id, organization.id, securityContext);
await auditAction({
organizationId: organization.id,

View File

@@ -1,5 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { buildQuotationDocumentData } from '@/features/crm/quotations/document/server/service';
import {
auditCrmSecurityEvent,
buildCrmSecurityContext,
canViewQuotationPricing
} from '@/features/crm/security/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -10,9 +15,27 @@ type Params = {
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationDocumentPreview
});
const securityContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
if (!canViewQuotationPricing(securityContext)) {
await auditCrmSecurityEvent({
organizationId: organization.id,
userId: session.user.id,
action: 'pricing_hidden',
entityType: 'crm_quotation',
entityId: id,
reason: 'Quotation document data requires pricing visibility'
});
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const documentData = await buildQuotationDocumentData(id, organization.id);
return NextResponse.json({

View File

@@ -1,5 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { getQuotationDocumentPreviewData } from '@/features/crm/quotations/document/server/service';
import {
auditCrmSecurityEvent,
buildCrmSecurityContext,
canViewQuotationPricing
} from '@/features/crm/security/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -10,9 +15,27 @@ type Params = {
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationDocumentPreview
});
const securityContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
if (!canViewQuotationPricing(securityContext)) {
await auditCrmSecurityEvent({
organizationId: organization.id,
userId: session.user.id,
action: 'pricing_hidden',
entityType: 'crm_quotation',
entityId: id,
reason: 'Quotation document preview requires pricing visibility'
});
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const preview = await getQuotationDocumentPreviewData(id, organization.id);
return NextResponse.json({

View File

@@ -1,5 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateQuotationPdf } from '@/features/foundation/pdf-generator/server/service';
import {
auditCrmSecurityEvent,
buildCrmSecurityContext,
canViewQuotationPricing
} from '@/features/crm/security/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -10,9 +15,27 @@ type Params = {
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationPdfDownload
});
const securityContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
if (!canViewQuotationPricing(securityContext)) {
await auditCrmSecurityEvent({
organizationId: organization.id,
userId: session.user.id,
action: 'pricing_hidden',
entityType: 'crm_quotation',
entityId: id,
reason: 'Quotation PDF download requires pricing visibility'
});
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const pdf = await generateQuotationPdf(id, organization.id);
return new NextResponse(pdf.buffer, {

View File

@@ -1,5 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateQuotationPreviewPdf } from '@/features/foundation/pdf-generator/server/service';
import {
auditCrmSecurityEvent,
buildCrmSecurityContext,
canViewQuotationPricing
} from '@/features/crm/security/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -10,9 +15,27 @@ type Params = {
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationPdfPreview
});
const securityContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
if (!canViewQuotationPricing(securityContext)) {
await auditCrmSecurityEvent({
organizationId: organization.id,
userId: session.user.id,
action: 'pricing_hidden',
entityType: 'crm_quotation',
entityId: id,
reason: 'Quotation PDF preview requires pricing visibility'
});
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const pdf = await generateQuotationPreviewPdf(id, organization.id);
return new NextResponse(pdf.buffer, {

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
import {
getQuotationActivity,
getQuotationDetail,
@@ -18,11 +19,16 @@ type Params = {
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationRead
});
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const [quotation, activity] = await Promise.all([
getQuotationDetail(id, organization.id),
getQuotationDetail(id, organization.id, accessContext),
getQuotationActivity(id, organization.id)
]);
@@ -49,12 +55,23 @@ export async function GET(_request: NextRequest, { params }: Params) {
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationUpdate
});
const payload = quotationSchema.parse(await request.json());
const before = await getQuotationDetail(id, organization.id);
const updated = await updateQuotation(id, organization.id, session.user.id, payload);
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const before = await getQuotationDetail(id, organization.id, accessContext);
const updated = await updateQuotation(
id,
organization.id,
session.user.id,
payload,
accessContext
);
await auditUpdate({
organizationId: organization.id,
@@ -94,11 +111,16 @@ export async function PATCH(request: NextRequest, { params }: Params) {
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationDelete
});
const before = await getQuotationDetail(id, organization.id);
const updated = await softDeleteQuotation(id, organization.id, session.user.id);
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const before = await getQuotationDetail(id, organization.id, accessContext);
const updated = await softDeleteQuotation(id, organization.id, session.user.id, accessContext);
await auditDelete({
organizationId: organization.id,

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
import { createQuotation, listQuotations } from '@/features/crm/quotations/server/service';
import { quotationSchema } from '@/features/crm/quotations/schemas/quotation.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
@@ -8,7 +9,7 @@ import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationRead
});
const { searchParams } = request.nextUrl;
@@ -22,6 +23,11 @@ export async function GET(request: NextRequest) {
const enquiry = searchParams.get('enquiry') ?? undefined;
const hot = searchParams.get('hot') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const result = await listQuotations(organization.id, {
page,
limit,
@@ -33,7 +39,7 @@ export async function GET(request: NextRequest) {
enquiry,
hot,
sort
});
}, accessContext);
const offset = (page - 1) * limit;
return NextResponse.json({
@@ -60,11 +66,16 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationCreate
});
const payload = quotationSchema.parse(await request.json());
const created = await createQuotation(organization.id, session.user.id, payload);
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const created = await createQuotation(organization.id, session.user.id, payload, accessContext);
await auditCreate({
organizationId: organization.id,

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;
}

View File

@@ -380,3 +380,16 @@ export function hasProductScopeAccess(access: ProductScopedAccess, productTypeId
return access.productTypeScopeIds.includes(productTypeId);
}
export function hasCrmPermission(
access: Pick<ResolvedCrmAccess, 'permissions'> | { permissions?: string[] | null },
permission: string
) {
return (access.permissions ?? []).includes(permission as Permission);
}
export function canViewQuotationPricing(
access: Pick<ResolvedCrmAccess, 'permissions'> | { permissions?: string[] | null }
) {
return hasCrmPermission(access, 'crm.quotation.pricing.read');
}

View File

@@ -60,6 +60,7 @@ export async function requireOrganizationAccess(options?: {
organizationId: activeOrganization.id,
role: activeOrganization.role,
businessRole: activeOrganization.businessRole,
businessRoles: [activeOrganization.businessRole],
permissions: session.user.activePermissions,
branchScopeIds: session.user.activeBranchScopeIds,
productTypeScopeIds: session.user.activeProductTypeScopeIds,
@@ -126,11 +127,12 @@ export async function requireOrganizationAccess(options?: {
return {
session,
membership: {
...membership,
permissions: resolvedAccess.permissions,
branchScopeIds: resolvedAccess.branchScopeIds,
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
membership: {
...membership,
businessRoles: resolvedAccess.businessRoles,
permissions: resolvedAccess.permissions,
branchScopeIds: resolvedAccess.branchScopeIds,
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
ownershipScope: resolvedAccess.ownershipScope,
branchScopeMode: resolvedAccess.branchScopeMode,
productScopeMode: resolvedAccess.productScopeMode,