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>
343 lines
11 KiB
TypeScript
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';
|
|
}
|