Unified search algorithm across all lists; per-section record visibility (contacts, deals, activities, leads, finance, tickets, notes, projects)
This commit is contained in:
@@ -1,48 +1,82 @@
|
||||
import type { DataVisibility, UserRole } from '@mento/shared';
|
||||
import { canViewAllRecords } from '@mento/shared';
|
||||
import type { DataScope, DataVisibility, DataVisibilityScopes, UserRole } from '@mento/shared';
|
||||
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
export type ScopeVisibility = {
|
||||
dataVisibility: DataVisibility;
|
||||
visibleUserIds: string[] | null;
|
||||
};
|
||||
|
||||
export type Actor = {
|
||||
userId: string;
|
||||
role: string;
|
||||
organizationId: string;
|
||||
/** own | team | org — از Membership */
|
||||
/** own | team | org — سطح پیشفرض از Membership */
|
||||
dataVisibility?: DataVisibility;
|
||||
/**
|
||||
* شناسه کاربرانی که پروندهشان برای این Actor قابلمشاهده است.
|
||||
* اگر undefined باشد فقط خود کاربر؛ اگر null یعنی همه (elevated/org).
|
||||
*/
|
||||
visibleUserIds?: string[] | null;
|
||||
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
|
||||
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
|
||||
};
|
||||
|
||||
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
|
||||
|
||||
/** 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'>,
|
||||
actor: Pick<Actor, 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>,
|
||||
scope?: DataScope,
|
||||
): boolean {
|
||||
if (elevated(actor.role)) return true;
|
||||
if (actor.dataVisibility === 'org') return true;
|
||||
if (actor.visibleUserIds === null) 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;
|
||||
}
|
||||
|
||||
function scopedOwnerIds(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
|
||||
/** شناسه کاربرانی که پروندهشان در این بخش دیده میشود؛ null یعنی همه. */
|
||||
export function scopedOwnerIds(
|
||||
actor: ScopedActor,
|
||||
scope?: DataScope,
|
||||
): string[] | null {
|
||||
if (seesAllRecords(actor)) return null;
|
||||
const ids = actor.visibleUserIds?.length
|
||||
? actor.visibleUserIds
|
||||
: [actor.userId];
|
||||
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))];
|
||||
}
|
||||
|
||||
/** فیلتر مالکیت برای Deal / Quote / Invoice / Person / Company */
|
||||
/** فیلتر مالکیت؛ پیشفرض بخش «مشتریان» (Person / Company). برای فاکتور/پیشفاکتور scope=finance. */
|
||||
export function ownerWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
|
||||
actor: ScopedActor,
|
||||
scope: DataScope = 'contacts',
|
||||
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
|
||||
const ids = scopedOwnerIds(actor);
|
||||
const ids = scopedOwnerIds(actor, scope);
|
||||
if (!ids) return {};
|
||||
if (ids.length === 1) return { ownerId: ids[0]! };
|
||||
return { ownerId: { in: ids } };
|
||||
@@ -50,9 +84,9 @@ export function ownerWhere(
|
||||
|
||||
/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */
|
||||
export function dealAccessWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
|
||||
actor: ScopedActor,
|
||||
): Prisma.DealWhereInput {
|
||||
const ids = scopedOwnerIds(actor);
|
||||
const ids = scopedOwnerIds(actor, 'deals');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
OR: [
|
||||
@@ -64,9 +98,9 @@ export function dealAccessWhere(
|
||||
|
||||
/** کارت پروژه: مالک یا یکی از مسئولان در محدوده دید */
|
||||
export function projectCardAccessWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
|
||||
actor: ScopedActor,
|
||||
): Prisma.ProjectCardWhereInput {
|
||||
const ids = scopedOwnerIds(actor);
|
||||
const ids = scopedOwnerIds(actor, 'projects');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
OR: [
|
||||
@@ -78,9 +112,9 @@ export function projectCardAccessWhere(
|
||||
|
||||
/** فعالیتها بر اساس مسئول اصلی یا مسئولان مشترک */
|
||||
export function assigneeWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
|
||||
actor: ScopedActor,
|
||||
): Prisma.ActivityWhereInput {
|
||||
const ids = scopedOwnerIds(actor);
|
||||
const ids = scopedOwnerIds(actor, 'activities');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
OR: [
|
||||
@@ -92,9 +126,9 @@ export function assigneeWhere(
|
||||
|
||||
/** یادداشت: نویسنده یا پروندههای تحت مالکیت در محدوده دید + محرمانگی شخصی */
|
||||
export function noteAccessWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
|
||||
actor: ScopedActor,
|
||||
): Prisma.NoteWhereInput {
|
||||
const ids = scopedOwnerIds(actor);
|
||||
const ids = scopedOwnerIds(actor, 'notes');
|
||||
const personalGate: Prisma.NoteWhereInput = {
|
||||
OR: [
|
||||
{ visibility: { not: 'personal' } },
|
||||
@@ -137,9 +171,9 @@ export function resolveOwnerId(
|
||||
|
||||
/** تیکت: مسئول یا ثبتکننده در محدوده دید */
|
||||
export function ticketAccessWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
|
||||
actor: ScopedActor,
|
||||
): Prisma.TicketWhereInput {
|
||||
const ids = scopedOwnerIds(actor);
|
||||
const ids = scopedOwnerIds(actor, 'tickets');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* الگوریتم مشترک جستجو در کل API:
|
||||
* - عبارت به کلمات شکسته میشود و همه کلمات باید (در هر فیلدی) پیدا شوند؛
|
||||
* پس «علی رضا» هم «علی رضایی» را پیدا میکند و بخشی از نام هم کافی است.
|
||||
* - حروف عربی/فارسی (ي/ی، ك/ک، ة/ه، أ/ا …) و ارقام فارسی/عربی/لاتین یکسان در نظر گرفته میشوند.
|
||||
* - برای شماره تلفن، پیشوند 0 / 98 / +98 نادیده گرفته میشود.
|
||||
*/
|
||||
|
||||
const MAX_TOKENS = 6;
|
||||
|
||||
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
||||
|
||||
function toAsciiDigits(s: string): string {
|
||||
return s.replace(/[۰-۹٠-٩]/g, (ch) => {
|
||||
const fa = FA_DIGITS.indexOf(ch);
|
||||
return String(fa >= 0 ? fa : AR_DIGITS.indexOf(ch));
|
||||
});
|
||||
}
|
||||
|
||||
function toFaDigits(s: string): string {
|
||||
return s.replace(/[0-9]/g, (d) => FA_DIGITS[Number(d)]!);
|
||||
}
|
||||
|
||||
function toPersianLetters(s: string): string {
|
||||
return s
|
||||
.replace(/[يىئ]/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(/ة/g, 'ه')
|
||||
.replace(/[أإٱ]/g, 'ا')
|
||||
.replace(/ؤ/g, 'و');
|
||||
}
|
||||
|
||||
function toArabicLetters(s: string): string {
|
||||
return s.replace(/ی/g, 'ي').replace(/ک/g, 'ك');
|
||||
}
|
||||
|
||||
/** متن ورودی جستجو را یکدست میکند (ارقام لاتین، حروف فارسی، فاصلههای یکدست). */
|
||||
export function normalizeSearchText(raw: string | null | undefined): string {
|
||||
return toPersianLetters(toAsciiDigits(String(raw ?? '')))
|
||||
.replace(/[\u064B-\u065F\u0670]/g, '')
|
||||
.replace(/[\u200c\u200f\u200e]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function searchTokens(raw: string | null | undefined): string[] {
|
||||
const text = normalizeSearchText(raw);
|
||||
if (!text) return [];
|
||||
return [...new Set(text.split(' ').filter(Boolean))].slice(0, MAX_TOKENS);
|
||||
}
|
||||
|
||||
/** شکلهای مختلف یک کلمه که ممکن است در دیتابیس ذخیره شده باشد. */
|
||||
export function tokenVariants(token: string): string[] {
|
||||
const out = new Set<string>([token, toArabicLetters(token)]);
|
||||
if (/[0-9]/.test(token)) {
|
||||
out.add(toFaDigits(token));
|
||||
if (/^\+?[0-9]+$/.test(token)) {
|
||||
const core = token.replace(/^\+/, '').replace(/^(0098|98|0)/, '');
|
||||
if (core.length >= 3) out.add(core);
|
||||
}
|
||||
}
|
||||
return [...out].filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* شرط Prisma برای جستجو: AND روی کلمات، OR روی شکلها و فیلدها.
|
||||
* `fieldsFor(term)` شرطهای OR یک کلمه را برای فیلدهای آن مدل برمیگرداند.
|
||||
*/
|
||||
export function buildSearchWhere<W extends object>(
|
||||
raw: string | null | undefined,
|
||||
fieldsFor: (term: string) => W[],
|
||||
): W | undefined {
|
||||
const tokens = searchTokens(raw);
|
||||
if (!tokens.length) return undefined;
|
||||
const parts = tokens.map((token) => ({
|
||||
OR: tokenVariants(token).flatMap((v) => fieldsFor(v)),
|
||||
}));
|
||||
return (parts.length === 1 ? parts[0] : { AND: parts }) as unknown as W;
|
||||
}
|
||||
|
||||
/** کمک برای فیلدهای متنی ساده: `{ field: { contains, mode: 'insensitive' } }` */
|
||||
export function ci(term: string) {
|
||||
return { contains: term, mode: 'insensitive' as const };
|
||||
}
|
||||
Reference in New Issue
Block a user