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

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