diff --git a/apps/api/prisma/migrations/20261006150000_membership_visibility_scopes/migration.sql b/apps/api/prisma/migrations/20261006150000_membership_visibility_scopes/migration.sql new file mode 100644 index 0000000..b49a038 --- /dev/null +++ b/apps/api/prisma/migrations/20261006150000_membership_visibility_scopes/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Membership" ADD COLUMN IF NOT EXISTS "dataVisibilityScopes" JSONB NOT NULL DEFAULT '{}'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b92c027..db52248 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -318,6 +318,8 @@ model Membership { commissionPercent Decimal @default(0) @db.Decimal(8, 2) /// own | team | org — سطح دید داده‌ها برای نقش‌های غیرمالک/مدیر dataVisibility String @default("own") + /// سطح دید تفکیکی هر بخش، مثلاً {"contacts":"org","deals":"own"}؛ بخش‌های بدون مقدار = dataVisibility + dataVisibilityScopes Json @default("{}") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 3bd3cd6..38771e2 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -18,6 +18,7 @@ import { AutomationsService } from '../automations/automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { type Actor, assigneeWhere, dealAccessWhere, ownerWhere } from '../common/record-scope'; import { normalizeTags } from '../common/tags'; +import { buildSearchWhere, ci } from '../common/search'; type ActivityActor = Pick; @@ -427,24 +428,24 @@ export class ActivitiesService { ...(entity?.companyId ? [{ companyId: entity.companyId }] : []), ...(entity?.dealId ? [{ dealId: entity.dealId }] : []), ]; - if (q?.trim()) { - const needle = q.trim(); - andParts.push({ - OR: [ - { title: { contains: needle, mode: 'insensitive' } }, - { description: { contains: needle, mode: 'insensitive' } }, - { - person: { - OR: [ - { firstName: { contains: needle, mode: 'insensitive' } }, - { lastName: { contains: needle, mode: 'insensitive' } }, - ], - }, - }, - { deal: { title: { contains: needle, mode: 'insensitive' } } }, - ], - }); - } + const search = buildSearchWhere(q, (t) => [ + { title: ci(t) }, + { description: ci(t) }, + { tags: { has: t } }, + { + person: { + OR: [ + { firstName: ci(t) }, + { lastName: ci(t) }, + { mobile: { contains: t } }, + { phone: { contains: t } }, + ], + }, + }, + { company: { name: ci(t) } }, + { deal: { title: ci(t) } }, + ]); + if (search) andParts.push(search); const base: Prisma.ActivityWhereInput = { organizationId, AND: andParts, diff --git a/apps/api/src/ai/ai-ask.service.ts b/apps/api/src/ai/ai-ask.service.ts index 7c071bd..835ea7d 100644 --- a/apps/api/src/ai/ai-ask.service.ts +++ b/apps/api/src/ai/ai-ask.service.ts @@ -232,12 +232,12 @@ Rules: const periods = tehranPeriods(); const { now, dayKey } = periods; const orgId = actor.organizationId; - const dealScope = ownerWhere(actor); + const dealScope = ownerWhere(actor, 'deals'); const activityScope = assigneeWhere(actor); const personScope = ownerWhere(actor); const companyScope = ownerWhere(actor); - const invoiceScope = ownerWhere(actor); - const quoteScope = ownerWhere(actor); + const invoiceScope = ownerWhere(actor, 'finance'); + const quoteScope = ownerWhere(actor, 'finance'); const ticketScope = ticketAccessWhere(actor); const createdBetween = @@ -531,7 +531,7 @@ Rules: this.prisma.deal.findMany({ where: { organizationId: orgId, - ...ownerWhere(actor), + ...ownerWhere(actor, 'deals'), ...(personId ? { personId } : { status: 'open' }), }, select: { diff --git a/apps/api/src/audit-logs/audit-logs.service.ts b/apps/api/src/audit-logs/audit-logs.service.ts index b30bebf..4cf4f9b 100644 --- a/apps/api/src/audit-logs/audit-logs.service.ts +++ b/apps/api/src/audit-logs/audit-logs.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import type { AuditLogDto, AuditLogListResponse } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { buildSearchWhere, ci } from '../common/search'; export type AuditWriteInput = { organizationId: string; @@ -60,16 +61,14 @@ export class AuditLogsService { organizationId, ...(opts.actorUserId ? { actorUserId: opts.actorUserId } : {}), ...(opts.entityType ? { entityType: opts.entityType } : {}), - ...(opts.q?.trim() - ? { - OR: [ - { summary: { contains: opts.q.trim(), mode: 'insensitive' } }, - { path: { contains: opts.q.trim(), mode: 'insensitive' } }, - { action: { contains: opts.q.trim(), mode: 'insensitive' } }, - ], - } - : {}), }; + const search = buildSearchWhere(opts.q, (t) => [ + { summary: ci(t) }, + { path: ci(t) }, + { action: ci(t) }, + { actor: { fullName: ci(t) } }, + ]); + if (search) where.AND = [search]; const [total, rows] = await Promise.all([ this.prisma.auditLog.count({ where }), diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts index 29ae1b8..dbc442a 100644 --- a/apps/api/src/auth/jwt.strategy.ts +++ b/apps/api/src/auth/jwt.strategy.ts @@ -4,9 +4,13 @@ import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import type { DataVisibility } from '@mento/shared'; import { BillingService } from '../billing/billing.service'; -import { normalizeDataVisibility } from '../common/record-scope'; +import { + type Actor, + normalizeDataVisibility, + parseVisibilityScopes, +} from '../common/record-scope'; import { PrismaService } from '../prisma/prisma.service'; -import { canViewAllRecords } from '@mento/shared'; +import { DATA_SCOPES, canViewAllRecords } from '@mento/shared'; export type JwtPayload = { sub: string; @@ -53,7 +57,12 @@ export class JwtStrategy extends PassportStrategy(Strategy) { isActive: true, organization: { isActive: true }, }, - select: { organizationId: true, role: true, dataVisibility: true }, + select: { + organizationId: true, + role: true, + dataVisibility: true, + dataVisibilityScopes: true, + }, }); if (!membership) { throw new UnauthorizedException(); @@ -61,12 +70,26 @@ export class JwtStrategy extends PassportStrategy(Strategy) { await this.billing.requireActiveForOrganization(membership.organizationId); const dataVisibility = normalizeDataVisibility(membership.dataVisibility); - const visibleUserIds = await this.resolveVisibleUserIds( - membership.organizationId, - user.id, - membership.role, - dataVisibility, - ); + const scopeLevels = parseVisibilityScopes(membership.dataVisibilityScopes); + const elevatedRole = canViewAllRecords(membership.role); + + let teamIds: string[] | undefined; + const resolve = async (level: DataVisibility): Promise => { + if (elevatedRole || level === 'org') return null; + if (level === 'own') return [user.id]; + teamIds ??= await this.teamPeerIds(membership.organizationId, user.id); + return teamIds; + }; + + const visibleUserIds = await resolve(dataVisibility); + const scopes: Actor['scopes'] = {}; + if (!elevatedRole) { + for (const scope of DATA_SCOPES) { + const level = scopeLevels[scope]; + if (!level || level === dataVisibility) continue; + scopes[scope] = { dataVisibility: level, visibleUserIds: await resolve(level) }; + } + } return { userId: user.id, @@ -74,22 +97,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) { role: membership.role, dataVisibility, visibleUserIds, + scopes, }; } - private async resolveVisibleUserIds( - organizationId: string, - userId: string, - role: string, - dataVisibility: DataVisibility, - ): Promise { - if (canViewAllRecords(role) || dataVisibility === 'org') { - return null; - } - if (dataVisibility === 'own') { - return [userId]; - } - + private async teamPeerIds(organizationId: string, userId: string): Promise { const memberships = await this.prisma.teamMember.findMany({ where: { userId, diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts index ae6b987..39b18f7 100644 --- a/apps/api/src/common/record-scope.ts +++ b/apps/api/src/common/record-scope.ts @@ -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>; }; +export type ScopedActor = Pick; + +/** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */ +export function forScope(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)[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: Pick, + 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, +/** شناسه کاربرانی که پرونده‌شان در این بخش دیده می‌شود؛ 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: ScopedActor, + scope: DataScope = 'contacts', ): { ownerId: string } | { ownerId: { in: string[] } } | Record { - 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: 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: 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: 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: 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: ScopedActor, ): Prisma.TicketWhereInput { - const ids = scopedOwnerIds(actor); + const ids = scopedOwnerIds(actor, 'tickets'); if (!ids) return {}; return { OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }], diff --git a/apps/api/src/common/search.ts b/apps/api/src/common/search.ts new file mode 100644 index 0000000..918d58e --- /dev/null +++ b/apps/api/src/common/search.ts @@ -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([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( + 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 }; +} diff --git a/apps/api/src/contacts/companies.service.ts b/apps/api/src/contacts/companies.service.ts index dea6c8a..5ba2b3f 100644 --- a/apps/api/src/contacts/companies.service.ts +++ b/apps/api/src/contacts/companies.service.ts @@ -25,6 +25,7 @@ import { } from '../common/record-scope'; import { normalizeTags } from '../common/tags'; import { normalizePersonImportSource } from './person-source'; +import { buildSearchWhere, ci } from '../common/search'; function parseDateOrNull(raw?: string | null): Date | null { if (!raw?.trim()) return null; @@ -77,22 +78,22 @@ export class CompaniesService { page = 1, limit = 20, ): Promise> { + const search = buildSearchWhere(q, (t) => [ + { name: ci(t) }, + { email: ci(t) }, + { phone: { contains: t } }, + { otherPhones: { contains: t } }, + { nationalId: { contains: t } }, + { economicCode: { contains: t } }, + { code: ci(t) }, + { sourceRef: ci(t) }, + { city: ci(t) }, + { province: ci(t) }, + ]); const where: Prisma.CompanyWhereInput = { organizationId: actor.organizationId, ...ownerWhere(actor), - ...(q?.trim() - ? { - OR: [ - { name: { contains: q.trim(), mode: 'insensitive' } }, - { email: { contains: q.trim(), mode: 'insensitive' } }, - { phone: { contains: q.trim() } }, - { nationalId: { contains: q.trim() } }, - { code: { contains: q.trim() } }, - { sourceRef: { contains: q.trim() } }, - { city: { contains: q.trim(), mode: 'insensitive' } }, - ], - } - : {}), + ...(search ? { AND: [search] } : {}), }; const [rows, total] = await this.prisma.$transaction([ diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 36f8393..975b391 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -32,6 +32,7 @@ import { AutomationsService } from '../automations/automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { WebhooksService } from '../public-api/webhooks.service'; import { normalizeTags } from '../common/tags'; +import { buildSearchWhere, ci } from '../common/search'; import { normalizePersonImportSource, personImportSourceLabel, @@ -105,27 +106,24 @@ export class PeopleService { const sourceFilter = opts?.importSource?.trim() ? normalizePersonImportSource(opts.importSource) : undefined; + const search = buildSearchWhere(q, (t) => [ + { firstName: ci(t) }, + { lastName: ci(t) }, + { previousFirstName: ci(t) }, + { previousLastName: ci(t) }, + { email: ci(t) }, + { mobile: { contains: t } }, + { phone: { contains: t } }, + { sourceRef: ci(t) }, + { previousSourceRef: ci(t) }, + { company: { name: ci(t) } }, + ]); const where: Prisma.PersonWhereInput = { organizationId: actor.organizationId, ...ownerWhere(actor), ...(opts?.mood ? { mood: opts.mood } : {}), ...(sourceFilter ? { importSource: sourceFilter } : {}), - ...(q?.trim() - ? { - OR: [ - { firstName: { contains: q.trim(), mode: 'insensitive' } }, - { lastName: { contains: q.trim(), mode: 'insensitive' } }, - { previousFirstName: { contains: q.trim(), mode: 'insensitive' } }, - { previousLastName: { contains: q.trim(), mode: 'insensitive' } }, - { email: { contains: q.trim(), mode: 'insensitive' } }, - { mobile: { contains: q.trim() } }, - { phone: { contains: q.trim() } }, - { sourceRef: { contains: q.trim() } }, - { previousSourceRef: { contains: q.trim() } }, - { company: { name: { contains: q.trim(), mode: 'insensitive' } } }, - ], - } - : {}), + ...(search ? { AND: [search] } : {}), }; const [rows, total] = await this.prisma.$transaction([ diff --git a/apps/api/src/faq/faq.service.ts b/apps/api/src/faq/faq.service.ts index 3d7a407..4a84f07 100644 --- a/apps/api/src/faq/faq.service.ts +++ b/apps/api/src/faq/faq.service.ts @@ -6,6 +6,7 @@ import type { } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { buildSearchWhere, ci } from '../common/search'; @Injectable() export class FaqService { @@ -21,15 +22,12 @@ export class FaqService { ...(opts.category?.trim() ? { category: opts.category.trim() } : {}), - ...(opts.q?.trim() - ? { - OR: [ - { question: { contains: opts.q.trim(), mode: 'insensitive' } }, - { answer: { contains: opts.q.trim(), mode: 'insensitive' } }, - ], - } - : {}), }; + const search = buildSearchWhere(opts.q, (t) => [ + { question: ci(t) }, + { answer: ci(t) }, + ]); + if (search) where.AND = [search]; const rows = await this.prisma.faqArticle.findMany({ where, orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], diff --git a/apps/api/src/finance/cheques.service.ts b/apps/api/src/finance/cheques.service.ts index 700d946..3cc4121 100644 --- a/apps/api/src/finance/cheques.service.ts +++ b/apps/api/src/finance/cheques.service.ts @@ -10,7 +10,8 @@ import type { } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; -import { type Actor, elevated, ownerWhere } from '../common/record-scope'; +import { type ScopedActor, ownerWhere, scopedOwnerIds } from '../common/record-scope'; +import { buildSearchWhere, ci } from '../common/search'; const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const; @@ -20,7 +21,7 @@ export class ChequesService { async list( organizationId: string, - actor: Pick, + actor: ScopedActor, opts?: { status?: string; q?: string; direction?: string }, ): Promise { const rows = await this.prisma.cheque.findMany({ @@ -31,37 +32,7 @@ export class ChequesService { ...(opts?.direction === 'in' || opts?.direction === 'out' ? { direction: opts.direction } : {}), - ...(opts?.q?.trim() - ? { - OR: [ - { number: { contains: opts.q.trim(), mode: 'insensitive' } }, - { bank: { contains: opts.q.trim(), mode: 'insensitive' } }, - { - invoice: { - number: { contains: opts.q.trim(), mode: 'insensitive' }, - }, - }, - { - person: { - OR: [ - { - firstName: { - contains: opts.q.trim(), - mode: 'insensitive', - }, - }, - { - lastName: { - contains: opts.q.trim(), - mode: 'insensitive', - }, - }, - ], - }, - }, - ], - } - : {}), + ...this.searchWhere(opts?.q), }, include: this.include, orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }], @@ -70,9 +41,27 @@ export class ChequesService { return rows.map((row) => this.toDto(row)); } + private searchWhere(q?: string): Prisma.ChequeWhereInput { + const search = buildSearchWhere(q, (t) => [ + { number: ci(t) }, + { bank: ci(t) }, + { invoice: { number: ci(t) } }, + { + person: { + OR: [ + { firstName: ci(t) }, + { lastName: ci(t) }, + { mobile: { contains: t } }, + ], + }, + }, + ]); + return search ? { AND: [search] } : {}; + } + async create( organizationId: string, - actor: Pick, + actor: ScopedActor, dto: CreateChequeRequest, ): Promise { const person = await this.prisma.person.findFirst({ @@ -112,7 +101,7 @@ export class ChequesService { async update( organizationId: string, id: string, - actor: Pick, + actor: ScopedActor, dto: UpdateChequeRequest, ): Promise { const existing = await this.prisma.cheque.findFirst({ @@ -144,11 +133,12 @@ export class ChequesService { } private accessWhere( - actor: Pick, + actor: ScopedActor, ): Prisma.ChequeWhereInput { - if (elevated(actor.role)) return {}; + const ids = scopedOwnerIds(actor, 'finance'); + if (ids === null) return {}; return { - OR: [{ ownerId: actor.userId }, { invoice: { ownerId: actor.userId } }], + OR: [{ ownerId: { in: ids } }, { invoice: { ownerId: { in: ids } } }], }; } diff --git a/apps/api/src/invoices/invoices.service.ts b/apps/api/src/invoices/invoices.service.ts index f2b7e09..76042c0 100644 --- a/apps/api/src/invoices/invoices.service.ts +++ b/apps/api/src/invoices/invoices.service.ts @@ -15,6 +15,7 @@ import type { } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { buildSearchWhere, ci } from '../common/search'; import { type Actor, ownerWhere } from '../common/record-scope'; import { AutomationsService } from '../automations/automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service'; @@ -77,7 +78,7 @@ export class InvoicesService { const dateFilter = buildDateRangeFilter(opts.from, opts.to); const where: Prisma.InvoiceWhereInput = { organizationId, - ...ownerWhere(actor), + ...ownerWhere(actor, 'finance'), ...(opts.status ? { status: opts.status as never } : {}), ...(opts.kind ? { kind: opts.kind } : {}), ...(opts.dealId ? { dealId: opts.dealId } : {}), @@ -85,15 +86,7 @@ export class InvoicesService { ...(opts.companyId ? { companyId: opts.companyId } : {}), ...(opts.quoteId ? { quoteId: opts.quoteId } : {}), ...(dateFilter ? dateFilter : {}), - ...(opts.q?.trim() - ? { - OR: [ - { title: { contains: opts.q.trim(), mode: 'insensitive' } }, - { number: { contains: opts.q.trim(), mode: 'insensitive' } }, - { notes: { contains: opts.q.trim(), mode: 'insensitive' } }, - ], - } - : {}), + ...invoiceSearchWhere(opts.q), }; const hasAmountFilter = @@ -143,7 +136,7 @@ export class InvoicesService { actor: InvoiceActor, ): Promise { const row = await this.prisma.invoice.findFirst({ - where: { id, organizationId, ...ownerWhere(actor) }, + where: { id, organizationId, ...ownerWhere(actor, 'finance') }, include, }); if (!row) throw new NotFoundException('فاکتور پیدا نشد'); @@ -306,7 +299,7 @@ export class InvoicesService { dto: UpdateInvoiceRequest, ): Promise { const existing = await this.prisma.invoice.findFirst({ - where: { id, organizationId, ...ownerWhere(actor) }, + where: { id, organizationId, ...ownerWhere(actor, 'finance') }, select: { id: true, status: true, currency: true }, }); if (!existing) throw new NotFoundException('فاکتور پیدا نشد'); @@ -447,7 +440,7 @@ export class InvoicesService { dto: CreatePaymentRequest, ): Promise { const invoice = await this.prisma.invoice.findFirst({ - where: { id: invoiceId, organizationId, ...ownerWhere(actor) }, + where: { id: invoiceId, organizationId, ...ownerWhere(actor, 'finance') }, select: { id: true, status: true, @@ -681,7 +674,7 @@ export class InvoicesService { actor: InvoiceActor, ) { const found = await this.prisma.invoice.findFirst({ - where: { id, organizationId, ...ownerWhere(actor) }, + where: { id, organizationId, ...ownerWhere(actor, 'finance') }, select: { id: true }, }); if (!found) throw new NotFoundException('فاکتور پیدا نشد'); @@ -874,6 +867,21 @@ function roundMoney(value: number): number { return Math.round((value + Number.EPSILON) * 100) / 100; } +function invoiceSearchWhere(q?: string): Prisma.InvoiceWhereInput { + const search = buildSearchWhere(q, (t) => [ + { title: ci(t) }, + { number: ci(t) }, + { notes: ci(t) }, + { + person: { + OR: [{ firstName: ci(t) }, { lastName: ci(t) }, { mobile: { contains: t } }], + }, + }, + { company: { name: ci(t) } }, + ]); + return search ? { AND: [search] } : {}; +} + function buildDateRangeFilter( from?: string, to?: string, diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index 095480d..2cfa6df 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -19,6 +19,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { type Actor, elevated, + forScope, } from '../common/record-scope'; import { asAddresses, @@ -28,6 +29,7 @@ import { import { emptyToNull, normalizePhone } from '../contacts/normalize'; import { LocalFileStorage } from '../storage/local-file.storage'; import { LeadStagesService } from './lead-stages.service'; +import { buildSearchWhere, ci } from '../common/search'; const LOGO_MAX_BYTES = 2 * 1024 * 1024; const LOGO_EXT: Record = { @@ -48,7 +50,8 @@ function mobileKey(raw: string | null | undefined): string | null { return d.startsWith('0') ? d : d; } -function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput { +function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput { + const actor = forScope(baseActor, 'leads'); if (elevated(actor.role)) return {}; if (actor.dataVisibility === 'org') return {}; if ( @@ -95,7 +98,17 @@ export class LeadsService { await this.stages.ensureDefaults(actor.organizationId); const page = Math.max(1, opts.page ?? 1); const limit = Math.min(100, Math.max(1, opts.limit ?? 20)); - const q = opts.q?.trim(); + const search = buildSearchWhere(opts.q, (t) => [ + { title: ci(t) }, + { contactName: ci(t) }, + { mobile: { contains: t } }, + { phone: { contains: t } }, + { city: ci(t) }, + { region: ci(t) }, + { trade: ci(t) }, + { instagram: ci(t) }, + { source: ci(t) }, + ]); const where: Prisma.LeadWhereInput = { organizationId: actor.organizationId, @@ -109,17 +122,7 @@ export class LeadsService { ...(opts.source ? { source: { contains: opts.source, mode: 'insensitive' } } : {}), - ...(q - ? { - OR: [ - { title: { contains: q, mode: 'insensitive' } }, - { contactName: { contains: q, mode: 'insensitive' } }, - { mobile: { contains: q } }, - { city: { contains: q, mode: 'insensitive' } }, - { trade: { contains: q, mode: 'insensitive' } }, - ], - } - : {}), + ...(search ? { AND: [search] } : {}), }; const orderBy: Prisma.LeadOrderByWithRelationInput = diff --git a/apps/api/src/loyalty/loyalty.service.ts b/apps/api/src/loyalty/loyalty.service.ts index e63daf1..0eb91cd 100644 --- a/apps/api/src/loyalty/loyalty.service.ts +++ b/apps/api/src/loyalty/loyalty.service.ts @@ -20,6 +20,7 @@ import type { import ExcelJS from 'exceljs'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { buildSearchWhere, ci } from '../common/search'; import { type Actor, ownerWhere } from '../common/record-scope'; import { SmsService } from '../common/sms.service'; @@ -723,22 +724,20 @@ export class LoyaltyService { q?: string, tierId?: string, ): Prisma.PersonWhereInput { - const query = q?.trim(); + const search = buildSearchWhere(q, (t) => [ + { firstName: ci(t) }, + { lastName: ci(t) }, + { mobile: { contains: t } }, + { phone: { contains: t } }, + { email: ci(t) }, + { sourceRef: ci(t) }, + { company: { name: ci(t) } }, + ]); return { organizationId: actor.organizationId, ...ownerWhere(actor), ...(tierId ? { loyaltyTierId: tierId } : {}), - ...(query - ? { - OR: [ - { firstName: { contains: query, mode: 'insensitive' } }, - { lastName: { contains: query, mode: 'insensitive' } }, - { mobile: { contains: query } }, - { email: { contains: query, mode: 'insensitive' } }, - { company: { name: { contains: query, mode: 'insensitive' } } }, - ], - } - : {}), + ...(search ? { AND: [search] } : {}), }; } diff --git a/apps/api/src/notes/notes.service.ts b/apps/api/src/notes/notes.service.ts index 9e91668..8057b10 100644 --- a/apps/api/src/notes/notes.service.ts +++ b/apps/api/src/notes/notes.service.ts @@ -17,6 +17,7 @@ import { Prisma } from '@prisma/client'; import { randomUUID } from 'node:crypto'; import * as path from 'path'; import { PrismaService } from '../prisma/prisma.service'; +import { buildSearchWhere, ci } from '../common/search'; import { LocalFileStorage } from '../storage/local-file.storage'; import { type Actor, @@ -131,15 +132,11 @@ export class NotesService { const page = opts.page ?? 1; const limit = opts.limit ?? 50; const access = noteAccessWhere(actor); - const search = opts.q?.trim() - ? { - OR: [ - { title: { contains: opts.q.trim(), mode: 'insensitive' as const } }, - { body: { contains: opts.q.trim(), mode: 'insensitive' as const } }, - { tags: { has: opts.q.trim() } }, - ], - } - : null; + const search = buildSearchWhere(opts.q, (t) => [ + { title: ci(t) }, + { body: ci(t) }, + { tags: { has: t } }, + ]); const where: Prisma.NoteWhereInput = { organizationId, diff --git a/apps/api/src/platform/platform.service.ts b/apps/api/src/platform/platform.service.ts index 0f106fc..05870b1 100644 --- a/apps/api/src/platform/platform.service.ts +++ b/apps/api/src/platform/platform.service.ts @@ -20,6 +20,7 @@ import { OrgRolesService } from '../org-roles/org-roles.service'; import { PipelinesService } from '../pipelines/pipelines.service'; import { BillingService } from '../billing/billing.service'; import { PrismaService } from '../prisma/prisma.service'; +import { buildSearchWhere, ci } from '../common/search'; import type { ApplyBillingPlanDto, CreatePlatformAdminDto, @@ -81,16 +82,11 @@ export class PlatformService { } async listOrganizations(query?: string): Promise { - const q = query?.trim(); const rows = await this.prisma.organization.findMany({ - where: q - ? { - OR: [ - { name: { contains: q, mode: 'insensitive' } }, - { slug: { contains: q, mode: 'insensitive' } }, - ], - } - : undefined, + where: buildSearchWhere(query, (t) => [ + { name: ci(t) }, + { slug: ci(t) }, + ]), orderBy: { createdAt: 'desc' }, select: { id: true, @@ -340,18 +336,14 @@ export class PlatformService { } async listUsers(query?: string, ownersOnly = false): Promise { - const q = query?.trim(); + const search = buildSearchWhere(query, (t) => [ + { fullName: ci(t) }, + { email: ci(t) }, + { mobile: { contains: t } }, + ]); const rows = await this.prisma.user.findMany({ where: { - ...(q - ? { - OR: [ - { fullName: { contains: q, mode: 'insensitive' } }, - { email: { contains: q, mode: 'insensitive' } }, - { mobile: { contains: q } }, - ], - } - : {}), + ...(search ? { AND: [search] } : {}), ...(ownersOnly ? { memberships: { some: { role: 'owner', isActive: true } } } : {}), diff --git a/apps/api/src/products/products.service.ts b/apps/api/src/products/products.service.ts index a0de739..5c6f56c 100644 --- a/apps/api/src/products/products.service.ts +++ b/apps/api/src/products/products.service.ts @@ -10,6 +10,7 @@ import type { } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { buildSearchWhere, ci } from '../common/search'; import { InventoryService } from '../inventory/inventory.service'; type ProductRow = { @@ -56,26 +57,15 @@ export class ProductsService { ? { category: opts.category.trim() } : {}), ...(opts?.kind ? { kind: opts.kind } : {}), - ...(opts?.q?.trim() - ? { - OR: [ - { name: { contains: opts.q.trim(), mode: 'insensitive' } }, - { sku: { contains: opts.q.trim(), mode: 'insensitive' } }, - { - description: { - contains: opts.q.trim(), - mode: 'insensitive', - }, - }, - { - category: { - contains: opts.q.trim(), - mode: 'insensitive', - }, - }, - ], - } - : {}), + ...(() => { + const search = buildSearchWhere(opts?.q, (t) => [ + { name: ci(t) }, + { sku: ci(t) }, + { description: ci(t) }, + { category: ci(t) }, + ]); + return search ? { AND: [search] } : {}; + })(), }, orderBy: [{ isActive: 'desc' }, { name: 'asc' }], }); diff --git a/apps/api/src/public-api/public-api-v1.controller.ts b/apps/api/src/public-api/public-api-v1.controller.ts index de29b0c..371b27c 100644 --- a/apps/api/src/public-api/public-api-v1.controller.ts +++ b/apps/api/src/public-api/public-api-v1.controller.ts @@ -8,7 +8,9 @@ import { import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; import { Type } from 'class-transformer'; import { CurrentUser } from '../auth/current-user.decorator'; +import type { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { buildSearchWhere, ci } from '../common/search'; import { ApiKeyAuthGuard } from './api-key-auth.guard'; class ListQueryDto { @@ -42,18 +44,16 @@ export class PublicApiV1Controller { ) { const page = query.page ?? 1; const limit = query.limit ?? 20; - const where = { + const search = buildSearchWhere(query.q, (t) => [ + { firstName: ci(t) }, + { lastName: ci(t) }, + { mobile: { contains: t } }, + { phone: { contains: t } }, + { email: ci(t) }, + ]); + const where: Prisma.PersonWhereInput = { organizationId: user.organizationId, - ...(query.q?.trim() - ? { - OR: [ - { firstName: { contains: query.q.trim(), mode: 'insensitive' as const } }, - { lastName: { contains: query.q.trim(), mode: 'insensitive' as const } }, - { mobile: { contains: query.q.trim() } }, - { email: { contains: query.q.trim(), mode: 'insensitive' as const } }, - ], - } - : {}), + ...(search ? { AND: [search] } : {}), }; const [total, items] = await Promise.all([ this.prisma.person.count({ where }), @@ -123,13 +123,10 @@ export class PublicApiV1Controller { ) { const page = query.page ?? 1; const limit = query.limit ?? 20; - const where = { + const search = buildSearchWhere(query.q, (t) => [{ title: ci(t) }]); + const where: Prisma.DealWhereInput = { organizationId: user.organizationId, - ...(query.q?.trim() - ? { - title: { contains: query.q.trim(), mode: 'insensitive' as const }, - } - : {}), + ...(search ? { AND: [search] } : {}), }; const [total, items] = await Promise.all([ this.prisma.deal.count({ where }), diff --git a/apps/api/src/quotes/quotes.service.ts b/apps/api/src/quotes/quotes.service.ts index 56240e0..4bbbfca 100644 --- a/apps/api/src/quotes/quotes.service.ts +++ b/apps/api/src/quotes/quotes.service.ts @@ -12,6 +12,7 @@ import type { } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { buildSearchWhere, ci } from '../common/search'; import { type Actor, ownerWhere } from '../common/record-scope'; type QuoteActor = Pick; @@ -50,21 +51,13 @@ export class QuotesService { const dateFilter = buildDateRangeFilter(opts.from, opts.to); const where: Prisma.QuoteWhereInput = { organizationId, - ...ownerWhere(actor), + ...ownerWhere(actor, 'finance'), ...(opts.status ? { status: opts.status as never } : {}), ...(opts.dealId ? { dealId: opts.dealId } : {}), ...(opts.personId ? { personId: opts.personId } : {}), ...(opts.companyId ? { companyId: opts.companyId } : {}), ...(dateFilter ? dateFilter : {}), - ...(opts.q?.trim() - ? { - OR: [ - { title: { contains: opts.q.trim(), mode: 'insensitive' } }, - { number: { contains: opts.q.trim(), mode: 'insensitive' } }, - { notes: { contains: opts.q.trim(), mode: 'insensitive' } }, - ], - } - : {}), + ...quoteSearchWhere(opts.q), }; const [rows, total] = await this.prisma.$transaction([ @@ -92,7 +85,7 @@ export class QuotesService { actor: QuoteActor, ): Promise { const row = await this.prisma.quote.findFirst({ - where: { id, organizationId, ...ownerWhere(actor) }, + where: { id, organizationId, ...ownerWhere(actor, 'finance') }, include, }); if (!row) throw new NotFoundException('پیش‌فاکتور پیدا نشد'); @@ -295,7 +288,7 @@ export class QuotesService { actor: QuoteActor, ) { const found = await this.prisma.quote.findFirst({ - where: { id, organizationId, ...ownerWhere(actor) }, + where: { id, organizationId, ...ownerWhere(actor, 'finance') }, select: { id: true }, }); if (!found) throw new NotFoundException('پیش‌فاکتور پیدا نشد'); @@ -413,6 +406,21 @@ function roundMoney(value: number): number { return Math.round((value + Number.EPSILON) * 100) / 100; } +function quoteSearchWhere(q?: string): Prisma.QuoteWhereInput { + const search = buildSearchWhere(q, (t) => [ + { title: ci(t) }, + { number: ci(t) }, + { notes: ci(t) }, + { + person: { + OR: [{ firstName: ci(t) }, { lastName: ci(t) }, { mobile: { contains: t } }], + }, + }, + { company: { name: ci(t) } }, + ]); + return search ? { AND: [search] } : {}; +} + function buildDateRangeFilter( from?: string, to?: string, diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts index 73a1959..82d4864 100644 --- a/apps/api/src/reports/reports.service.ts +++ b/apps/api/src/reports/reports.service.ts @@ -46,8 +46,8 @@ export class ReportsService { ): Promise { const { start, end } = resolveRange(opts.from, opts.to); const orgId = actor.organizationId; - const dealScope = ownerWhere(actor); - const invoiceScope = ownerWhere(actor); + const dealScope = ownerWhere(actor, 'deals'); + const invoiceScope = ownerWhere(actor, 'finance'); const activityScope = assigneeWhere(actor); const ticketScope = ticketAccessWhere(actor); const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId }; @@ -1026,7 +1026,7 @@ export class ReportsService { ): Promise { const { start, end } = resolveRange(opts.from, opts.to); const orgId = actor.organizationId; - const dealScope = ownerWhere(actor); + const dealScope = ownerWhere(actor, 'deals'); const rates = await loadOrgRates(this.prisma, orgId); const baseSelect = { @@ -1236,7 +1236,7 @@ export class ReportsService { const dealOwnerWhere = ownerFilterIds == null - ? ownerWhere(actor) + ? ownerWhere(actor, 'deals') : { ownerId: { in: ownerFilterIds } }; const activityAssigneeWhere = ownerFilterIds == null @@ -1458,7 +1458,7 @@ export class ReportsService { actor: Actor, opts: { userId?: string; teamId?: string }, ): Promise { - const scope = ownerWhere(actor); + const scope = ownerWhere(actor, 'deals'); let allowed: string[] | null = null; if ('ownerId' in scope && typeof scope.ownerId === 'string') { allowed = [scope.ownerId]; @@ -1516,7 +1516,7 @@ export class ReportsService { async home(actor: Actor): Promise { const orgId = actor.organizationId; - const dealScope = ownerWhere(actor); + const dealScope = ownerWhere(actor, 'deals'); const activityScope = assigneeWhere(actor); const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId }; const rates = await loadOrgRates(this.prisma, orgId); diff --git a/apps/api/src/sales/sales.service.ts b/apps/api/src/sales/sales.service.ts index df91113..0ef86cf 100644 --- a/apps/api/src/sales/sales.service.ts +++ b/apps/api/src/sales/sales.service.ts @@ -16,9 +16,11 @@ import type { } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { buildSearchWhere, ci } from '../common/search'; import { type Actor, elevated, + forScope, ownerWhere, } from '../common/record-scope'; import { @@ -69,10 +71,10 @@ export class SalesService { if (opts.type && isGoalType(opts.type)) { where.type = opts.type; } - const q = opts.q?.trim(); - if (q) { - where.title = { contains: q, mode: 'insensitive' }; - } + const search = buildSearchWhere(opts.q, (t) => [ + { title: ci(t) }, + ]); + if (search) where.AND = [search]; const rows = await this.prisma.salesGoal.findMany({ where, @@ -237,7 +239,7 @@ export class SalesService { const dealOwner = elevated(actor.role) && opts.userId ? { ownerId: opts.userId } - : ownerWhere(actor); + : ownerWhere(actor, 'deals'); const goalsWhere: Prisma.SalesGoalWhereInput = { organizationId: actor.organizationId, @@ -400,9 +402,10 @@ export class SalesService { } private goalVisibility( - actor: Actor, + baseActor: Actor, filterUserId?: string, ): Prisma.SalesGoalWhereInput { + const actor = forScope(baseActor, 'deals'); if (elevated(actor.role) || actor.dataVisibility === 'org' || actor.visibleUserIds === null) { if (filterUserId === 'org') return { userId: null }; if (filterUserId) return { userId: filterUserId }; diff --git a/apps/api/src/tickets/tickets.service.ts b/apps/api/src/tickets/tickets.service.ts index a80bd58..a3e8258 100644 --- a/apps/api/src/tickets/tickets.service.ts +++ b/apps/api/src/tickets/tickets.service.ts @@ -14,6 +14,7 @@ import type { import { TICKET_SLA_HOURS } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { buildSearchWhere, ci } from '../common/search'; import { type Actor, elevated, @@ -63,9 +64,19 @@ export class TicketsService { const limit = opts.limit ?? 50; const now = new Date(); const access = ticketAccessWhere(actor); - const where: Prisma.TicketWhereInput = { + const search = buildSearchWhere(opts.q, (t) => [ + { subject: ci(t) }, + { number: ci(t) }, + { description: ci(t) }, + { + person: { + OR: [{ firstName: ci(t) }, { lastName: ci(t) }, { mobile: { contains: t } }], + }, + }, + ]); + const finalWhere: Prisma.TicketWhereInput = { organizationId: actor.organizationId, - ...access, + AND: [access, ...(search ? [search] : [])], ...(opts.status ? { status: opts.status as never } : {}), ...(opts.priority ? { priority: opts.priority as never } : {}), ...(opts.personId ? { personId: opts.personId } : {}), @@ -79,62 +90,8 @@ export class TicketsService { status: { in: ['open', 'in_progress', 'waiting'] }, } : {}), - ...(opts.q?.trim() - ? { - AND: [ - access, - { - OR: [ - { subject: { contains: opts.q.trim(), mode: 'insensitive' } }, - { number: { contains: opts.q.trim(), mode: 'insensitive' } }, - { - description: { - contains: opts.q.trim(), - mode: 'insensitive', - }, - }, - ], - }, - ], - } - : {}), }; - // When q is set, avoid double-spreading access at top level - const finalWhere: Prisma.TicketWhereInput = opts.q?.trim() - ? { - organizationId: actor.organizationId, - ...(opts.status ? { status: opts.status as never } : {}), - ...(opts.priority ? { priority: opts.priority as never } : {}), - ...(opts.personId ? { personId: opts.personId } : {}), - ...(opts.assigneeId && elevated(actor.role) - ? { assigneeId: opts.assigneeId } - : {}), - ...(opts.sla === 'overdue' - ? { - slaDueAt: { lt: now }, - firstRespondedAt: null, - status: { in: ['open', 'in_progress', 'waiting'] }, - } - : {}), - AND: [ - access, - { - OR: [ - { subject: { contains: opts.q.trim(), mode: 'insensitive' } }, - { number: { contains: opts.q.trim(), mode: 'insensitive' } }, - { - description: { - contains: opts.q.trim(), - mode: 'insensitive', - }, - }, - ], - }, - ], - } - : where; - const [rows, total] = await this.prisma.$transaction([ this.prisma.ticket.findMany({ where: finalWhere, diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts index c0b5fb9..af771bc 100644 --- a/apps/api/src/users/dto.ts +++ b/apps/api/src/users/dto.ts @@ -3,6 +3,7 @@ import { IsBoolean, IsIn, IsNumber, + IsObject, IsOptional, IsString, Matches, @@ -89,6 +90,12 @@ export class UpdateUserCommissionDto { } export class UpdateUserVisibilityDto { + @IsOptional() @IsIn(['own', 'team', 'org']) - dataVisibility!: 'own' | 'team' | 'org'; + dataVisibility?: 'own' | 'team' | 'org'; + + /** سطح دید هر بخش؛ null یعنی «مثل پیش‌فرض». در سرویس اعتبارسنجی می‌شود. */ + @IsOptional() + @IsObject() + scopes?: Record; } diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index a254215..b91e294 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -107,7 +107,7 @@ export class UsersController { user.organizationId, user.role, id, - dto.dataVisibility, + dto, ); } diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index be18027..b2cb0c7 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -6,13 +6,16 @@ import { NotFoundException, } from '@nestjs/common'; import type { + DataScope, DataVisibility, InviteUserRequest, OrgUserDto, UpdateOrgUserRequest, + UpdateUserVisibilityRequest, UserRole, } from '@mento/shared'; -import { DATA_VISIBILITIES } from '@mento/shared'; +import { DATA_SCOPES, DATA_VISIBILITIES } from '@mento/shared'; +import { parseVisibilityScopes } from '../common/record-scope'; import * as bcrypt from 'bcryptjs'; import { Prisma } from '@prisma/client'; import { normalizeMobile } from '../auth/auth.service'; @@ -40,6 +43,7 @@ type MembershipRow = { isActive: boolean; commissionPercent: { toString(): string } | number | null; dataVisibility?: string | null; + dataVisibilityScopes?: unknown; createdAt: Date; orgRole: { id: string; name: string; color: string } | null; user: { @@ -241,14 +245,28 @@ export class UsersService { organizationId: string, actorRole: UserRole, targetId: string, - dataVisibility: DataVisibility, + dto: UpdateUserVisibilityRequest, ): Promise { if (actorRole !== 'owner' && actorRole !== 'admin') { throw new ForbiddenException('اجازه تغییر سطح دسترسی به پرونده‌ها را ندارید'); } - if (!DATA_VISIBILITIES.includes(dataVisibility)) { + const { dataVisibility, scopes } = dto; + if (dataVisibility === undefined && !scopes) { + throw new BadRequestException('تغییری ارسال نشده است'); + } + if (dataVisibility !== undefined && !DATA_VISIBILITIES.includes(dataVisibility)) { throw new BadRequestException('سطح دسترسی به پرونده‌ها نامعتبر است'); } + if (scopes) { + for (const [key, value] of Object.entries(scopes)) { + if (!DATA_SCOPES.includes(key as DataScope)) { + throw new BadRequestException(`بخش نامعتبر: ${key}`); + } + if (value !== null && !DATA_VISIBILITIES.includes(value as DataVisibility)) { + throw new BadRequestException('سطح دسترسی به پرونده‌ها نامعتبر است'); + } + } + } const target = await this.memberships.findUserInOrgById( organizationId, targetId, @@ -258,9 +276,23 @@ export class UsersService { 'مالک و مدیر همیشه همه پرونده‌ها را می‌بینند', ); } + const data: Prisma.MembershipUpdateInput = {}; + if (dataVisibility !== undefined) data.dataVisibility = dataVisibility; + if (scopes) { + const current = await this.prisma.membership.findUnique({ + where: { id: target.id }, + select: { dataVisibilityScopes: true }, + }); + const merged = parseVisibilityScopes(current?.dataVisibilityScopes); + for (const [key, value] of Object.entries(scopes)) { + if (value === null) delete merged[key as DataScope]; + else merged[key as DataScope] = value; + } + data.dataVisibilityScopes = merged; + } await this.prisma.membership.update({ where: { id: target.id }, - data: { dataVisibility }, + data, }); return this.dtoOf(organizationId, targetId); } @@ -442,6 +474,10 @@ export class UsersService { : DATA_VISIBILITIES.includes(row.dataVisibility as DataVisibility) ? (row.dataVisibility as DataVisibility) : 'own', + dataVisibilityScopes: + row.role === 'owner' || row.role === 'admin' + ? {} + : parseVisibilityScopes(row.dataVisibilityScopes), createdAt: row.createdAt.toISOString(), }; } diff --git a/apps/web/src/components/TeamsSettingsPanel.vue b/apps/web/src/components/TeamsSettingsPanel.vue index a587db5..b916493 100644 --- a/apps/web/src/components/TeamsSettingsPanel.vue +++ b/apps/web/src/components/TeamsSettingsPanel.vue @@ -6,8 +6,11 @@ import type { OrgUserDto, TeamDto, TeamMemberRole, + UpdateUserVisibilityRequest, } from '@mento/shared'; import { + DATA_SCOPE_LABELS, + DATA_SCOPES, DATA_VISIBILITY_LABELS, DATA_VISIBILITIES, type DataVisibility, @@ -173,11 +176,17 @@ async function removeTeam(id: string) { } } -async function setVisibility(userId: string, dataVisibility: DataVisibility) { +const expandedVisibility = ref(null); + +function scopeOverrides(u: OrgUserDto): number { + return Object.keys(u.dataVisibilityScopes ?? {}).length; +} + +async function setVisibility(userId: string, payload: UpdateUserVisibilityRequest) { + error.value = ''; try { - await api.patch(`/users/${userId}/visibility`, { dataVisibility }); - ok.value = - 'سطح دسترسی به پرونده‌ها به‌روز شد. کاربر باید دوباره وارد شود تا اعمال شود.'; + await api.patch(`/users/${userId}/visibility`, payload); + ok.value = 'سطح دسترسی به پرونده‌ها به‌روز شد.'; emit('refreshUsers'); } catch (e) { error.value = axios.isAxiosError(e) @@ -290,7 +299,9 @@ onMounted(() => {

