feat(acl): deep data access by section for roles and users

Role default + user override with OR rules; covers customers, leads, sales, activities, notes, routines, projects, and tickets.
This commit is contained in:
2026-10-22 11:53:26 +03:30
parent cb5944e767
commit 09a60b3b5a
25 changed files with 1997 additions and 122 deletions
+233
View File
@@ -374,6 +374,15 @@ export interface OrgUserDto {
dataVisibility?: DataVisibility;
/** سطح دسترسی تفکیکی هر بخش (فقط بخش‌هایی که با پیش‌فرض فرق دارند) */
dataVisibilityScopes?: DataVisibilityScopes;
/**
* Override عمق داده نسبت به نقش؛ کلید غایب = ارث از نقش.
* اگر کل آبجکت خالی باشد و نقش هم خالی باشد → بدون محدودیت عمقی.
*/
dataAccessOverride?: DataAccessPolicy;
/** سیاست مؤثر پس از resolve (نقش + override) — فقط برای نمایش UI */
effectiveDataAccess?: DataAccessPolicy;
/** @deprecated از effectiveDataAccess.customers استفاده کنید */
effectiveCustomersAccess?: SectionDataAccess;
/** تیک مالک: این کاربر سوئیچ بایگانی اطلاعات قبل را ببیند */
priorArchiveEnabled?: boolean;
/** ساعت خروج روزانه HH:mm به وقت تهران؛ خالی یعنی بدون محدودیت */
@@ -438,6 +447,226 @@ export interface UpdateUserVisibilityRequest {
scopes?: Partial<Record<DataScope, DataVisibility | null>>;
}
/* ——— Deep data access (customers + leads + sales + activities + notes/routines/projects/tickets) ——— */
export type DataAccessOwnerKind = "self" | "team";
export type DataAccessMode = "all" | "rules";
/**
* بخش‌های عمق داده.
* customers/leads/sales/activities/notes ≈ RolePermissionKey؛
* routines/projects/tickets ≈ ماژول / DataScope (بدون مجوز نقش جدا).
*/
export type DataAccessSection =
| "customers"
| "leads"
| "sales"
| "activities"
| "notes"
| "routines"
| "projects"
| "tickets";
export const DATA_ACCESS_SECTIONS: DataAccessSection[] = [
"customers",
"leads",
"sales",
"activities",
"notes",
"routines",
"projects",
"tickets",
];
export const DATA_ACCESS_SECTION_LABELS: Record<DataAccessSection, string> = {
customers: "مشتریان (اشخاص و شرکت‌ها)",
leads: "لیدها",
sales: "فروش و معاملات",
activities: "فعالیت‌ها",
notes: "یادداشت‌ها",
routines: "کارهای روتین",
projects: "پروژه‌ها",
tickets: "تیکت‌ها",
};
/** نگاشت بخش عمق‌داده ← کلید قدیمی DataScope برای مهاجرت */
export const DATA_ACCESS_LEGACY_SCOPE: Record<DataAccessSection, DataScope | null> = {
customers: "contacts",
leads: "leads",
sales: "deals",
activities: "activities",
notes: "notes",
routines: "routines",
projects: "projects",
tickets: "tickets",
};
/**
* یک قانون: فیلدهای پرشده با AND.
* قوانین متعدد در SectionDataAccess با OR ترکیب می‌شوند.
* ابعاد بلااستفاده برای یک بخش نادیده گرفته می‌شوند.
*/
export type DataAccessRule = {
/** self / team → مالک / مسئول / نویسنده (بسته به بخش) */
owners?: DataAccessOwnerKind[];
/** مشتریان: گروه‌ها */
groupIds?: string[];
/** مشتریان: importSource/sourceRef · لیدها: source · یادداشت: noteType · تیکت: status */
sources?: string[];
/** مشتریان: حال */
moods?: PersonMood[];
/** لید / معامله / فعالیت / پروژه: مرحله */
stageIds?: string[];
/** معامله: پایپ‌لاین */
pipelineIds?: string[];
/** فعالیت / پروژه: بورد */
boardIds?: string[];
/** فعالیت: typeDefId · روتین: categoryId */
typeIds?: string[];
};
/** @deprecated نام قبلی؛ از DataAccessRule استفاده کنید */
export type ContactAccessRule = DataAccessRule;
export type SectionDataAccess = {
mode: DataAccessMode;
rules?: DataAccessRule[];
};
export type DataAccessPolicy = Partial<Record<DataAccessSection, SectionDataAccess>>;
export interface UpdateUserDataAccessRequest {
/**
* null یعنی حذف override و برگشت به ارث از نقش.
* آبجکت یعنی جایگزینی کامل override (کلید غایب در آبجکت = آن بخش از override پاک می‌شود).
*/
dataAccessOverride?: DataAccessPolicy | null;
}
export function emptySectionDataAccess(): SectionDataAccess {
return { mode: "all", rules: [] };
}
export function parseDataAccessPolicy(raw: unknown): DataAccessPolicy {
const out: DataAccessPolicy = {};
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
const obj = raw as Record<string, unknown>;
for (const section of DATA_ACCESS_SECTIONS) {
const parsed = parseSectionDataAccess(obj[section]);
if (parsed) out[section] = parsed;
}
return out;
}
export function parseSectionDataAccess(raw: unknown): SectionDataAccess | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const obj = raw as Record<string, unknown>;
const mode: DataAccessMode = obj.mode === "rules" ? "rules" : "all";
const rulesRaw = Array.isArray(obj.rules) ? obj.rules : [];
const rules: DataAccessRule[] = [];
for (const item of rulesRaw) {
const rule = parseDataAccessRule(item);
if (rule) rules.push(rule);
}
if (mode === "all") return { mode: "all", rules: [] };
return { mode: "rules", rules };
}
function parseStringIdList(raw: unknown): string[] | undefined {
if (!Array.isArray(raw)) return undefined;
const list = raw
.filter((v): v is string => typeof v === "string" && v.trim().length > 0)
.map((v) => v.trim());
return list.length ? [...new Set(list)] : undefined;
}
export function parseDataAccessRule(raw: unknown): DataAccessRule | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const obj = raw as Record<string, unknown>;
const rule: DataAccessRule = {};
if (Array.isArray(obj.owners)) {
const owners = obj.owners.filter(
(v): v is DataAccessOwnerKind => v === "self" || v === "team",
);
if (owners.length) rule.owners = [...new Set(owners)];
}
const groupIds = parseStringIdList(obj.groupIds);
if (groupIds) rule.groupIds = groupIds;
const sources = parseStringIdList(obj.sources);
if (sources) rule.sources = sources;
if (Array.isArray(obj.moods)) {
const moods = obj.moods.filter(
(v): v is PersonMood =>
typeof v === "string" && (PERSON_MOODS as readonly string[]).includes(v),
);
if (moods.length) rule.moods = [...new Set(moods)];
}
const stageIds = parseStringIdList(obj.stageIds);
if (stageIds) rule.stageIds = stageIds;
const pipelineIds = parseStringIdList(obj.pipelineIds);
if (pipelineIds) rule.pipelineIds = pipelineIds;
const boardIds = parseStringIdList(obj.boardIds);
if (boardIds) rule.boardIds = boardIds;
const typeIds = parseStringIdList(obj.typeIds);
if (typeIds) rule.typeIds = typeIds;
if (
!rule.owners &&
!rule.groupIds &&
!rule.sources &&
!rule.moods &&
!rule.stageIds &&
!rule.pipelineIds &&
!rule.boardIds &&
!rule.typeIds
) {
return null;
}
return rule;
}
/** @deprecated */
export const parseContactAccessRule = parseDataAccessRule;
/** آیا این بخش محدودیت عمقی دارد؟ mode=all یا بدون قانون معتبر = بدون محدودیت */
export function sectionHasDataAccessLimits(section: SectionDataAccess | null | undefined): boolean {
if (!section || section.mode !== "rules") return false;
return Boolean(section.rules?.length);
}
/**
* مهاجرت از مدل قدیمی own/team/org به SectionDataAccess.
* اگر scopeKey داده شود، سطح تفکیکی همان بخش اولویت دارد.
*/
export function migrateVisibilityToSectionAccess(
dataVisibility: DataVisibility | string | null | undefined,
scopes?: DataVisibilityScopes | null,
scopeKey?: DataScope | null,
): SectionDataAccess {
const scoped =
scopeKey && scopes
? scopes[scopeKey]
: undefined;
const level =
scoped === "own" || scoped === "team" || scoped === "org"
? scoped
: dataVisibility === "team" || dataVisibility === "org" || dataVisibility === "own"
? dataVisibility
: "own";
if (level === "org") return { mode: "all", rules: [] };
if (level === "team") return { mode: "rules", rules: [{ owners: ["self", "team"] }] };
return { mode: "rules", rules: [{ owners: ["self"] }] };
}
/** @deprecated از migrateVisibilityToSectionAccess استفاده کنید */
export function migrateVisibilityToCustomersAccess(
dataVisibility: DataVisibility | string | null | undefined,
scopes?: DataVisibilityScopes | null,
): SectionDataAccess {
return migrateVisibilityToSectionAccess(dataVisibility, scopes, "contacts");
}
export type RolePermissionKey =
| "customers"
| "leads"
@@ -486,6 +715,8 @@ export interface OrgRoleDto {
description: string;
color: string;
permissions: RolePermissionKey[];
/** پیش‌فرض عمق داده برای اعضای این نقش */
dataAccessPolicy?: DataAccessPolicy;
systemKey: UserRole | null;
/** نقش والد؛ null یعنی ریشه */
parentId: string | null;
@@ -504,6 +735,7 @@ export interface CreateOrgRoleRequest {
description?: string;
color?: string;
permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy;
/** اگر ست شود، این نقش زیرمجموعهٔ نقش والد خواهد بود */
parentId?: string | null;
}
@@ -513,6 +745,7 @@ export interface UpdateOrgRoleRequest {
description?: string;
color?: string;
permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy;
/** null یعنی جدا کردن از والد و تبدیل به ریشه */
parentId?: string | null;
}