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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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('شخص پیدا نشد');
|
||||
|
||||
@@ -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' } }],
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user