Files
CRM_Montzar/apps/api/src/common/record-scope.ts
T
MahdiandCursor 0e1bc4a1fe fix(access): honor finance sections and team-member data scope
Role treasury keys now open the first allowed finance page, and team-member ownership limits lists, dashboard, and reports instead of the whole organization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 11:20:20 +03:30

343 lines
11 KiB
TypeScript

import type {
DataScope,
DataVisibility,
DataVisibilityScopes,
SectionDataAccess,
UserRole,
} from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords, sectionHasDataAccessLimits } from '@mento/shared';
import type { Prisma } from '@prisma/client';
import {
activitiesDeepWhere,
dealsDeepWhere,
notesDeepWhere,
projectsDeepWhere,
routinesDeepWhere,
sectionOwnerIdUnion,
ticketsDeepWhere,
} from './data-access';
export type ScopeVisibility = {
dataVisibility: DataVisibility;
visibleUserIds: string[] | null;
};
export type Actor = {
userId: string;
role: string;
organizationId: string;
/** own | team | org — سطح پیش‌فرض از Membership */
dataVisibility?: DataVisibility;
/**
* شناسه کاربرانی که پرونده‌شان برای این Actor قابل‌مشاهده است.
* اگر undefined باشد فقط خود کاربر؛ اگر null یعنی همه (elevated/org).
*/
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';
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیت‌های ساخته‌شده قبل از آن می‌توانند قبلی باشند */
priorCutoff?: Date | null;
/** مالک واقعی وقتی در حالت مشاهده به‌جای کاربر دیگر است */
impersonatorUserId?: string;
impersonatorRole?: string;
/** شروع این نشست؛ برای ساعت خروج روزانه */
sessionStartedAt?: Date;
/** در حالت مشاهده، سرور تغییر را رد می‌کند */
viewAsReadOnly?: boolean;
};
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
type DeepAccessActor = ScopedActor &
Partial<
Pick<
Actor,
| 'customersAccess'
| 'leadsAccess'
| 'salesAccess'
| 'activitiesAccess'
| 'notesAccess'
| 'routinesAccess'
| 'projectsAccess'
| 'ticketsAccess'
| 'teamPeerIds'
>
>;
function sectionForScope(actor: DeepAccessActor, scope: DataScope): SectionDataAccess | undefined {
switch (scope) {
case 'contacts':
return actor.customersAccess;
case 'leads':
return actor.leadsAccess;
case 'deals':
return actor.salesAccess;
case 'activities':
return actor.activitiesAccess;
case 'notes':
return actor.notesAccess;
case 'routines':
return actor.routinesAccess;
case 'projects':
return actor.projectsAccess;
case 'tickets':
return actor.ticketsAccess;
default:
return undefined;
}
}
/**
* شناسه‌های مالک از قانون عمق داده.
* legacy = بخش عمیق روی این Actor نیست.
* open = محدودیت مالکی ندارد (همه، یا فقط فیلترهای غیرمالک).
*/
function deepOwnerIds(
actor: DeepAccessActor,
scope: DataScope,
): { kind: 'legacy' } | { kind: 'open' } | { kind: 'ids'; ids: string[] } {
const section = sectionForScope(actor, scope);
if (!section) return { kind: 'legacy' };
if (!sectionHasDataAccessLimits(section)) return { kind: 'open' };
const ids = sectionOwnerIdUnion(section, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (ids === null) return { kind: 'open' };
return { kind: 'ids', ids };
}
/** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
const s = actor.scopes?.[scope];
if (!s) return actor;
return { ...actor, dataVisibility: s.dataVisibility, visibleUserIds: s.visibleUserIds };
}
export function parseVisibilityScopes(raw: unknown): DataVisibilityScopes {
const out: DataVisibilityScopes = {};
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
for (const scope of DATA_SCOPES) {
const v = (raw as Record<string, unknown>)[scope];
if (v === 'own' || v === 'team' || v === 'org') out[scope] = v;
}
return out;
}
export function elevated(role: string | null | undefined): boolean {
return canViewAllRecords(role);
}
export function seesAllRecords(
actor: Pick<Actor, 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>,
scope?: DataScope,
): boolean {
if (elevated(actor.role)) return true;
const s = scope ? actor.scopes?.[scope] : undefined;
const visibility = s ? s.dataVisibility : actor.dataVisibility;
const userIds = s ? s.visibleUserIds : actor.visibleUserIds;
if (visibility === 'org') return true;
if (userIds === null) return true;
return false;
}
/** شناسه کاربرانی که پرونده‌شان در این بخش دیده می‌شود؛ null یعنی همه. */
export function scopedOwnerIds(
actor: ScopedActor,
scope?: DataScope,
): string[] | null {
const a = scope ? forScope(actor, scope) : actor;
if (seesAllRecords(a)) return null;
const ids = a.visibleUserIds?.length
? a.visibleUserIds
: [a.userId];
return [...new Set(ids.filter(Boolean))];
}
/** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */
export function ownerWhere(
actor: DeepAccessActor,
scope: DataScope = 'contacts',
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
const deep = deepOwnerIds(actor, scope);
if (deep.kind === 'open') return {};
if (deep.kind === 'ids') {
if (deep.ids.length === 1) return { ownerId: deep.ids[0]! };
if (!deep.ids.length) return { ownerId: '__no_access__' };
return { ownerId: { in: deep.ids } };
}
const ids = scopedOwnerIds(actor, scope);
if (!ids) return {};
if (ids.length === 1) return { ownerId: ids[0]! };
return { ownerId: { in: ids } };
}
/** معامله: عمق دادهٔ sales (JWT) یا مدل قدیمی own/team/org */
export function dealAccessWhere(
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 {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
};
}
/** کارت پروژه: عمق داده یا مدل قدیمی own/team/org */
export function projectCardAccessWhere(
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 {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
};
}
/** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */
export function assigneeWhere(actor: DeepAccessActor): Prisma.ActivityWhereInput {
const deep = deepOwnerIds(actor, 'activities');
const ids =
deep.kind === 'open'
? null
: deep.kind === 'ids'
? deep.ids
: scopedOwnerIds(actor, 'activities');
if (!ids) return {};
if (!ids.length) return { assigneeId: '__no_access__' };
return {
OR: [
{ assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
};
}
/** دسترسی به فعالیت: عمق دادهٔ activities (JWT) یا مدل قدیمی */
export function activityAccessWhere(
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 {
OR: [
{ assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
{ participantIds: { hasSome: ids } },
],
};
}
/** یادداشت: عمق داده یا مدل قدیمی + محرمانگی شخصی */
export function noteAccessWhere(
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: [
{ visibility: { not: 'personal' } },
{ visibility: 'personal', authorId: actor.userId },
// رکوردهای قدیمی بدون فیلد visibility در DB با default team می‌آیند
],
};
if (!ids) return personalGate;
return {
AND: [
personalGate,
{
OR: [
{ authorId: { in: ids } },
{
deal: {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
},
},
{ person: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } },
],
},
],
};
}
export function resolveOwnerId(
actor: Pick<Actor, 'userId' | 'role'>,
requested?: string | null,
allowOthers?: boolean,
): string {
const allowed = allowOthers ?? elevated(actor.role);
if (allowed && requested?.trim()) {
return requested.trim();
}
return actor.userId;
}
/** تیکت: عمق داده یا مدل قدیمی */
export function ticketAccessWhere(
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 {
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
};
}
/** کار روتین: عمق داده (وقتی 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);
}
export type OrgRole = UserRole;
export function normalizeDataVisibility(
value: string | null | undefined,
): DataVisibility {
if (value === 'team' || value === 'org' || value === 'own') return value;
return 'own';
}