task-l.3
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user