This commit is contained in:
phaichayon
2026-06-22 10:22:45 +07:00
parent 51d67ef7c2
commit b154a8de33
37 changed files with 5952 additions and 332 deletions

View File

@@ -22,6 +22,7 @@ import {
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 { listApprovalRequests } from '@/features/foundation/approval/server/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import {
@@ -57,6 +58,11 @@ type DashboardContext = {
membershipRole: string;
businessRole: string;
permissions: string[];
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: string;
branchScopeMode: string;
productScopeMode: string;
};
type NormalizedFilters = {
@@ -73,15 +79,6 @@ type StatusMaps = {
quotationStatusById: Map<string, string>;
};
const DASHBOARD_FULL_ACCESS_ROLES = new Set([
'marketing',
'sales_manager',
'department_manager',
'top_manager'
]);
const DASHBOARD_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
function normalizeDashboardFilters(filters: CrmDashboardFilters): NormalizedFilters {
const now = new Date();
const monthStart = startOfDay(new Date(now.getFullYear(), now.getMonth(), 1));
@@ -128,20 +125,26 @@ function canViewApprovalData(context: DashboardContext) {
}
function hasDashboardFullScope(context: DashboardContext) {
return (
context.membershipRole === 'admin' || DASHBOARD_FULL_ACCESS_ROLES.has(context.businessRole)
);
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
}
function canAccessDashboardEnquiry(
row: typeof crmEnquiries.$inferSelect,
context: DashboardContext
) {
if (!hasBranchScopeAccess(context, row.branchId)) {
return false;
}
if (!hasProductScopeAccess(context, row.productType)) {
return false;
}
if (hasDashboardFullScope(context)) {
return true;
}
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) {
if (context.ownershipScope === 'own') {
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
}
@@ -152,11 +155,19 @@ function canAccessDashboardQuotation(
row: typeof crmQuotations.$inferSelect,
context: DashboardContext
) {
if (!hasBranchScopeAccess(context, row.branchId)) {
return false;
}
if (!hasProductScopeAccess(context, row.quotationType)) {
return false;
}
if (hasDashboardFullScope(context)) {
return true;
}
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) {
if (context.ownershipScope === 'own') {
return row.salesmanId === context.userId;
}

View File

@@ -11,6 +11,7 @@ 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 { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
@@ -50,13 +51,6 @@ const ENQUIRY_OPTION_CATEGORIES = {
} as const;
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
const SALES_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
const ENQUIRY_FULL_ACCESS_ROLES = new Set([
'marketing',
'sales_manager',
'department_manager',
'top_manager'
]);
const SALES_CREATED_ENQUIRY_ROLES = new Set([
'sales',
'sales_support',
@@ -73,6 +67,11 @@ export interface EnquiryAccessContext {
userId: string;
membershipRole: string;
businessRole: string;
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: string;
branchScopeMode: string;
productScopeMode: string;
}
function mapOption(
@@ -141,20 +140,26 @@ function mapEnquiryRecord(
}
function hasEnquiryFullAccess(context: EnquiryAccessContext) {
return (
context.membershipRole === 'admin' || ENQUIRY_FULL_ACCESS_ROLES.has(context.businessRole)
);
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
}
function canAccessEnquiryRow(
row: typeof crmEnquiries.$inferSelect,
context: EnquiryAccessContext
) {
if (!hasBranchScopeAccess(context, row.branchId)) {
return false;
}
if (!hasProductScopeAccess(context, row.productType)) {
return false;
}
if (hasEnquiryFullAccess(context)) {
return true;
}
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
if (context.ownershipScope === 'own') {
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
}
@@ -162,17 +167,23 @@ function canAccessEnquiryRow(
}
function buildAccessScopedFilters(context: EnquiryAccessContext): SQL[] {
if (hasEnquiryFullAccess(context)) {
return [];
const filters: SQL[] = [];
if (context.branchScopeMode === 'assigned' && context.branchScopeIds.length > 0) {
filters.push(inArray(crmEnquiries.branchId, context.branchScopeIds));
}
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
return [
if (context.productScopeMode === 'assigned' && context.productTypeScopeIds.length > 0) {
filters.push(inArray(crmEnquiries.productType, context.productTypeScopeIds));
}
if (!hasEnquiryFullAccess(context) && context.ownershipScope === 'own') {
filters.push(
or(eq(crmEnquiries.createdBy, context.userId), eq(crmEnquiries.assignedToUserId, context.userId))!
];
);
}
return [];
return filters;
}
function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord {
@@ -437,7 +448,11 @@ async function assertFollowupBelongsToEnquiry(
return followup;
}
async function validateEnquiryPayload(organizationId: string, payload: EnquiryMutationPayload) {
async function validateEnquiryPayload(
organizationId: string,
payload: EnquiryMutationPayload,
accessContext?: EnquiryAccessContext
) {
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
if (payload.contactId) {
@@ -465,6 +480,14 @@ async function validateEnquiryPayload(organizationId: string, payload: EnquiryMu
await validateBranchAccess(payload.branchId);
}
if (accessContext && !hasBranchScopeAccess(accessContext, payload.branchId)) {
throw new AuthError('Forbidden branch scope', 403);
}
if (accessContext && !hasProductScopeAccess(accessContext, payload.productType)) {
throw new AuthError('Forbidden product scope', 403);
}
for (const projectParty of payload.projectParties ?? []) {
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
await assertMasterOptionValue(
@@ -996,13 +1019,13 @@ export async function getEnquiryActivity(
export async function createEnquiry(
organizationId: string,
userId: string,
businessRole: string,
accessContext: EnquiryAccessContext,
payload: EnquiryMutationPayload
) {
await validateEnquiryPayload(organizationId, payload);
await validateEnquiryPayload(organizationId, payload, accessContext);
const pipelineStage = await resolvePipelineStageForCreate(
organizationId,
businessRole,
accessContext.businessRole,
payload.status
);
@@ -1064,7 +1087,7 @@ export async function updateEnquiry(
payload: EnquiryMutationPayload,
accessContext?: EnquiryAccessContext
) {
await validateEnquiryPayload(organizationId, payload);
await validateEnquiryPayload(organizationId, payload, accessContext);
const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload);
@@ -1167,9 +1190,10 @@ async function updateEnquiryAssignment(
organizationId: string,
userId: string,
payload: EnquiryAssignmentMutationPayload,
mode: 'assign' | 'reassign'
mode: 'assign' | 'reassign',
accessContext?: EnquiryAccessContext
) {
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
if (mode === 'assign' && enquiry.assignedToUserId) {
throw new AuthError('Enquiry is already assigned. Use reassign instead.', 400);
@@ -1202,18 +1226,20 @@ export async function assignEnquiryToUser(
id: string,
organizationId: string,
userId: string,
payload: EnquiryAssignmentMutationPayload
payload: EnquiryAssignmentMutationPayload,
accessContext?: EnquiryAccessContext
) {
return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign');
return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign', accessContext);
}
export async function reassignEnquiryToUser(
id: string,
organizationId: string,
userId: string,
payload: EnquiryAssignmentMutationPayload
payload: EnquiryAssignmentMutationPayload,
accessContext?: EnquiryAccessContext
) {
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign');
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign', accessContext);
}
export async function listEnquiryFollowups(