سطح دسترسی به پرونده‌ها

- مالک و مدیر همیشه همه پرونده‌ها را می‌بینند. برای بقیه: فقط خود، تیم‌های عضو، یا کل سازمان. + مالک و مدیر همیشه همه پرونده‌ها را می‌بینند. برای بقیه یک سطح پیش‌فرض (فقط خود، تیم‌های عضو، + یا کل سازمان) تعیین کنید و در صورت نیاز با «تنظیم تفکیکی» برای هر بخش سطح جداگانه بگذارید؛ + مثلاً همه مشتریان را ببیند ولی معاملات فقط مال خودش.

{ description="فقط نقش‌های فروش و پشتیبانی سطح دسترسی جدا به پرونده‌ها دارند." />
-
- {{ u.fullName }} - +
+
+ + {{ u.fullName }} + + {{ toFaDigits(scopeOverrides(u)) }} بخش تفکیکی + + + + + {{ expandedVisibility === u.id ? 'بستن' : 'تنظیم تفکیکی' }} + +
+
+ +
@@ -386,13 +434,42 @@ onMounted(() => { display: grid; gap: 0.5rem; } +.vis-user { + border-bottom: 1px solid #f1f5f9; + padding-bottom: 0.45rem; +} .vis-row { display: grid; - grid-template-columns: 1fr 220px; + grid-template-columns: 1fr 240px auto; gap: 0.75rem; align-items: center; padding: 0.45rem 0; - border-bottom: 1px solid #f1f5f9; +} +.vis-badge { + margin-inline-start: 0.4rem; + padding: 0.1rem 0.45rem; + border-radius: 999px; + background: #eef2ff; + color: #4338ca; + font-size: 0.72rem; +} +.vis-scopes { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 0.5rem 1rem; + padding: 0.6rem 0.75rem; + background: #f8fafc; + border-radius: 10px; +} +.vis-scope { + display: grid; + gap: 0.25rem; + font-size: 0.85rem; + color: #334155; +} +select.custom { + border-color: #6366f1; + background: #eef2ff; } select { font: inherit; diff --git a/apps/web/src/components/UserMultiSelect.vue b/apps/web/src/components/UserMultiSelect.vue index 7bc53fa..8f40e2e 100644 --- a/apps/web/src/components/UserMultiSelect.vue +++ b/apps/web/src/components/UserMultiSelect.vue @@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'; import UiIcon from '@/components/UiIcon.vue'; import UserAvatar from '@/components/UserAvatar.vue'; +import { matchesSearch } from '@/lib/search'; type PickUser = { id: string; fullName: string }; @@ -35,9 +36,8 @@ const selected = computed(() => props.modelValue.map((id) => byId.value.get(id) ?? { id, fullName: '—' }), ); const filtered = computed(() => { - const q = query.value.trim().toLowerCase(); - if (!q) return props.users; - return props.users.filter((u) => u.fullName.toLowerCase().includes(q)); + if (!query.value.trim()) return props.users; + return props.users.filter((u) => matchesSearch(query.value, [u.fullName])); }); const canRemove = computed(() => props.modelValue.length > props.min); diff --git a/apps/web/src/lib/pipeline-board.ts b/apps/web/src/lib/pipeline-board.ts index 30d3c82..5039647 100644 --- a/apps/web/src/lib/pipeline-board.ts +++ b/apps/web/src/lib/pipeline-board.ts @@ -1,3 +1,5 @@ +import { matchesSearch } from '@/lib/search'; + export type PipelineAccent = 'green' | 'orange' | 'purple'; export type PipelineDeal = { @@ -14,6 +16,8 @@ export type PipelineDeal = { assigneeNames?: string[]; ownerId?: string | null; assigneeIds?: string[]; + /** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */ + searchFields?: Array; }; export type PipelineUiCopy = { @@ -190,6 +194,12 @@ export function mapBoardToStages( assigneeNames, ownerId: deal.owner?.id ?? null, assigneeIds: (deal.assignees ?? []).map((a) => a.id), + searchFields: [ + deal.notes, + deal.company?.name, + deal.person?.fullName, + ...assigneeNames, + ], }; }), }; @@ -246,6 +256,13 @@ export function mapProjectBoardToStages( assigneeNames, ownerId: card.owner?.id ?? null, assigneeIds: (card.assignees ?? []).map((a) => a.id), + searchFields: [ + card.notes, + card.company?.name, + card.person?.fullName, + card.deal?.title, + ...assigneeNames, + ], }; }), })); @@ -343,11 +360,7 @@ export function filterStages( (d.assigneeIds ?? []).includes(mineUserId); if (!isMine) return false; } - if ( - q && - !d.title.toLowerCase().includes(q) && - !d.description.toLowerCase().includes(q) - ) { + if (q && !matchesSearch(q, [d.title, d.description, ...(d.searchFields ?? [])])) { return false; } if (min != null && !Number.isNaN(min) && (d.amount == null || d.amount < min)) { diff --git a/apps/web/src/lib/search.ts b/apps/web/src/lib/search.ts new file mode 100644 index 0000000..4ab4866 --- /dev/null +++ b/apps/web/src/lib/search.ts @@ -0,0 +1,43 @@ +/** + * همان الگوریتم جستجوی سرور (apps/api/src/common/search.ts) برای فیلترهای سمت کاربر: + * کلمه‌به‌کلمه، بخشی از کلمه کافی است، حروف عربی/فارسی و ارقام فارسی/لاتین یکسان‌اند. + */ + +function toAsciiDigits(s: string): string { + return s.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))).replace( + /[٠-٩]/g, + (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)), + ); +} + +export function normalizeSearchText(raw: unknown): string { + return toAsciiDigits(String(raw ?? '')) + .toLowerCase() + .replace(/[يىئ]/g, 'ی') + .replace(/ك/g, 'ک') + .replace(/ة/g, 'ه') + .replace(/[أإٱ]/g, 'ا') + .replace(/ؤ/g, 'و') + .replace(/[\u064B-\u065F\u0670]/g, '') + .replace(/[\u200c\u200f\u200e]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function phoneCore(token: string): string | null { + if (!/^\+?[0-9]+$/.test(token)) return null; + const core = token.replace(/^\+/, '').replace(/^(0098|98|0)/, ''); + return core.length >= 3 ? core : null; +} + +/** آیا همه کلمات عبارت جستجو در یکی از فیلدها پیدا می‌شوند؟ عبارت خالی = true */ +export function matchesSearch(query: string, fields: unknown[]): boolean { + const q = normalizeSearchText(query); + if (!q) return true; + const hay = normalizeSearchText(fields.filter((f) => f != null && f !== '').join(' \u0001 ')); + return q.split(' ').every((token) => { + if (hay.includes(token)) return true; + const core = phoneCore(token); + return core ? hay.includes(core) : false; + }); +} diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index 1cb9a20..d63931c 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -1,5 +1,6 @@