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
@@ -0,0 +1,27 @@
-- Deep data access: role default + membership override (phase 1: customers)
ALTER TABLE "OrgRole" ADD COLUMN IF NOT EXISTS "dataAccessPolicy" JSONB NOT NULL DEFAULT '{}';
ALTER TABLE "Membership" ADD COLUMN IF NOT EXISTS "dataAccessOverride" JSONB NOT NULL DEFAULT '{}';
-- Migrate legacy own/team/org (+ optional contacts scope) into customers override
UPDATE "Membership" m
SET "dataAccessOverride" = jsonb_build_object(
'customers',
CASE
WHEN COALESCE(
NULLIF(m."dataVisibilityScopes"::jsonb ->> 'contacts', ''),
m."dataVisibility"
) = 'org' THEN '{"mode":"all","rules":[]}'::jsonb
WHEN COALESCE(
NULLIF(m."dataVisibilityScopes"::jsonb ->> 'contacts', ''),
m."dataVisibility"
) = 'team' THEN '{"mode":"rules","rules":[{"owners":["self","team"]}]}'::jsonb
ELSE '{"mode":"rules","rules":[{"owners":["self"]}]}'::jsonb
END
)
WHERE m."role" NOT IN ('owner', 'admin')
AND (
m."dataAccessOverride" IS NULL
OR m."dataAccessOverride"::text IN ('{}', 'null')
);
+4
View File
@@ -338,6 +338,8 @@ model OrgRole {
description String @default("")
color String @default("teal")
permissions String[] @default([])
/// عمق داده به‌ازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}}
dataAccessPolicy Json @default("{}")
/// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند
systemKey String?
/// نقش والد برای سلسله‌مراتب زیرنقش‌ها
@@ -370,6 +372,8 @@ model Membership {
dataVisibility String @default("own")
/// سطح دید تفکیکی هر بخش، مثلاً {"contacts":"org","deals":"own"}؛ بخش‌های بدون مقدار = dataVisibility
dataVisibilityScopes Json @default("{}")
/// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy
dataAccessOverride Json @default("{}")
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
priorArchiveEnabled Boolean @default(false)
/// current | archive
+4
View File
@@ -9,6 +9,7 @@ import type {
VoicePersonDraft,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { leadsDeepWhere } from '../common/data-access';
import {
type Actor,
assigneeWhere,
@@ -260,6 +261,9 @@ Rules:
}
private leadWhere(actor: Actor) {
if (actor.leadsAccess !== undefined) {
return leadsDeepWhere(actor);
}
const scoped = forScope(actor, 'leads');
if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {};
if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) {
+36
View File
@@ -4,6 +4,10 @@ import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { DataVisibility } from '@mento/shared';
import { BillingService } from '../billing/billing.service';
import {
resolveAllSectionAccess,
sectionNeedsTeamPeers,
} from '../common/data-access';
import {
type Actor,
normalizeDataVisibility,
@@ -86,8 +90,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
role: true,
dataVisibility: true,
dataVisibilityScopes: true,
dataAccessOverride: true,
priorArchiveEnabled: true,
priorArchiveLens: true,
orgRole: { select: { dataAccessPolicy: true } },
organization: { select: { enabledModules: true, priorArchiveEnabledAt: true } },
},
});
@@ -118,6 +124,27 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
}
const deepAccess = resolveAllSectionAccess({
role: membership.role,
rolePolicy: membership.orgRole?.dataAccessPolicy,
override: membership.dataAccessOverride,
legacyVisibility: dataVisibility,
legacyScopes: scopeLevels,
});
const needsTeamPeers =
!elevatedRole &&
(sectionNeedsTeamPeers(deepAccess.customers) ||
sectionNeedsTeamPeers(deepAccess.leads) ||
sectionNeedsTeamPeers(deepAccess.sales) ||
sectionNeedsTeamPeers(deepAccess.activities) ||
sectionNeedsTeamPeers(deepAccess.notes) ||
sectionNeedsTeamPeers(deepAccess.routines) ||
sectionNeedsTeamPeers(deepAccess.projects) ||
sectionNeedsTeamPeers(deepAccess.tickets));
if (needsTeamPeers) {
teamIds ??= await this.teamPeerIds(membership.organizationId, user.id);
}
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
const priorGranted =
modules.includes('prior_data_archive') && membership.priorArchiveEnabled;
@@ -130,6 +157,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
dataVisibility,
visibleUserIds,
scopes,
customersAccess: deepAccess.customers,
leadsAccess: deepAccess.leads,
salesAccess: deepAccess.sales,
activitiesAccess: deepAccess.activities,
notesAccess: deepAccess.notes,
routinesAccess: deepAccess.routines,
projectsAccess: deepAccess.projects,
ticketsAccess: deepAccess.tickets,
teamPeerIds: teamIds ?? (elevatedRole ? null : [user.id]),
priorLens: priorGranted ? priorLens : 'off',
priorCutoff:
priorGranted && membership.organization.priorArchiveEnabledAt
+465
View File
@@ -0,0 +1,465 @@
import type {
DataAccessPolicy,
DataAccessRule,
DataAccessSection,
DataVisibility,
DataVisibilityScopes,
SectionDataAccess,
} from '@mento/shared';
import {
DATA_ACCESS_LEGACY_SCOPE,
DATA_ACCESS_SECTIONS,
migrateVisibilityToSectionAccess,
parseDataAccessPolicy,
sectionHasDataAccessLimits,
} from '@mento/shared';
import { canViewAllRecords } from '@mento/shared';
import type { Prisma } from '@prisma/client';
import type { Actor } from './record-scope';
function elevated(role: string | null | undefined): boolean {
return canViewAllRecords(role);
}
export type SectionAccessContext = {
section: SectionDataAccess;
source: 'role' | 'override' | 'default';
};
function ownerIdList(
rule: DataAccessRule,
ctx: { userId: string; teamPeerIds: string[] | null },
): string[] {
const ownerIds = new Set<string>();
if (!rule.owners?.length) return [];
if (rule.owners.includes('self')) ownerIds.add(ctx.userId);
if (rule.owners.includes('team')) {
const peers = ctx.teamPeerIds?.length ? ctx.teamPeerIds : [ctx.userId];
for (const id of peers) ownerIds.add(id);
}
return [...ownerIds];
}
/** Resolve یک بخش: override کاربر بر نقش؛ وگرنه مهاجرت از visibility قدیمی. */
export function resolveSectionAccess(
sectionKey: DataAccessSection,
input: {
role: string;
rolePolicy?: unknown;
override?: unknown;
legacyVisibility?: DataVisibility | string | null;
legacyScopes?: DataVisibilityScopes | null;
},
): SectionAccessContext {
if (elevated(input.role)) {
return { section: { mode: 'all', rules: [] }, source: 'default' };
}
const override = parseDataAccessPolicy(input.override);
if (override[sectionKey]) {
return { section: override[sectionKey]!, source: 'override' };
}
const rolePolicy = parseDataAccessPolicy(input.rolePolicy);
if (rolePolicy[sectionKey]) {
return { section: rolePolicy[sectionKey]!, source: 'role' };
}
if (input.legacyVisibility != null || input.legacyScopes) {
return {
section: migrateVisibilityToSectionAccess(
input.legacyVisibility,
input.legacyScopes,
DATA_ACCESS_LEGACY_SCOPE[sectionKey],
),
source: 'default',
};
}
return { section: { mode: 'all', rules: [] }, source: 'default' };
}
/** @deprecated */
export function resolveCustomersAccess(input: {
role: string;
rolePolicy?: unknown;
override?: unknown;
legacyVisibility?: DataVisibility | string | null;
legacyScopes?: DataVisibilityScopes | null;
}): SectionAccessContext {
return resolveSectionAccess('customers', input);
}
export function resolveAllSectionAccess(input: {
role: string;
rolePolicy?: unknown;
override?: unknown;
legacyVisibility?: DataVisibility | string | null;
legacyScopes?: DataVisibilityScopes | null;
}): DataAccessPolicy {
const out: DataAccessPolicy = {};
for (const key of DATA_ACCESS_SECTIONS) {
out[key] = resolveSectionAccess(key, input).section;
}
return out;
}
export function buildContactRuleWhere(
rule: DataAccessRule,
ctx: { userId: string; teamPeerIds: string[] | null },
): Prisma.PersonWhereInput | null {
const and: Prisma.PersonWhereInput[] = [];
const ids = ownerIdList(rule, ctx);
if (rule.owners?.length) {
if (!ids.length) return null;
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
}
if (rule.sources?.length) {
and.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
});
}
if (rule.moods?.length) {
and.push({ mood: { in: rule.moods } });
}
if (!and.length) return null;
return and.length === 1 ? and[0]! : { AND: and };
}
function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
if (!parts.length) return { id: '__no_access__' } as T & { id: string };
return parts.length === 1 ? parts[0]! : { OR: parts };
}
export function customersPersonWhere(
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
): Prisma.PersonWhereInput {
if (elevated(actor.role)) return {};
const section = actor.customersAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.PersonWhereInput[] = [];
for (const rule of section.rules ?? []) {
const w = buildContactRuleWhere(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (w) parts.push(w);
}
return orParts(parts);
}
export function customersCompanyWhere(
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
): Prisma.CompanyWhereInput {
if (elevated(actor.role)) return {};
const section = actor.customersAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.CompanyWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.CompanyWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
}
if (rule.sources?.length) {
and.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
});
}
if (
rule.moods?.length &&
!rule.owners?.length &&
!rule.groupIds?.length &&
!rule.sources?.length
) {
continue;
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function leadsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'leadsAccess' | 'teamPeerIds'>,
): Prisma.LeadWhereInput {
if (elevated(actor.role)) return {};
const section = actor.leadsAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.LeadWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.LeadWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (rule.sources?.length) {
and.push({
OR: rule.sources.map((s) => ({
source: { equals: s, mode: 'insensitive' as const },
})),
});
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function dealsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'salesAccess' | 'teamPeerIds'>,
): Prisma.DealWhereInput {
if (elevated(actor.role)) return {};
const section = actor.salesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.DealWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.DealWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
}
if (rule.pipelineIds?.length) {
and.push({ pipelineId: { in: rule.pipelineIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function activitiesDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'activitiesAccess' | 'teamPeerIds'>,
): Prisma.ActivityWhereInput {
if (elevated(actor.role)) return {};
const section = actor.activitiesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ActivityWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.ActivityWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
{ participantIds: { hasSome: ids } },
],
});
}
if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (rule.typeIds?.length) {
and.push({ typeDefId: { in: rule.typeIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function notesDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'notesAccess' | 'teamPeerIds'>,
): Prisma.NoteWhereInput {
const personalGate: Prisma.NoteWhereInput = {
OR: [
{ visibility: { not: 'personal' } },
{ visibility: 'personal', authorId: actor.userId },
],
};
if (elevated(actor.role)) return personalGate;
const section = actor.notesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return personalGate;
const parts: Prisma.NoteWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.NoteWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ authorId: { in: ids } },
{
deal: {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
},
},
{ person: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } },
],
});
}
if (rule.sources?.length) {
and.push({ noteType: { in: rule.sources } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return {
AND: [personalGate, orParts(parts)],
};
}
export function ticketsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'ticketsAccess' | 'teamPeerIds'>,
): Prisma.TicketWhereInput {
if (elevated(actor.role)) return {};
const section = actor.ticketsAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.TicketWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.TicketWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
});
}
if (rule.sources?.length) {
and.push({
status: {
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
},
});
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function projectsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'projectsAccess' | 'teamPeerIds'>,
): Prisma.ProjectCardWhereInput {
if (elevated(actor.role)) return {};
const section = actor.projectsAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ProjectCardWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.ProjectCardWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
}
if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function routinesDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'routinesAccess' | 'teamPeerIds'>,
): Prisma.RoutineTaskWhereInput {
if (elevated(actor.role)) return {};
const section = actor.routinesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.RoutineTaskWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.RoutineTaskWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
if (rule.typeIds?.length) {
and.push({ categoryId: { in: rule.typeIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
/** اتحاد شناسهٔ owners در قوانین؛ null = بدون محدودیت مالکی (یا mode=all) */
export function sectionOwnerIdUnion(
section: SectionDataAccess | null | undefined,
ctx: { userId: string; teamPeerIds: string[] | null },
): string[] | null {
if (!sectionHasDataAccessLimits(section)) return null;
const ids = new Set<string>();
let sawOwnerRule = false;
let sawNonOwnerRule = false;
for (const rule of section?.rules ?? []) {
if (rule.owners?.length) {
sawOwnerRule = true;
for (const id of ownerIdList(rule, ctx)) ids.add(id);
} else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length ||
rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length ||
rule.typeIds?.length) {
sawNonOwnerRule = true;
}
}
if (sawNonOwnerRule && !sawOwnerRule) return null;
if (!sawOwnerRule) return null;
return [...ids];
}
export function sectionNeedsTeamPeers(section: SectionDataAccess | null | undefined): boolean {
if (!sectionHasDataAccessLimits(section)) return false;
return (section?.rules ?? []).some((r) => r.owners?.includes('team'));
}
export function sanitizeDataAccessPolicyInput(raw: unknown): DataAccessPolicy {
return parseDataAccessPolicy(raw);
}
+59 -11
View File
@@ -1,6 +1,20 @@
import type { DataScope, DataVisibility, DataVisibilityScopes, UserRole } from '@mento/shared';
import type {
DataScope,
DataVisibility,
DataVisibilityScopes,
SectionDataAccess,
UserRole,
} from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
import type { Prisma } from '@prisma/client';
import {
activitiesDeepWhere,
dealsDeepWhere,
notesDeepWhere,
projectsDeepWhere,
routinesDeepWhere,
ticketsDeepWhere,
} from './data-access';
export type ScopeVisibility = {
dataVisibility: DataVisibility;
@@ -20,6 +34,17 @@ export type Actor = {
visibleUserIds?: string[] | null;
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
/** عمق دادهٔ بخش‌ها (resolve شده از نقش + override) */
customersAccess?: SectionDataAccess;
leadsAccess?: SectionDataAccess;
salesAccess?: SectionDataAccess;
activitiesAccess?: SectionDataAccess;
notesAccess?: SectionDataAccess;
routinesAccess?: SectionDataAccess;
projectsAccess?: SectionDataAccess;
ticketsAccess?: SectionDataAccess;
/** هم‌تیمی‌ها برای قوانین owners:team (شامل خود کاربر) */
teamPeerIds?: string[] | null;
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
priorLens?: 'off' | 'current' | 'archive';
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیت‌های ساخته‌شده قبل از آن می‌توانند قبلی باشند */
@@ -93,10 +118,13 @@ export function ownerWhere(
return { ownerId: { in: ids } };
}
/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */
/** معامله: عمق دادهٔ sales (JWT) یا مدل قدیمی own/team/org */
export function dealAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'salesAccess' | 'teamPeerIds'>,
): Prisma.DealWhereInput {
if (actor.salesAccess !== undefined) {
return dealsDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'deals');
if (!ids) return {};
return {
@@ -107,10 +135,13 @@ export function dealAccessWhere(
};
}
/** کارت پروژه: مالک یا یکی از مسئولان در محدوده دید */
/** کارت پروژه: عمق داده یا مدل قدیمی own/team/org */
export function projectCardAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'projectsAccess' | 'teamPeerIds'>,
): Prisma.ProjectCardWhereInput {
if (actor.projectsAccess !== undefined) {
return projectsDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'projects');
if (!ids) return {};
return {
@@ -135,10 +166,13 @@ export function assigneeWhere(
};
}
/** دسترسی به فعالیت: مسئولان یا شرکت‌کنندگان (فیلد شرکت‌کنندگان) در محدوده دید */
/** دسترسی به فعالیت: عمق دادهٔ activities (JWT) یا مدل قدیمی */
export function activityAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'activitiesAccess' | 'teamPeerIds'>,
): Prisma.ActivityWhereInput {
if (actor.activitiesAccess !== undefined) {
return activitiesDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'activities');
if (!ids) return {};
return {
@@ -150,10 +184,13 @@ export function activityAccessWhere(
};
}
/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت در محدوده دید + محرمانگی شخصی */
/** یادداشت: عمق داده یا مدل قدیمی + محرمانگی شخصی */
export function noteAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'notesAccess' | 'teamPeerIds'>,
): Prisma.NoteWhereInput {
if (actor.notesAccess !== undefined) {
return notesDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'notes');
const personalGate: Prisma.NoteWhereInput = {
OR: [
@@ -195,10 +232,13 @@ export function resolveOwnerId(
return actor.userId;
}
/** تیکت: مسئول یا ثبت‌کننده در محدوده دید */
/** تیکت: عمق داده یا مدل قدیمی */
export function ticketAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'ticketsAccess' | 'teamPeerIds'>,
): Prisma.TicketWhereInput {
if (actor.ticketsAccess !== undefined) {
return ticketsDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'tickets');
if (!ids) return {};
return {
@@ -206,6 +246,14 @@ export function ticketAccessWhere(
};
}
/** کار روتین: عمق داده (وقتی JWT ست کرده) */
export function routineTaskAccessWhere(
actor: Pick<Actor, 'userId' | 'role' | 'routinesAccess' | 'teamPeerIds'>,
): Prisma.RoutineTaskWhereInput {
if (actor.routinesAccess === undefined) return {};
return routinesDeepWhere(actor);
}
export function canReassignOwner(role: string | null | undefined): boolean {
return elevated(role);
}
+3 -3
View File
@@ -16,11 +16,11 @@ import {
asCustomFields,
toJsonCustomFields,
} from '../common/custom-fields';
import { customersCompanyWhere } from '../common/data-access';
import {
type Actor,
canReassignOwner,
elevated,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
import { normalizeTags } from '../common/tags';
@@ -112,7 +112,7 @@ export class CompaniesService {
];
const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId,
...ownerWhere(actor),
...customersCompanyWhere(actor),
...(and.length ? { AND: and } : {}),
};
@@ -137,7 +137,7 @@ export class CompaniesService {
async get(actor: Actor, id: string): Promise<CompanyDto> {
const row = await this.prisma.company.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
where: { id, organizationId: actor.organizationId, ...customersCompanyWhere(actor) },
include: companyInclude,
});
if (!row) throw new NotFoundException('شرکت پیدا نشد');
@@ -2,8 +2,9 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/comm
import type { ImportContactsResult, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { customersCompanyWhere } from '../common/data-access';
import type { Actor } from '../common/record-scope';
import { ownerWhere, resolveOwnerId } from '../common/record-scope';
import { resolveOwnerId } from '../common/record-scope';
import {
collectAddresses,
} from '../common/custom-fields';
@@ -776,7 +777,7 @@ export class ContactsExcelService {
where: {
organizationId: actor.organizationId,
sourceRef: didarId,
...ownerWhere(actor),
...customersCompanyWhere(actor),
},
select: { id: true, customFields: true },
});
@@ -787,7 +788,7 @@ export class ContactsExcelService {
organizationId: actor.organizationId,
name: { equals: name, mode: 'insensitive' },
...(didarId ? { sourceRef: null } : {}),
...ownerWhere(actor),
...customersCompanyWhere(actor),
},
select: { id: true, customFields: true },
});
@@ -805,7 +806,7 @@ export class ContactsExcelService {
where: {
organizationId: actor.organizationId,
sourceRef: ref,
...ownerWhere(actor),
...customersCompanyWhere(actor),
},
select: { id: true },
});
@@ -815,7 +816,7 @@ export class ContactsExcelService {
where: {
organizationId: actor.organizationId,
name: { equals: name, mode: 'insensitive' },
...ownerWhere(actor),
...customersCompanyWhere(actor),
},
select: { id: true, sourceRef: true },
});
+6 -6
View File
@@ -29,10 +29,10 @@ import {
toJsonAddresses,
toJsonCustomFields,
} from '../common/custom-fields';
import { customersPersonWhere } from '../common/data-access';
import {
type Actor,
canReassignOwner,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
import { LoyaltyService } from '../loyalty/loyalty.service';
@@ -206,7 +206,7 @@ export class PeopleService implements OnModuleInit {
if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
return {
organizationId: actor.organizationId,
...ownerWhere(actor),
...customersPersonWhere(actor),
...(moods.length ? { mood: { in: moods } } : {}),
...(sources.length ? { importSource: { in: sources } } : {}),
...(and.length ? { AND: and } : {}),
@@ -290,7 +290,7 @@ export class PeopleService implements OnModuleInit {
by: ['importSource'],
where: {
organizationId: actor.organizationId,
...ownerWhere(actor),
...customersPersonWhere(actor),
},
_count: { _all: true },
});
@@ -370,7 +370,7 @@ export class PeopleService implements OnModuleInit {
async get(actor: Actor, id: string): Promise<PersonDto> {
const row = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
where: { id, organizationId: actor.organizationId, ...customersPersonWhere(actor) },
include: personInclude,
});
if (!row) throw new NotFoundException('شخص پیدا نشد');
@@ -538,7 +538,7 @@ export class PeopleService implements OnModuleInit {
where: {
id: { in: unique },
organizationId: actor.organizationId,
...ownerWhere(actor),
...customersPersonWhere(actor),
},
});
return { deleted: res.count };
@@ -546,7 +546,7 @@ export class PeopleService implements OnModuleInit {
private async ensureExists(actor: Actor, id: string) {
const found = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
where: { id, organizationId: actor.organizationId, ...customersPersonWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('شخص پیدا نشد');
+15 -11
View File
@@ -24,6 +24,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { renderSmsText } from '../common/sms-text';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
import { leadsDeepWhere } from '../common/data-access';
import {
type Actor,
elevated,
@@ -59,18 +60,21 @@ function mobileKey(raw: string | null | undefined): string | null {
return d.startsWith('0') ? d : d;
}
function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput {
const actor = forScope(baseActor, 'leads');
if (elevated(actor.role)) return {};
if (actor.dataVisibility === 'org') return {};
if (
actor.dataVisibility === 'team' &&
actor.visibleUserIds &&
actor.visibleUserIds.length
) {
return { assigneeId: { in: actor.visibleUserIds } };
function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
if (actor.leadsAccess !== undefined) {
return leadsDeepWhere(actor);
}
return { assigneeId: actor.userId };
const scoped = forScope(actor, 'leads');
if (elevated(scoped.role)) return {};
if (scoped.dataVisibility === 'org') return {};
if (
scoped.dataVisibility === 'team' &&
scoped.visibleUserIds &&
scoped.visibleUserIds.length
) {
return { assigneeId: { in: scoped.visibleUserIds } };
}
return { assigneeId: scoped.userId };
}
const leadInclude = {
@@ -98,7 +98,7 @@ export class MembershipsService {
where: { organizationId, userId },
include: {
user: true,
orgRole: { select: { id: true, name: true, color: true } },
orgRole: { select: { id: true, name: true, color: true, dataAccessPolicy: true } },
},
});
}
@@ -108,7 +108,7 @@ export class MembershipsService {
where: { organizationId },
include: {
user: true,
orgRole: { select: { id: true, name: true, color: true } },
orgRole: { select: { id: true, name: true, color: true, dataAccessPolicy: true } },
},
orderBy: [{ user: { lastLoginAt: 'desc' } }, { user: { fullName: 'asc' } }],
});
+9
View File
@@ -3,6 +3,7 @@ import {
ArrayUnique,
IsArray,
IsIn,
IsObject,
IsOptional,
IsString,
MaxLength,
@@ -36,6 +37,10 @@ export class CreateOrgRoleDto {
@IsIn(PERMISSION_KEYS, { each: true })
permissions?: RolePermissionKey[];
@IsOptional()
@IsObject()
dataAccessPolicy?: Record<string, unknown>;
@IsOptional()
@ValidateIf((_, v) => v !== null)
@IsString()
@@ -64,6 +69,10 @@ export class UpdateOrgRoleDto {
@IsIn(PERMISSION_KEYS, { each: true })
permissions?: RolePermissionKey[];
@IsOptional()
@IsObject()
dataAccessPolicy?: Record<string, unknown>;
@IsOptional()
@ValidateIf((_, v) => v !== null)
@IsString()
+17 -1
View File
@@ -5,7 +5,13 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { OrgRoleDto, RolePermissionKey, UserRole } from '@mento/shared';
import type {
DataAccessPolicy,
OrgRoleDto,
RolePermissionKey,
UserRole,
} from '@mento/shared';
import { parseDataAccessPolicy } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
@@ -25,6 +31,7 @@ type RoleRow = {
description: string;
color: string;
permissions: string[];
dataAccessPolicy?: unknown;
systemKey: string | null;
parentId: string | null;
createdAt: Date;
@@ -70,6 +77,7 @@ export class OrgRolesService {
description?: string;
color?: string;
permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy;
parentId?: string | null;
},
): Promise<OrgRoleDto> {
@@ -101,6 +109,9 @@ export class OrgRolesService {
color: dto.color ?? parent?.color ?? 'teal',
permissions,
parentId,
...(dto.dataAccessPolicy
? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) }
: {}),
},
include: ROLE_MEMBERS_INCLUDE,
});
@@ -122,6 +133,7 @@ export class OrgRolesService {
description?: string;
color?: string;
permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy;
parentId?: string | null;
},
): Promise<OrgRoleDto> {
@@ -144,6 +156,9 @@ export class OrgRolesService {
if (dto.permissions !== undefined) {
data.permissions = sanitizePermissions(dto.permissions);
}
if (dto.dataAccessPolicy !== undefined) {
data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy);
}
if (dto.parentId !== undefined) {
const nextParentId =
@@ -410,6 +425,7 @@ export class OrgRolesService {
description: row.description,
color: row.color,
permissions: sanitizePermissions(row.permissions),
dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy),
systemKey,
parentId: row.parentId,
parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null,
@@ -35,7 +35,13 @@ const cardInclude = {
type CardActor = Pick<
Actor,
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'
| 'userId'
| 'role'
| 'dataVisibility'
| 'visibleUserIds'
| 'scopes'
| 'projectsAccess'
| 'teamPeerIds'
>;
@Injectable()
+22 -2
View File
@@ -1,4 +1,6 @@
import { ForbiddenException } from '@nestjs/common';
import { sectionHasDataAccessLimits } from '@mento/shared';
import { sectionOwnerIdUnion } from '../common/data-access';
import { type Actor, elevated, scopedOwnerIds } from '../common/record-scope';
import type { PrismaService } from '../prisma/prisma.service';
@@ -38,8 +40,26 @@ export async function resolveRoutineScope(
manageable = [...new Set([actor.userId, ...members.map((m) => m.userId)])];
}
const visible = scopedOwnerIds(actor, 'routines');
const viewable = visible === null ? null : [...new Set([...visible, ...manageable])];
let viewable: string[] | null;
if (actor.routinesAccess !== undefined) {
if (!sectionHasDataAccessLimits(actor.routinesAccess)) {
viewable = null;
} else {
const owners = sectionOwnerIdUnion(actor.routinesAccess, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
// فقط-مالک: محدود به owners؛ اگر قانون بدون owners (مثلاً دسته) باشد null = همه + فیلتر deepWhere
viewable = owners === null ? null : owners.length ? owners : [actor.userId];
}
} else {
const visible = scopedOwnerIds(actor, 'routines');
viewable = visible === null ? null : [...new Set([...visible, ...manageable])];
}
if (viewable !== null) {
viewable = [...new Set([...viewable, ...manageable])];
}
return {
...base,
+24 -2
View File
@@ -16,7 +16,7 @@ import type {
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import type { Actor } from '../common/record-scope';
import { type Actor, routineTaskAccessWhere } from '../common/record-scope';
import type {
CreateRoutineTaskDto,
RoutineEntityQueryDto,
@@ -63,6 +63,10 @@ export class RoutineTasksService {
return resolveRoutineScope(this.prisma, actor);
}
private deepWhere(actor: Actor): Prisma.RoutineTaskWhereInput {
return routineTaskAccessWhere(actor);
}
/* ───────── خواندن ───────── */
async today(actor: Actor, userId?: string, date?: string): Promise<RoutineTodayDto> {
@@ -71,6 +75,7 @@ export class RoutineTasksService {
if (!canView(scope, target)) throw new ForbiddenException('دسترسی ندارید');
const day = date && isDayKey(date) ? date : tehranDayKey();
const dayDate = dayKeyToDate(day);
const deep = this.deepWhere(actor);
const [todayRows, overdueRows, score] = await Promise.all([
this.prisma.routineTask.findMany({
@@ -79,6 +84,7 @@ export class RoutineTasksService {
assigneeId: target,
scheduledFor: dayDate,
status: { not: 'cancelled' },
...deep,
},
include: routineTaskInclude,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'asc' }],
@@ -89,6 +95,7 @@ export class RoutineTasksService {
assigneeId: target,
status: 'overdue',
scheduledFor: { lt: dayDate },
...deep,
},
include: { ...routineTaskInclude, routine: { select: { ...routineTaskInclude.routine.select, carryOverOverdue: true } } },
orderBy: [{ dueAt: 'asc' }],
@@ -148,7 +155,12 @@ export class RoutineTasksService {
const filter =
userId === 'all' ? assigneeFilter(scope) : assigneeFilter(scope, userId || actor.userId);
const rows = await this.prisma.routineTask.findMany({
where: { organizationId: actor.organizationId, status: 'overdue', ...filter },
where: {
organizationId: actor.organizationId,
status: 'overdue',
...filter,
...this.deepWhere(actor),
},
include: routineTaskInclude,
orderBy: [{ dueAt: 'asc' }],
take: 300,
@@ -166,6 +178,7 @@ export class RoutineTasksService {
organizationId: actor.organizationId,
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) },
...filter,
...this.deepWhere(actor),
...(q.categoryId ? { categoryId: q.categoryId } : {}),
...(q.status ? { status: q.status } : {}),
...(q.source ? { source: q.source } : {}),
@@ -196,6 +209,7 @@ export class RoutineTasksService {
OR: or,
status: { not: 'cancelled' },
...assigneeFilter(scope),
...this.deepWhere(actor),
},
include: routineTaskInclude,
orderBy: [{ scheduledFor: 'desc' }, { dueAt: 'desc' }],
@@ -208,6 +222,14 @@ export class RoutineTasksService {
const scope = await this.scope(actor);
const row = await this.findTask(actor.organizationId, id);
if (!canView(scope, row.assigneeId)) throw new ForbiddenException('دسترسی ندارید');
const deep = this.deepWhere(actor);
if (Object.keys(deep).length) {
const allowed = await this.prisma.routineTask.findFirst({
where: { id, organizationId: actor.organizationId, ...deep },
select: { id: true },
});
if (!allowed) throw new ForbiddenException('دسترسی ندارید');
}
const events = await this.prisma.routineTaskEvent.findMany({
where: { taskId: id },
include: { actor: { select: { id: true, fullName: true } } },
@@ -17,6 +17,7 @@ import { SMS_CAMPAIGN_CHUNK_SIZE } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { renderSmsText } from '../common/sms-text';
import { leadsDeepWhere } from '../common/data-access';
import {
type Actor,
elevated,
@@ -28,6 +29,9 @@ import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'
import type { Prisma } from '@prisma/client';
function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput {
if (baseActor.leadsAccess !== undefined) {
return leadsDeepWhere(baseActor);
}
const actor = forScope(baseActor, 'leads');
if (elevated(actor.role)) return {};
if (actor.dataVisibility === 'org') return {};
+11
View File
@@ -112,3 +112,14 @@ export class UpdateUserVisibilityDto {
@IsObject()
scopes?: Record<string, 'own' | 'team' | 'org' | null>;
}
export class UpdateUserDataAccessDto {
/**
* null = پاک کردن override و ارث از نقش.
* آبجکت = جایگزینی کامل override.
*/
@ValidateIf((_, v) => v !== null)
@IsOptional()
@IsObject()
dataAccessOverride?: Record<string, unknown> | null;
}
+16
View File
@@ -20,6 +20,7 @@ import {
UpdateLogoutTimeDto,
UpdateUserRoleDto,
UpdateUserStatusDto,
UpdateUserDataAccessDto,
UpdateUserVisibilityDto,
} from './dto';
import { UsersService } from './users.service';
@@ -143,6 +144,21 @@ export class UsersController {
);
}
@Patch(':id/data-access')
@Roles('owner', 'admin')
updateDataAccess(
@CurrentUser() user: { organizationId: string; role: UserRole },
@Param('id') id: string,
@Body() dto: UpdateUserDataAccessDto,
) {
return this.usersService.updateDataAccess(
user.organizationId,
user.role,
id,
dto,
);
}
@Patch(':id/role')
@Roles('owner', 'admin')
updateRole(
+46 -2
View File
@@ -14,7 +14,8 @@ import type {
UpdateUserVisibilityRequest,
UserRole,
} from '@mento/shared';
import { DATA_SCOPES, DATA_VISIBILITIES } from '@mento/shared';
import { DATA_SCOPES, DATA_VISIBILITIES, parseDataAccessPolicy } from '@mento/shared';
import { resolveAllSectionAccess } from '../common/data-access';
import { parseVisibilityScopes } from '../common/record-scope';
import * as bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
@@ -44,11 +45,12 @@ type MembershipRow = {
commissionPercent: { toString(): string } | number | null;
dataVisibility?: string | null;
dataVisibilityScopes?: unknown;
dataAccessOverride?: unknown;
priorArchiveEnabled?: boolean;
logoutTime?: string | null;
blockLoginAfterLogout?: boolean;
createdAt: Date;
orgRole: { id: string; name: string; color: string } | null;
orgRole: { id: string; name: string; color: string; dataAccessPolicy?: unknown } | null;
user: {
id: string;
email: string;
@@ -360,6 +362,31 @@ export class UsersService {
return this.dtoOf(organizationId, targetId);
}
async updateDataAccess(
organizationId: string,
actorRole: UserRole,
targetId: string,
dto: { dataAccessOverride?: Record<string, unknown> | null },
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید');
}
if (dto.dataAccessOverride === undefined) {
throw new BadRequestException('تغییری ارسال نشده است');
}
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (target.role === 'owner' || target.role === 'admin') {
throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند');
}
const next =
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride);
await this.prisma.membership.update({
where: { id: target.id },
data: { dataAccessOverride: next },
});
return this.dtoOf(organizationId, targetId);
}
async updateRole(
organizationId: string,
actor: { userId: string; role: UserRole },
@@ -541,6 +568,23 @@ export class UsersService {
row.role === 'owner' || row.role === 'admin'
? {}
: parseVisibilityScopes(row.dataVisibilityScopes),
dataAccessOverride:
row.role === 'owner' || row.role === 'admin'
? {}
: parseDataAccessPolicy(row.dataAccessOverride),
...(() => {
const effectiveDataAccess = resolveAllSectionAccess({
role: row.role,
rolePolicy: row.orgRole?.dataAccessPolicy,
override: row.dataAccessOverride,
legacyVisibility: row.dataVisibility,
legacyScopes: parseVisibilityScopes(row.dataVisibilityScopes),
});
return {
effectiveDataAccess,
effectiveCustomersAccess: effectiveDataAccess.customers,
};
})(),
priorArchiveEnabled: Boolean(row.priorArchiveEnabled),
logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null,
blockLoginAfterLogout:
@@ -0,0 +1,542 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type {
DataAccessOwnerKind,
DataAccessRule,
DataAccessSection,
PersonMood,
SectionDataAccess,
} from '@mento/shared';
import {
DATA_ACCESS_SECTION_LABELS,
PERSON_IMPORT_SOURCE_LABELS,
PERSON_IMPORT_SOURCES,
PERSON_MOOD_LABELS,
PERSON_MOODS,
sectionHasDataAccessLimits,
} from '@mento/shared';
import { UiButton } from '@/components/ui';
export type IdOption = { id: string; name: string };
const props = withDefaults(
defineProps<{
modelValue: SectionDataAccess;
section?: DataAccessSection;
groups?: IdOption[];
stages?: IdOption[];
pipelines?: IdOption[];
boards?: IdOption[];
activityTypes?: IdOption[];
disabled?: boolean;
compact?: boolean;
}>(),
{
section: 'customers',
groups: () => [],
stages: () => [],
pipelines: () => [],
boards: () => [],
activityTypes: () => [],
},
);
const emit = defineEmits<{
'update:modelValue': [SectionDataAccess];
}>();
const sectionLabel = computed(() => DATA_ACCESS_SECTION_LABELS[props.section]);
const showGroups = computed(() => props.section === 'customers');
const showMoods = computed(() => props.section === 'customers');
const showImportSources = computed(() => props.section === 'customers');
const showLeadSources = computed(() => props.section === 'leads');
const showNoteTypes = computed(() => props.section === 'notes');
const showTicketStatuses = computed(() => props.section === 'tickets');
const showStages = computed(
() =>
props.section === 'leads' ||
props.section === 'sales' ||
props.section === 'activities' ||
props.section === 'projects',
);
const showPipelines = computed(() => props.section === 'sales');
const showBoards = computed(
() => props.section === 'activities' || props.section === 'projects',
);
const showTypes = computed(
() => props.section === 'activities' || props.section === 'routines',
);
const ownerSelfLabel = computed(() => {
if (
props.section === 'leads' ||
props.section === 'activities' ||
props.section === 'routines' ||
props.section === 'tickets'
) {
return 'فقط مسئول خودش';
}
if (props.section === 'notes') return 'فقط نویسنده خودش';
return 'فقط پرونده‌های خود';
});
const noteTypeOptions = [
{ value: 'general', label: 'عمومی' },
{ value: 'call_result', label: 'نتیجه تماس' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'important', label: 'مهم' },
{ value: 'warning', label: 'هشدار' },
{ value: 'next_action', label: 'اقدام بعدی' },
{ value: 'follow_up', label: 'پیگیری' },
{ value: 'idea', label: 'ایده' },
{ value: 'suggestion', label: 'پیشنهاد' },
{ value: 'personal', label: 'شخصی' },
];
const ticketStatusOptions = [
{ value: 'open', label: 'باز' },
{ value: 'in_progress', label: 'در حال انجام' },
{ value: 'waiting', label: 'در انتظار' },
{ value: 'resolved', label: 'حل‌شده' },
{ value: 'closed', label: 'بسته' },
];
const typesLegend = computed(() =>
props.section === 'routines' ? 'دسته روتین' : 'نوع فعالیت',
);
const mode = computed({
get: () => (props.modelValue?.mode === 'rules' ? 'rules' : 'all'),
set: (next: 'all' | 'rules') => {
if (next === 'all') {
emit('update:modelValue', { mode: 'all', rules: [] });
return;
}
const rules = props.modelValue?.rules?.length
? props.modelValue.rules
: [emptyRule()];
emit('update:modelValue', { mode: 'rules', rules });
},
});
const rules = computed(() => props.modelValue?.rules ?? []);
function emptyRule(): DataAccessRule {
return {};
}
function patchRule(index: number, patch: Partial<DataAccessRule>) {
const next = rules.value.map((r, i) => (i === index ? { ...r, ...patch } : { ...r }));
emit('update:modelValue', { mode: 'rules', rules: next });
}
function toggleOwner(index: number, kind: DataAccessOwnerKind) {
const current = new Set(rules.value[index]?.owners ?? []);
if (current.has(kind)) current.delete(kind);
else current.add(kind);
const owners = [...current] as DataAccessOwnerKind[];
patchRule(index, { owners: owners.length ? owners : undefined });
}
type ListKey =
| 'groupIds'
| 'sources'
| 'moods'
| 'stageIds'
| 'pipelineIds'
| 'boardIds'
| 'typeIds';
function toggleInList(index: number, key: ListKey, value: string) {
const rule = rules.value[index] ?? {};
const current = new Set((rule[key] as string[] | undefined) ?? []);
if (current.has(value)) current.delete(value);
else current.add(value);
const list = [...current];
patchRule(index, { [key]: list.length ? list : undefined });
}
function addRule() {
emit('update:modelValue', {
mode: 'rules',
rules: [...rules.value, emptyRule()],
});
}
function removeRule(index: number) {
const next = rules.value.filter((_, i) => i !== index);
emit('update:modelValue', {
mode: next.length ? 'rules' : 'all',
rules: next,
});
}
function nameOf(list: IdOption[], id: string) {
return list.find((x) => x.id === id)?.name ?? id;
}
function ruleSummary(rule: DataAccessRule): string {
const parts: string[] = [];
if (rule.owners?.includes('self')) parts.push(ownerSelfLabel.value.replace('فقط ', ''));
if (rule.owners?.includes('team')) parts.push('تیم');
if (rule.groupIds?.length) {
parts.push(
`گروه: ${rule.groupIds
.slice(0, 2)
.map((id) => nameOf(props.groups, id))
.join('، ')}${rule.groupIds.length > 2 ? '…' : ''}`,
);
}
if (rule.sources?.length) {
const labelMap: Record<string, string> = {
...(PERSON_IMPORT_SOURCE_LABELS as Record<string, string>),
...Object.fromEntries(noteTypeOptions.map((o) => [o.value, o.label])),
...Object.fromEntries(ticketStatusOptions.map((o) => [o.value, o.label])),
};
const labels = rule.sources.map((s) => labelMap[s] ?? s).slice(0, 2);
const prefix =
props.section === 'notes'
? 'نوع'
: props.section === 'tickets'
? 'وضعیت'
: 'منبع';
parts.push(`${prefix}: ${labels.join('، ')}${rule.sources.length > 2 ? '…' : ''}`);
}
if (rule.moods?.length) {
parts.push(
`حال: ${rule.moods.map((m) => PERSON_MOOD_LABELS[m as PersonMood] ?? m).join('، ')}`,
);
}
if (rule.pipelineIds?.length) {
parts.push(
`پایپ‌لاین: ${rule.pipelineIds
.slice(0, 2)
.map((id) => nameOf(props.pipelines, id))
.join('، ')}`,
);
}
if (rule.stageIds?.length) {
parts.push(
`مرحله: ${rule.stageIds
.slice(0, 2)
.map((id) => nameOf(props.stages, id))
.join('، ')}`,
);
}
if (rule.boardIds?.length) {
parts.push(
`بورد: ${rule.boardIds
.slice(0, 2)
.map((id) => nameOf(props.boards, id))
.join('، ')}`,
);
}
if (rule.typeIds?.length) {
parts.push(
`نوع: ${rule.typeIds
.slice(0, 2)
.map((id) => nameOf(props.activityTypes, id))
.join('، ')}`,
);
}
return parts.length ? parts.join(' · ') : 'قانون خالی (اثر ندارد)';
}
const sourceOptions = computed(() =>
PERSON_IMPORT_SOURCES.map((s) => ({
value: s,
label: PERSON_IMPORT_SOURCE_LABELS[s],
})),
);
const hasLimits = computed(() => sectionHasDataAccessLimits(props.modelValue));
const customSource = ref('');
watch(
() => props.modelValue,
() => {
customSource.value = '';
},
);
function addCustomSource(index: number) {
const v = customSource.value.trim();
if (!v) return;
toggleInList(index, 'sources', v);
customSource.value = '';
}
</script>
<template>
<div class="dae" :class="{ compact }">
<div class="dae-mode">
<label>
<input v-model="mode" type="radio" value="all" :disabled="disabled" />
همهٔ {{ sectionLabel }} (بدون محدودیت عمقی)
</label>
<label>
<input v-model="mode" type="radio" value="rules" :disabled="disabled" />
فقط با قوانین (چند قانون با OR)
</label>
</div>
<template v-if="mode === 'rules'">
<p class="hint">
قوانین با هم «یا» می‌شوند. داخل هر قانون، گزینه‌های پرشده با هم «و» هستند.
</p>
<article v-for="(rule, index) in rules" :key="index" class="dae-rule">
<header>
<strong>قانون {{ index + 1 }}</strong>
<span class="muted">{{ ruleSummary(rule) }}</span>
<UiButton v-if="!disabled" variant="secondary" @click="removeRule(index)">
حذف
</UiButton>
</header>
<fieldset :disabled="disabled">
<legend>مسئول / مالک</legend>
<label class="chip">
<input
type="checkbox"
:checked="rule.owners?.includes('self')"
@change="toggleOwner(index, 'self')"
/>
{{ ownerSelfLabel }}
</label>
<label class="chip">
<input
type="checkbox"
:checked="rule.owners?.includes('team')"
@change="toggleOwner(index, 'team')"
/>
تیم‌های عضو
</label>
</fieldset>
<fieldset v-if="showGroups" :disabled="disabled">
<legend>گروه‌ها</legend>
<p v-if="!groups.length" class="muted">گروهی تعریف نشده.</p>
<label v-for="g in groups" :key="g.id" class="chip">
<input
type="checkbox"
:checked="rule.groupIds?.includes(g.id)"
@change="toggleInList(index, 'groupIds', g.id)"
/>
{{ g.name }}
</label>
</fieldset>
<fieldset v-if="showImportSources" :disabled="disabled">
<legend>منبع</legend>
<label v-for="s in sourceOptions" :key="s.value" class="chip">
<input
type="checkbox"
:checked="rule.sources?.includes(s.value)"
@change="toggleInList(index, 'sources', s.value)"
/>
{{ s.label }}
</label>
<div class="custom-source">
<input
v-model="customSource"
type="text"
placeholder="منبع سفارشی (sourceRef)…"
:disabled="disabled"
@keydown.enter.prevent="addCustomSource(index)"
/>
<UiButton v-if="!disabled" variant="secondary" @click="addCustomSource(index)">
افزودن
</UiButton>
</div>
</fieldset>
<fieldset v-if="showLeadSources" :disabled="disabled">
<legend>منبع لید</legend>
<div class="custom-source">
<input
v-model="customSource"
type="text"
placeholder="مثلاً اینستاگرام، سایت…"
:disabled="disabled"
@keydown.enter.prevent="addCustomSource(index)"
/>
<UiButton v-if="!disabled" variant="secondary" @click="addCustomSource(index)">
افزودن
</UiButton>
</div>
<label v-for="s in rule.sources ?? []" :key="s" class="chip">
<input
type="checkbox"
checked
@change="toggleInList(index, 'sources', s)"
/>
{{ s }}
</label>
</fieldset>
<fieldset v-if="showNoteTypes" :disabled="disabled">
<legend>نوع یادداشت</legend>
<label v-for="t in noteTypeOptions" :key="t.value" class="chip">
<input
type="checkbox"
:checked="rule.sources?.includes(t.value)"
@change="toggleInList(index, 'sources', t.value)"
/>
{{ t.label }}
</label>
</fieldset>
<fieldset v-if="showTicketStatuses" :disabled="disabled">
<legend>وضعیت تیکت</legend>
<label v-for="t in ticketStatusOptions" :key="t.value" class="chip">
<input
type="checkbox"
:checked="rule.sources?.includes(t.value)"
@change="toggleInList(index, 'sources', t.value)"
/>
{{ t.label }}
</label>
</fieldset>
<fieldset v-if="showMoods" :disabled="disabled">
<legend>حال مشتری</legend>
<label v-for="m in PERSON_MOODS" :key="m" class="chip">
<input
type="checkbox"
:checked="rule.moods?.includes(m)"
@change="toggleInList(index, 'moods', m)"
/>
{{ PERSON_MOOD_LABELS[m] }}
</label>
</fieldset>
<fieldset v-if="showPipelines" :disabled="disabled">
<legend>پایپ‌لاین</legend>
<p v-if="!pipelines.length" class="muted">پایپ‌لاینی نیست.</p>
<label v-for="p in pipelines" :key="p.id" class="chip">
<input
type="checkbox"
:checked="rule.pipelineIds?.includes(p.id)"
@change="toggleInList(index, 'pipelineIds', p.id)"
/>
{{ p.name }}
</label>
</fieldset>
<fieldset v-if="showStages" :disabled="disabled">
<legend>مرحله</legend>
<p v-if="!stages.length" class="muted">مرحله‌ای نیست.</p>
<label v-for="s in stages" :key="s.id" class="chip">
<input
type="checkbox"
:checked="rule.stageIds?.includes(s.id)"
@change="toggleInList(index, 'stageIds', s.id)"
/>
{{ s.name }}
</label>
</fieldset>
<fieldset v-if="showBoards" :disabled="disabled">
<legend>بورد فعالیت</legend>
<p v-if="!boards.length" class="muted">بوردی نیست.</p>
<label v-for="b in boards" :key="b.id" class="chip">
<input
type="checkbox"
:checked="rule.boardIds?.includes(b.id)"
@change="toggleInList(index, 'boardIds', b.id)"
/>
{{ b.name }}
</label>
</fieldset>
<fieldset v-if="showTypes" :disabled="disabled">
<legend>{{ typesLegend }}</legend>
<p v-if="!activityTypes.length" class="muted">موردی تعریف نشده.</p>
<label v-for="t in activityTypes" :key="t.id" class="chip">
<input
type="checkbox"
:checked="rule.typeIds?.includes(t.id)"
@change="toggleInList(index, 'typeIds', t.id)"
/>
{{ t.name }}
</label>
</fieldset>
</article>
<UiButton v-if="!disabled" variant="secondary" @click="addRule">+ قانون جدید</UiButton>
</template>
<p v-else-if="!hasLimits" class="hint">
بدون محدودیت عمقی — همهٔ {{ sectionLabel }} قابل مشاهده‌اند.
</p>
</div>
</template>
<style scoped>
.dae {
display: grid;
gap: 0.75rem;
}
.dae-mode {
display: grid;
gap: 0.35rem;
}
.dae-mode label,
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
margin-inline-end: 0.55rem;
margin-bottom: 0.25rem;
}
.hint,
.muted {
color: #64748b;
font-size: 0.82rem;
margin: 0;
}
.dae-rule {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.75rem;
display: grid;
gap: 0.65rem;
background: #f8fafc;
}
.dae-rule header {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
}
fieldset {
border: 1px dashed #e2e8f0;
border-radius: 8px;
padding: 0.5rem 0.65rem;
margin: 0;
}
legend {
padding-inline: 0.25rem;
font-size: 0.8rem;
color: #475569;
font-weight: 600;
}
.custom-source {
display: flex;
gap: 0.4rem;
margin-top: 0.35rem;
}
.custom-source input {
flex: 1;
font: inherit;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.35rem 0.5rem;
}
.compact .dae-rule {
padding: 0.55rem;
}
</style>
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import type {
DataAccessPolicy,
DataAccessSection,
RolePermissionKey,
SectionDataAccess,
} from '@mento/shared';
import {
DATA_ACCESS_SECTION_LABELS,
DATA_ACCESS_SECTIONS,
emptySectionDataAccess,
parseDataAccessPolicy,
} from '@mento/shared';
import { api } from '@/lib/api';
import { useCustomerGroups } from '@/lib/customer-groups';
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
const props = defineProps<{
modelValue: DataAccessPolicy;
/** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */
enabledPermissions?: RolePermissionKey[];
disabled?: boolean;
compact?: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [DataAccessPolicy];
}>();
/** بخش‌هایی که به RolePermissionKey وصل‌اند؛ بقیه (روتین/پروژه/تیکت) همیشه دیده می‌شوند */
const SECTION_PERM: Partial<Record<DataAccessSection, RolePermissionKey>> = {
customers: 'customers',
leads: 'leads',
sales: 'sales',
activities: 'activities',
notes: 'notes',
};
const visibleSections = computed(() => {
const perms = props.enabledPermissions;
if (!perms?.length) return DATA_ACCESS_SECTIONS;
return DATA_ACCESS_SECTIONS.filter((s) => {
const key = SECTION_PERM[s];
if (!key) return true;
return perms.includes(key);
});
});
const active = ref<DataAccessSection>('customers');
watch(
visibleSections,
(list) => {
if (!list.includes(active.value) && list[0]) active.value = list[0];
},
{ immediate: true },
);
const policy = computed(() => parseDataAccessPolicy(props.modelValue));
function sectionValue(key: DataAccessSection): SectionDataAccess {
return policy.value[key] ?? emptySectionDataAccess();
}
function setSection(key: DataAccessSection, next: SectionDataAccess) {
emit('update:modelValue', {
...policy.value,
[key]: next,
});
}
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
const leadStages = ref<IdOption[]>([]);
const pipelines = ref<IdOption[]>([]);
const dealStages = ref<IdOption[]>([]);
const activityBoards = ref<IdOption[]>([]);
const activityStages = ref<IdOption[]>([]);
const activityTypes = ref<IdOption[]>([]);
const projectBoards = ref<IdOption[]>([]);
const projectStages = ref<IdOption[]>([]);
const routineCategories = ref<IdOption[]>([]);
async function loadOptions() {
await loadCustomerGroups().catch(() => undefined);
try {
const { data } = await api.get<Array<{ id: string; name: string }>>('/leads/stages');
leadStages.value = data.map((s) => ({ id: s.id, name: s.name }));
} catch {
leadStages.value = [];
}
try {
const { data } = await api.get<
Array<{
id: string;
name: string;
stages?: Array<{ id: string; name: string }>;
}>
>('/pipelines');
pipelines.value = data.map((p) => ({ id: p.id, name: p.name }));
dealStages.value = data.flatMap((p) =>
(p.stages ?? []).map((s) => ({ id: s.id, name: `${p.name} · ${s.name}` })),
);
} catch {
pipelines.value = [];
dealStages.value = [];
}
try {
const { data } = await api.get<Array<{ id: string; name: string }>>('/activities/boards');
activityBoards.value = data.map((b) => ({ id: b.id, name: b.name }));
} catch {
activityBoards.value = [];
}
try {
const stageLists = await Promise.all(
activityBoards.value.map(async (b) => {
const { data } = await api.get<Array<{ id: string; name: string }>>(
'/activities/stages',
{ params: { boardId: b.id } },
);
return data.map((s) => ({
id: s.id,
name: `${b.name} · ${s.name}`,
}));
}),
);
activityStages.value = stageLists.flat();
} catch {
activityStages.value = [];
}
try {
const { data } = await api.get<Array<{ id: string; name: string }>>('/activities/types');
activityTypes.value = data.map((t) => ({ id: t.id, name: t.name }));
} catch {
activityTypes.value = [];
}
try {
const { data } = await api.get<
Array<{
id: string;
name: string;
stages?: Array<{ id: string; name: string }>;
}>
>('/project-boards');
projectBoards.value = data.map((b) => ({ id: b.id, name: b.name }));
projectStages.value = data.flatMap((b) =>
(b.stages ?? []).map((s) => ({ id: s.id, name: `${b.name} · ${s.name}` })),
);
} catch {
projectBoards.value = [];
projectStages.value = [];
}
try {
const { data } = await api.get<Array<{ id: string; name: string }>>('/routines/categories');
routineCategories.value = data.map((c) => ({ id: c.id, name: c.name }));
} catch {
routineCategories.value = [];
}
}
onMounted(() => {
void loadOptions();
});
const stagesForActive = computed(() => {
if (active.value === 'leads') return leadStages.value;
if (active.value === 'sales') return dealStages.value;
if (active.value === 'activities') return activityStages.value;
if (active.value === 'projects') return projectStages.value;
return [];
});
const boardsForActive = computed(() => {
if (active.value === 'activities') return activityBoards.value;
if (active.value === 'projects') return projectBoards.value;
return [];
});
const typesForActive = computed(() => {
if (active.value === 'activities') return activityTypes.value;
if (active.value === 'routines') return routineCategories.value;
return [];
});
</script>
<template>
<div class="dap">
<p v-if="!visibleSections.length" class="muted">
برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید.
</p>
<template v-else>
<div class="tabs" role="tablist">
<button
v-for="s in visibleSections"
:key="s"
type="button"
class="tab"
:class="{ on: active === s }"
role="tab"
:aria-selected="active === s"
@click="active = s"
>
{{ DATA_ACCESS_SECTION_LABELS[s] }}
</button>
</div>
<DataAccessEditor
:key="active"
:section="active"
:model-value="sectionValue(active)"
:groups="customerGroups"
:stages="stagesForActive"
:pipelines="pipelines"
:boards="boardsForActive"
:activity-types="typesForActive"
:disabled="disabled"
:compact="compact"
@update:model-value="setSection(active, $event)"
/>
</template>
</div>
</template>
<style scoped>
.dap {
display: grid;
gap: 0.75rem;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tab {
font: inherit;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.3rem 0.7rem;
cursor: pointer;
color: #475569;
}
.tab.on {
border-color: #0f766e;
background: #f0fdfa;
color: #0f766e;
font-weight: 600;
}
.muted {
color: #64748b;
font-size: 0.85rem;
}
</style>
+95 -76
View File
@@ -5,21 +5,21 @@ import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import type {
DataAccessPolicy,
OrganizationDto,
OrgUserDto,
TeamDto,
TeamMemberRole,
UpdatePriorArchiveCutoffRequest,
UpdateUserVisibilityRequest,
} from '@mento/shared';
import {
DATA_SCOPE_LABELS,
DATA_SCOPES,
DATA_VISIBILITY_LABELS,
DATA_VISIBILITIES,
type DataVisibility,
DATA_ACCESS_SECTION_LABELS,
DATA_ACCESS_SECTIONS,
emptySectionDataAccess,
sectionHasDataAccessLimits,
} from '@mento/shared';
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
const props = defineProps<{
@@ -96,7 +96,6 @@ async function setPriorArchive(userId: string, enabled: boolean) {
function cutoffPayload(value: string): UpdatePriorArchiveCutoffRequest {
const parsed = parseLocalInput(value);
if (!parsed) return { cutoffAt: null };
// پایان همان روز محلی تا ایمپورت همان روز هم قبل از تاریخ قطع شمرده شود
parsed.setHours(23, 59, 59, 999);
return { cutoffAt: parsed.toISOString() };
}
@@ -280,25 +279,75 @@ async function removeTeam(id: string) {
}
}
const expandedVisibility = ref<string | null>(null);
const expandedAccess = ref<string | null>(null);
const draftAccess = ref<DataAccessPolicy>({});
const accessMode = ref<'inherit' | 'custom'>('inherit');
const accessSaving = ref(false);
function scopeOverrides(u: OrgUserDto): number {
return Object.keys(u.dataVisibilityScopes ?? {}).length;
function clonePolicy(policy?: DataAccessPolicy | null): DataAccessPolicy {
const src = policy ?? {};
const out: DataAccessPolicy = {};
for (const [key, section] of Object.entries(src)) {
if (!section) continue;
out[key as keyof DataAccessPolicy] = {
mode: section.mode,
rules: (section.rules ?? []).map((r) => ({ ...r })),
};
}
return out;
}
async function setVisibility(userId: string, payload: UpdateUserVisibilityRequest) {
function accessSummary(u: OrgUserDto): string {
const override = u.dataAccessOverride ?? {};
const hasOverride = DATA_ACCESS_SECTIONS.some((s) => Boolean(override[s]));
const effective = u.effectiveDataAccess ?? {};
const limited = DATA_ACCESS_SECTIONS.filter((s) =>
sectionHasDataAccessLimits(effective[s] ?? emptySectionDataAccess()),
);
if (!hasOverride && !limited.length) return 'مثل نقش · بدون محدودیت';
if (!hasOverride) {
return `مثل نقش · ${toFaDigits(limited.length)} بخش محدود`;
}
const labels = limited.map((s) => DATA_ACCESS_SECTION_LABELS[s]).slice(0, 2);
return `سفارشی · ${labels.join('، ') || 'بدون محدودیت'}${limited.length > 2 ? '…' : ''}`;
}
function openAccessEditor(u: OrgUserDto) {
if (expandedAccess.value === u.id) {
expandedAccess.value = null;
return;
}
const override = u.dataAccessOverride ?? {};
const hasOverride = DATA_ACCESS_SECTIONS.some((s) => Boolean(override[s]));
accessMode.value = hasOverride ? 'custom' : 'inherit';
draftAccess.value = hasOverride
? clonePolicy(override)
: clonePolicy(u.effectiveDataAccess);
expandedAccess.value = u.id;
}
async function saveUserAccess(userId: string) {
accessSaving.value = true;
error.value = '';
ok.value = '';
try {
await api.patch(`/users/${userId}/visibility`, payload);
ok.value = 'سطح دسترسی به پرونده‌ها به‌روز شد.';
const payload =
accessMode.value === 'inherit'
? { dataAccessOverride: null }
: { dataAccessOverride: draftAccess.value };
await api.patch(`/users/${userId}/data-access`, payload);
ok.value = 'محدوده داده به‌روز شد.';
expandedAccess.value = null;
emit('refreshUsers');
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'تغییر سطح دسترسی به پرونده‌ها ناموفق بود.',
'ذخیره محدوده داده ناموفق بود.',
)
: 'تغییر سطح دسترسی به پرونده‌ها ناموفق بود.';
: 'ذخیره محدوده داده ناموفق بود.';
} finally {
accessSaving.value = false;
}
}
@@ -448,70 +497,45 @@ onMounted(() => {
</section>
<section class="card">
<h3>سطح دسترسی به پرونده‌ها</h3>
<h3>محدوده داده (مشتریان / لید / معامله / فعالیت)</h3>
<p class="hint">
مالک و مدیر همیشه همه پرونده‌ها را می‌بینند. برای بقیه یک سطح پیش‌فرض (فقط خود، تیم‌های عضو،
یا کل سازمان) تعیین کنید و در صورت نیاز با «تنظیم تفکیکی» برای هر بخش سطح جداگانه بگذارید؛
مثلاً همه مشتریان را ببیند ولی معاملات فقط مال خودش.
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید override
بگذارید.
</p>
<UiEmptyState
v-if="!editableUsers.length"
title="کاربر قابل تنظیم نیست"
description="فقط نقش‌های فروش و پشتیبانی سطح دسترسی جدا به پرونده‌ها دارند."
description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند."
/>
<div v-else class="vis-table">
<div v-for="u in editableUsers" :key="u.id" class="vis-user">
<div class="vis-row">
<span>
{{ u.fullName }}
<small v-if="scopeOverrides(u)" class="vis-badge">
{{ toFaDigits(scopeOverrides(u)) }} بخش تفکیکی
</small>
<small class="vis-badge">{{ accessSummary(u) }}</small>
</span>
<select
:value="u.dataVisibility ?? 'own'"
:disabled="!canEdit"
title="سطح پیش‌فرض"
@change="
setVisibility(u.id, {
dataVisibility: ($event.target as HTMLSelectElement).value as DataVisibility,
})
"
>
<option v-for="v in DATA_VISIBILITIES" :key="v" :value="v">
پیش‌فرض: {{ DATA_VISIBILITY_LABELS[v] }}
</option>
</select>
<UiButton
variant="secondary"
@click="expandedVisibility = expandedVisibility === u.id ? null : u.id"
>
{{ expandedVisibility === u.id ? 'بستن' : 'تنظیم تفکیکی' }}
<UiButton v-if="canEdit" variant="secondary" @click="openAccessEditor(u)">
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم محدوده' }}
</UiButton>
</div>
<div v-if="expandedVisibility === u.id" class="vis-scopes">
<label v-for="s in DATA_SCOPES" :key="s" class="vis-scope">
<span>{{ DATA_SCOPE_LABELS[s] }}</span>
<select
:value="u.dataVisibilityScopes?.[s] ?? ''"
:disabled="!canEdit"
:class="{ custom: Boolean(u.dataVisibilityScopes?.[s]) }"
@change="
setVisibility(u.id, {
scopes: {
[s]: (($event.target as HTMLSelectElement).value || null) as DataVisibility | null,
},
})
"
>
<option value="">
مثل پیش‌فرض ({{ DATA_VISIBILITY_LABELS[u.dataVisibility ?? 'own'] }})
</option>
<option v-for="v in DATA_VISIBILITIES" :key="v" :value="v">
{{ DATA_VISIBILITY_LABELS[v] }}
</option>
</select>
<div v-if="expandedAccess === u.id" class="vis-scopes">
<label class="chip-row">
<input v-model="accessMode" type="radio" value="inherit" />
مثل نقش (بدون override)
</label>
<label class="chip-row">
<input v-model="accessMode" type="radio" value="custom" />
سفارشی برای این کاربر
</label>
<DataAccessPolicyPanel
v-if="accessMode === 'custom'"
v-model="draftAccess"
compact
/>
<div class="actions">
<UiButton :loading="accessSaving" @click="saveUserAccess(u.id)">ذخیره</UiButton>
<UiButton variant="secondary" @click="expandedAccess = null">انصراف</UiButton>
</div>
</div>
</div>
</div>
@@ -642,7 +666,7 @@ onMounted(() => {
}
.vis-row {
display: grid;
grid-template-columns: 1fr 240px auto;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: center;
padding: 0.45rem 0;
@@ -657,21 +681,16 @@ onMounted(() => {
}
.vis-scopes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.5rem 1rem;
gap: 0.55rem;
padding: 0.6rem 0.75rem;
background: #f8fafc;
border-radius: 10px;
}
.vis-scope {
display: grid;
gap: 0.25rem;
font-size: 0.85rem;
color: #334155;
}
select.custom {
border-color: #6366f1;
background: #eef2ff;
.chip-row {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
}
select {
font: inherit;
+92
View File
@@ -12,6 +12,7 @@ import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu, UiModal } from '@/components/ui';
import {
ROLE_PERMISSION_ITEMS,
type DataAccessPolicy,
type InviteUserRequest,
type OrganizationDto,
type OrgModuleKey,
@@ -30,6 +31,7 @@ import {
toFaDigits,
} from '@/lib/jalali';
import TeamsSettingsPanel from '@/components/TeamsSettingsPanel.vue';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
type TabKey = 'users' | 'roles' | 'access' | 'teams';
type RoleColor = 'teal' | 'violet' | 'rose' | 'sky' | 'amber' | 'slate' | 'pink';
@@ -89,6 +91,8 @@ const orgName = ref('');
const businessField = ref('');
const orgSaving = ref(false);
const accessSaving = ref(false);
const roleFormDataAccess = ref<DataAccessPolicy>({});
const focusDataAccess = ref<DataAccessPolicy>({});
const tab = ref<TabKey>('users');
const query = ref('');
@@ -583,6 +587,7 @@ function openCreateRole(parentId?: string | null) {
roleFormPerms.value = parent
? [...parent.permissions]
: visibleRolePermissions.value.map((p) => p.key);
roleFormDataAccess.value = clonePolicy(parent?.dataAccessPolicy);
roleFormOpen.value = true;
}
@@ -602,6 +607,7 @@ function openEditRole() {
: 'teal') as RoleColor;
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
roleFormPerms.value = [...role.permissions];
roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy);
roleFormOpen.value = true;
}
@@ -631,12 +637,14 @@ async function submitRoleForm() {
description: string;
color: RoleColor;
permissions: RolePermissionKey[];
dataAccessPolicy?: OrgRoleDto['dataAccessPolicy'];
parentId?: string | null;
} = {
name: roleFormName.value.trim(),
description: roleFormDesc.value.trim(),
color: roleFormColor.value,
permissions: roleFormPerms.value,
dataAccessPolicy: roleFormDataAccess.value,
};
if (!editing?.systemKey) {
body.parentId = roleFormParentId.value || null;
@@ -693,6 +701,40 @@ async function toggleAccess(role: OrgRoleDto, key: RolePermissionKey) {
}
}
function clonePolicy(policy?: DataAccessPolicy | null): DataAccessPolicy {
const src = policy ?? {};
const out: DataAccessPolicy = {};
for (const [key, section] of Object.entries(src)) {
if (!section) continue;
out[key as keyof DataAccessPolicy] = {
mode: section.mode,
rules: (section.rules ?? []).map((r) => ({ ...r })),
};
}
return out;
}
function syncFocusDataAccess() {
focusDataAccess.value = clonePolicy(accessFocusRole.value?.dataAccessPolicy);
}
async function saveFocusDataAccess() {
const role = accessFocusRole.value;
if (!role || !canEditRoles.value) return;
accessSaving.value = true;
error.value = '';
try {
await api.patch(`/org-roles/${role.id}`, { dataAccessPolicy: focusDataAccess.value });
ok.value = `محدوده داده برای نقش «${role.name}» ذخیره شد.`;
await load();
syncFocusDataAccess();
} catch (e) {
error.value = apiError(e, 'ذخیره محدوده داده ناموفق بود');
} finally {
accessSaving.value = false;
}
}
function onDocClick() {
menuUserId.value = null;
}
@@ -728,10 +770,15 @@ watch(
if (!list.some((r) => r.id === accessFocusId.value)) {
accessFocusId.value = list[0].id;
}
syncFocusDataAccess();
},
{ immediate: true },
);
watch(accessFocusId, () => {
syncFocusDataAccess();
});
onMounted(() => {
syncViewport();
window.addEventListener('resize', syncViewport);
@@ -862,6 +909,29 @@ onUnmounted(() => {
</tbody>
</table>
</div>
<div v-if="accessFocusRole" class="data-access-block">
<div class="matrix-head">
<h3>محدوده داده — {{ accessFocusRole.name }}</h3>
<button
v-if="canEditRoles"
type="button"
class="btn primary"
:disabled="accessSaving"
@click="saveFocusDataAccess"
>
{{ accessSaving ? 'در حال ذخیره…' : 'ذخیره محدوده' }}
</button>
</div>
<p class="hint">
زیرمجموعهٔ مجوز هر بخش: اگر تنظیم نشود همه را می‌بیند؛ قوانین با هم «یا» می‌شوند.
</p>
<DataAccessPolicyPanel
v-model="focusDataAccess"
:enabled-permissions="accessFocusRole.permissions"
:disabled="!canEditRoles"
/>
</div>
</section>
</div>
@@ -1362,6 +1432,14 @@ onUnmounted(() => {
{{ perm.label }}
</label>
</fieldset>
<fieldset class="perm-set">
<legend>محدوده داده</legend>
<DataAccessPolicyPanel
v-model="roleFormDataAccess"
:enabled-permissions="roleFormPerms"
compact
/>
</fieldset>
</form>
<template #footer>
<button type="button" class="btn ghost" @click="roleFormOpen = false">انصراف</button>
@@ -1390,6 +1468,20 @@ onUnmounted(() => {
min-width: 0;
}
.data-access-block {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eef2f7;
display: grid;
gap: 0.75rem;
}
.data-access-block .hint {
color: #64748b;
font-size: 0.85rem;
margin: 0;
}
.page-head {
display: flex;
justify-content: flex-start;
+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;
}