diff --git a/apps/api/prisma/migrations/20261022120000_deep_data_access/migration.sql b/apps/api/prisma/migrations/20261022120000_deep_data_access/migration.sql new file mode 100644 index 0000000..1a0602d --- /dev/null +++ b/apps/api/prisma/migrations/20261022120000_deep_data_access/migration.sql @@ -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') + ); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index ad290fb..98e6f5b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/src/ai/ai-ask.service.ts b/apps/api/src/ai/ai-ask.service.ts index 8cae72a..1064db8 100644 --- a/apps/api/src/ai/ai-ask.service.ts +++ b/apps/api/src/ai/ai-ask.service.ts @@ -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) { diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts index 6e16234..83595b1 100644 --- a/apps/api/src/auth/jwt.strategy.ts +++ b/apps/api/src/auth/jwt.strategy.ts @@ -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 diff --git a/apps/api/src/common/data-access.ts b/apps/api/src/common/data-access.ts new file mode 100644 index 0000000..c848a93 --- /dev/null +++ b/apps/api/src/common/data-access.ts @@ -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(); + 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(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, +): 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, +): 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, +): 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, +): 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, +): 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, +): 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, +): 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, +): 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, +): 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(); + 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); +} diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts index 112c41b..00f8ac9 100644 --- a/apps/api/src/common/record-scope.ts +++ b/apps/api/src/common/record-scope.ts @@ -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>; + /** عمق دادهٔ بخش‌ها (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, ): 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, ): 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, ): 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, ): 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, ): 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, +): Prisma.RoutineTaskWhereInput { + if (actor.routinesAccess === undefined) return {}; + return routinesDeepWhere(actor); +} + export function canReassignOwner(role: string | null | undefined): boolean { return elevated(role); } diff --git a/apps/api/src/contacts/companies.service.ts b/apps/api/src/contacts/companies.service.ts index dbbabbe..b095001 100644 --- a/apps/api/src/contacts/companies.service.ts +++ b/apps/api/src/contacts/companies.service.ts @@ -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 { 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('شرکت پیدا نشد'); diff --git a/apps/api/src/contacts/contacts-excel.service.ts b/apps/api/src/contacts/contacts-excel.service.ts index 5a2670b..1b85348 100644 --- a/apps/api/src/contacts/contacts-excel.service.ts +++ b/apps/api/src/contacts/contacts-excel.service.ts @@ -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 }, }); diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 151192c..7adce20 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -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 { 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('شخص پیدا نشد'); diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index 6752353..a069263 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -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 = { diff --git a/apps/api/src/memberships/memberships.service.ts b/apps/api/src/memberships/memberships.service.ts index 5767e16..1115cb6 100644 --- a/apps/api/src/memberships/memberships.service.ts +++ b/apps/api/src/memberships/memberships.service.ts @@ -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' } }], }); diff --git a/apps/api/src/org-roles/dto.ts b/apps/api/src/org-roles/dto.ts index 2561f38..f7a9292 100644 --- a/apps/api/src/org-roles/dto.ts +++ b/apps/api/src/org-roles/dto.ts @@ -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; + @IsOptional() @ValidateIf((_, v) => v !== null) @IsString() @@ -64,6 +69,10 @@ export class UpdateOrgRoleDto { @IsIn(PERMISSION_KEYS, { each: true }) permissions?: RolePermissionKey[]; + @IsOptional() + @IsObject() + dataAccessPolicy?: Record; + @IsOptional() @ValidateIf((_, v) => v !== null) @IsString() diff --git a/apps/api/src/org-roles/org-roles.service.ts b/apps/api/src/org-roles/org-roles.service.ts index 27482b3..03d53d7 100644 --- a/apps/api/src/org-roles/org-roles.service.ts +++ b/apps/api/src/org-roles/org-roles.service.ts @@ -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 { @@ -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 { @@ -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, diff --git a/apps/api/src/project-boards/project-boards.service.ts b/apps/api/src/project-boards/project-boards.service.ts index 22b697e..503ffc2 100644 --- a/apps/api/src/project-boards/project-boards.service.ts +++ b/apps/api/src/project-boards/project-boards.service.ts @@ -35,7 +35,13 @@ const cardInclude = { type CardActor = Pick< Actor, - 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' + | 'userId' + | 'role' + | 'dataVisibility' + | 'visibleUserIds' + | 'scopes' + | 'projectsAccess' + | 'teamPeerIds' >; @Injectable() diff --git a/apps/api/src/routines/routine-scope.ts b/apps/api/src/routines/routine-scope.ts index e77059f..ad7edc3 100644 --- a/apps/api/src/routines/routine-scope.ts +++ b/apps/api/src/routines/routine-scope.ts @@ -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, diff --git a/apps/api/src/routines/routine-tasks.service.ts b/apps/api/src/routines/routine-tasks.service.ts index 103bdb1..57db1cc 100644 --- a/apps/api/src/routines/routine-tasks.service.ts +++ b/apps/api/src/routines/routine-tasks.service.ts @@ -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 { @@ -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 } } }, diff --git a/apps/api/src/sms-campaigns/sms-campaigns.service.ts b/apps/api/src/sms-campaigns/sms-campaigns.service.ts index 75a6f5d..28ee921 100644 --- a/apps/api/src/sms-campaigns/sms-campaigns.service.ts +++ b/apps/api/src/sms-campaigns/sms-campaigns.service.ts @@ -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 {}; diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts index 6f98177..5be1f1c 100644 --- a/apps/api/src/users/dto.ts +++ b/apps/api/src/users/dto.ts @@ -112,3 +112,14 @@ export class UpdateUserVisibilityDto { @IsObject() scopes?: Record; } + +export class UpdateUserDataAccessDto { + /** + * null = پاک کردن override و ارث از نقش. + * آبجکت = جایگزینی کامل override. + */ + @ValidateIf((_, v) => v !== null) + @IsOptional() + @IsObject() + dataAccessOverride?: Record | null; +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 39cff28..9d7943c 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -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( diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 7d65969..24bb95d 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -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 | null }, + ): Promise { + 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: diff --git a/apps/web/src/components/DataAccessEditor.vue b/apps/web/src/components/DataAccessEditor.vue new file mode 100644 index 0000000..58b0bc6 --- /dev/null +++ b/apps/web/src/components/DataAccessEditor.vue @@ -0,0 +1,542 @@ + + + + + diff --git a/apps/web/src/components/DataAccessPolicyPanel.vue b/apps/web/src/components/DataAccessPolicyPanel.vue new file mode 100644 index 0000000..4d702c8 --- /dev/null +++ b/apps/web/src/components/DataAccessPolicyPanel.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/apps/web/src/components/TeamsSettingsPanel.vue b/apps/web/src/components/TeamsSettingsPanel.vue index b1d84db..c862a4c 100644 --- a/apps/web/src/components/TeamsSettingsPanel.vue +++ b/apps/web/src/components/TeamsSettingsPanel.vue @@ -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(null); +const expandedAccess = ref(null); +const draftAccess = ref({}); +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(() => {
-

سطح دسترسی به پرونده‌ها

+

محدوده داده (مشتریان / لید / معامله / فعالیت)

- مالک و مدیر همیشه همه پرونده‌ها را می‌بینند. برای بقیه یک سطح پیش‌فرض (فقط خود، تیم‌های عضو، - یا کل سازمان) تعیین کنید و در صورت نیاز با «تنظیم تفکیکی» برای هر بخش سطح جداگانه بگذارید؛ - مثلاً همه مشتریان را ببیند ولی معاملات فقط مال خودش. + مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید override + بگذارید.

{{ u.fullName }} - - {{ toFaDigits(scopeOverrides(u)) }} بخش تفکیکی - + {{ accessSummary(u) }} - - - {{ expandedVisibility === u.id ? 'بستن' : 'تنظیم تفکیکی' }} + + {{ expandedAccess === u.id ? 'بستن' : 'تنظیم محدوده' }}
-
-
@@ -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; diff --git a/apps/web/src/views/TeamSettingsView.vue b/apps/web/src/views/TeamSettingsView.vue index 783e1e7..d818329 100644 --- a/apps/web/src/views/TeamSettingsView.vue +++ b/apps/web/src/views/TeamSettingsView.vue @@ -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({}); +const focusDataAccess = ref({}); const tab = ref('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(() => {
+ +
+
+

محدوده داده — {{ accessFocusRole.name }}

+ +
+

+ زیرمجموعهٔ مجوز هر بخش: اگر تنظیم نشود همه را می‌بیند؛ قوانین با هم «یا» می‌شوند. +

+ +
@@ -1362,6 +1432,14 @@ onUnmounted(() => { {{ perm.label }} +
+ محدوده داده + +