task-l.3.1
This commit is contained in:
@@ -15,6 +15,10 @@ 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 {
|
||||
type CrmSecurityContext,
|
||||
canAccessScopedCrmRecord
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import type {
|
||||
ApprovalActionRecord,
|
||||
@@ -49,6 +53,7 @@ const APPROVAL_ACTIONS = {
|
||||
} as const;
|
||||
|
||||
const QUOTATION_STATUS_CATEGORY = 'crm_quotation_status';
|
||||
export type ApprovalAccessContext = CrmSecurityContext;
|
||||
|
||||
function mapWorkflowRecord(row: typeof crmApprovalWorkflows.$inferSelect): ApprovalWorkflowRecord {
|
||||
return {
|
||||
@@ -683,24 +688,61 @@ function buildApprovalFilters(organizationId: string, filters: ApprovalFilters):
|
||||
];
|
||||
}
|
||||
|
||||
function canCurrentActorAccessApproval(
|
||||
currentStep: ApprovalStepRecord | null,
|
||||
accessContext?: ApprovalAccessContext
|
||||
) {
|
||||
if (!accessContext) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
accessContext.membershipRole === 'admin' ||
|
||||
accessContext.ownershipScope === 'organization'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!currentStep) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
accessContext.businessRole === currentStep.roleCode ||
|
||||
(accessContext.businessRoles ?? []).includes(currentStep.roleCode)
|
||||
);
|
||||
}
|
||||
|
||||
function canAccessQuotationForApproval(
|
||||
quotation: typeof crmQuotations.$inferSelect | null | undefined,
|
||||
accessContext?: ApprovalAccessContext
|
||||
) {
|
||||
if (!quotation || !accessContext) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return canAccessScopedCrmRecord(accessContext, {
|
||||
branchId: quotation.branchId,
|
||||
productType: quotation.quotationType,
|
||||
createdBy: quotation.createdBy,
|
||||
ownerUserId: quotation.salesmanId
|
||||
});
|
||||
}
|
||||
|
||||
export async function listApprovalRequests(
|
||||
organizationId: string,
|
||||
filters: ApprovalFilters
|
||||
filters: ApprovalFilters,
|
||||
accessContext?: ApprovalAccessContext
|
||||
): Promise<{ items: ApprovalListItem[]; totalItems: number }> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 10;
|
||||
const whereFilters = buildApprovalFilters(organizationId, filters);
|
||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [totalResult] = await db.select({ value: count() }).from(crmApprovalRequests).where(where);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmApprovalRequests)
|
||||
.where(where)
|
||||
.orderBy(parseSort(filters.sort))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
.orderBy(parseSort(filters.sort));
|
||||
|
||||
const workflowIds = [...new Set(rows.map((row) => row.workflowId))];
|
||||
const requesterIds = [...new Set(rows.map((row) => row.requestedBy))];
|
||||
@@ -740,11 +782,37 @@ export async function listApprovalRequests(
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: rows.map((row) => {
|
||||
const quotationIds = rows
|
||||
.filter((row) => row.entityType === 'quotation')
|
||||
.map((row) => row.entityId);
|
||||
const quotations = quotationIds.length
|
||||
? await db
|
||||
.select()
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.organizationId, organizationId),
|
||||
inArray(crmQuotations.id, quotationIds),
|
||||
isNull(crmQuotations.deletedAt)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
const quotationMap = new Map(quotations.map((row) => [row.id, row]));
|
||||
|
||||
const visibleItems = rows
|
||||
.map((row) => {
|
||||
const workflow = workflowMap.get(row.workflowId);
|
||||
const currentStep = stepMap.get(`${row.workflowId}:${row.currentStep}`) ?? null;
|
||||
const entitySummary = entitySummaries.get(`${row.entityType}:${row.entityId}`) ?? null;
|
||||
const canAccess =
|
||||
!accessContext ||
|
||||
canCurrentActorAccessApproval(currentStep, accessContext) ||
|
||||
(row.entityType === 'quotation' &&
|
||||
canAccessQuotationForApproval(quotationMap.get(row.entityId), accessContext));
|
||||
|
||||
if (!canAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...mapRequestRecord(row),
|
||||
@@ -755,9 +823,14 @@ export async function listApprovalRequests(
|
||||
requestedByName: requesterMap.get(row.requestedBy) ?? null,
|
||||
entityCode: entitySummary?.entityCode ?? null,
|
||||
entityTitle: entitySummary?.entityTitle ?? null
|
||||
};
|
||||
}),
|
||||
totalItems: totalResult?.value ?? 0
|
||||
} satisfies ApprovalListItem;
|
||||
})
|
||||
.filter((item): item is ApprovalListItem => item !== null);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
return {
|
||||
items: visibleItems.slice(offset, offset + limit),
|
||||
totalItems: visibleItems.length
|
||||
};
|
||||
}
|
||||
|
||||
@@ -815,7 +888,8 @@ export async function getCurrentApprovalStep(
|
||||
|
||||
export async function getApprovalRequest(
|
||||
id: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
accessContext?: ApprovalAccessContext
|
||||
): Promise<ApprovalDetailRecord> {
|
||||
const requestRow = await assertApprovalRequest(id, organizationId);
|
||||
const [workflowRow] = await db
|
||||
@@ -838,6 +912,31 @@ export async function getApprovalRequest(
|
||||
const currentStep = steps.find((step) => step.stepNumber === requestRow.currentStep) ?? null;
|
||||
const entitySummary =
|
||||
entitySummaries.get(`${requestRow.entityType}:${requestRow.entityId}`) ?? null;
|
||||
const quotation =
|
||||
requestRow.entityType === 'quotation'
|
||||
? (
|
||||
await db
|
||||
.select()
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.organizationId, organizationId),
|
||||
eq(crmQuotations.id, requestRow.entityId),
|
||||
isNull(crmQuotations.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)[0]
|
||||
: null;
|
||||
|
||||
const canAccess =
|
||||
!accessContext ||
|
||||
canCurrentActorAccessApproval(currentStep, accessContext) ||
|
||||
(requestRow.entityType === 'quotation' && canAccessQuotationForApproval(quotation, accessContext));
|
||||
|
||||
if (!canAccess) {
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
|
||||
return {
|
||||
request: mapRequestRecord(requestRow),
|
||||
|
||||
Reference in New Issue
Block a user