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
+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: