Unified search algorithm across all lists; per-section record visibility (contacts, deals, activities, leads, finance, tickets, notes, projects)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Membership" ADD COLUMN IF NOT EXISTS "dataVisibilityScopes" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<Actor, 'userId' | 'role'>;
|
||||
|
||||
@@ -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<Prisma.ActivityWhereInput>(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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<Prisma.AuditLogWhereInput>(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 }),
|
||||
|
||||
@@ -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<string[] | null> => {
|
||||
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<string[] | null> {
|
||||
if (canViewAllRecords(role) || dataVisibility === 'org') {
|
||||
return null;
|
||||
}
|
||||
if (dataVisibility === 'own') {
|
||||
return [userId];
|
||||
}
|
||||
|
||||
private async teamPeerIds(organizationId: string, userId: string): Promise<string[]> {
|
||||
const memberships = await this.prisma.teamMember.findMany({
|
||||
where: {
|
||||
userId,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<PaginatedResponse<CompanyDto>> {
|
||||
const search = buildSearchWhere<Prisma.CompanyWhereInput>(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([
|
||||
|
||||
@@ -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<Prisma.PersonWhereInput>(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([
|
||||
|
||||
@@ -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<Prisma.FaqArticleWhereInput>(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' }],
|
||||
|
||||
@@ -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, 'userId' | 'role'>,
|
||||
actor: ScopedActor,
|
||||
opts?: { status?: string; q?: string; direction?: string },
|
||||
): Promise<ChequeDto[]> {
|
||||
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<Prisma.ChequeWhereInput>(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, 'userId' | 'role'>,
|
||||
actor: ScopedActor,
|
||||
dto: CreateChequeRequest,
|
||||
): Promise<ChequeDto> {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
@@ -112,7 +101,7 @@ export class ChequesService {
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
actor: ScopedActor,
|
||||
dto: UpdateChequeRequest,
|
||||
): Promise<ChequeDto> {
|
||||
const existing = await this.prisma.cheque.findFirst({
|
||||
@@ -144,11 +133,12 @@ export class ChequesService {
|
||||
}
|
||||
|
||||
private accessWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
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 } } }],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<InvoiceDto> {
|
||||
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<InvoiceDto> {
|
||||
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<InvoiceDto> {
|
||||
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<Prisma.InvoiceWhereInput>(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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -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<Prisma.LeadWhereInput>(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 =
|
||||
|
||||
@@ -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<Prisma.PersonWhereInput>(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] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Prisma.NoteWhereInput>(opts.q, (t) => [
|
||||
{ title: ci(t) },
|
||||
{ body: ci(t) },
|
||||
{ tags: { has: t } },
|
||||
]);
|
||||
|
||||
const where: Prisma.NoteWhereInput = {
|
||||
organizationId,
|
||||
|
||||
@@ -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<PlatformOrganizationDto[]> {
|
||||
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<Prisma.OrganizationWhereInput>(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<PlatformUserDto[]> {
|
||||
const q = query?.trim();
|
||||
const search = buildSearchWhere<Prisma.UserWhereInput>(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 } } }
|
||||
: {}),
|
||||
|
||||
@@ -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<Prisma.ProductWhereInput>(opts?.q, (t) => [
|
||||
{ name: ci(t) },
|
||||
{ sku: ci(t) },
|
||||
{ description: ci(t) },
|
||||
{ category: ci(t) },
|
||||
]);
|
||||
return search ? { AND: [search] } : {};
|
||||
})(),
|
||||
},
|
||||
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
@@ -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<Prisma.PersonWhereInput>(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<Prisma.DealWhereInput>(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 }),
|
||||
|
||||
@@ -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<Actor, 'userId' | 'role'>;
|
||||
@@ -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<QuoteDto> {
|
||||
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<Prisma.QuoteWhereInput>(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,
|
||||
|
||||
@@ -46,8 +46,8 @@ export class ReportsService {
|
||||
): Promise<ReportsDashboardDto> {
|
||||
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<ReportsDrilldownDto> {
|
||||
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<string[] | null> {
|
||||
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<HomeDashboardDto> {
|
||||
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);
|
||||
|
||||
@@ -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<Prisma.SalesGoalWhereInput>(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 };
|
||||
|
||||
@@ -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<Prisma.TicketWhereInput>(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,
|
||||
|
||||
@@ -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<string, 'own' | 'team' | 'org' | null>;
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export class UsersController {
|
||||
user.organizationId,
|
||||
user.role,
|
||||
id,
|
||||
dto.dataVisibility,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<OrgUserDto> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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(() => {
|
||||
<section class="card">
|
||||
<h3>سطح دسترسی به پروندهها</h3>
|
||||
<p class="hint">
|
||||
مالک و مدیر همیشه همه پروندهها را میبینند. برای بقیه: فقط خود، تیمهای عضو، یا کل سازمان.
|
||||
مالک و مدیر همیشه همه پروندهها را میبینند. برای بقیه یک سطح پیشفرض (فقط خود، تیمهای عضو،
|
||||
یا کل سازمان) تعیین کنید و در صورت نیاز با «تنظیم تفکیکی» برای هر بخش سطح جداگانه بگذارید؛
|
||||
مثلاً همه مشتریان را ببیند ولی معاملات فقط مال خودش.
|
||||
</p>
|
||||
<UiEmptyState
|
||||
v-if="!editableUsers.length"
|
||||
@@ -298,22 +309,59 @@ onMounted(() => {
|
||||
description="فقط نقشهای فروش و پشتیبانی سطح دسترسی جدا به پروندهها دارند."
|
||||
/>
|
||||
<div v-else class="vis-table">
|
||||
<div v-for="u in editableUsers" :key="u.id" class="vis-row">
|
||||
<span>{{ u.fullName }}</span>
|
||||
<select
|
||||
:value="u.dataVisibility ?? 'own'"
|
||||
:disabled="!canEdit"
|
||||
@change="
|
||||
setVisibility(
|
||||
u.id,
|
||||
($event.target as HTMLSelectElement).value as DataVisibility,
|
||||
)
|
||||
"
|
||||
>
|
||||
<option v-for="v in DATA_VISIBILITIES" :key="v" :value="v">
|
||||
{{ DATA_VISIBILITY_LABELS[v] }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-for="u in editableUsers" :key="u.id" class="vis-user">
|
||||
<div class="vis-row">
|
||||
<span>
|
||||
{{ u.fullName }}
|
||||
<small v-if="scopeOverrides(u)" class="vis-badge">
|
||||
{{ toFaDigits(scopeOverrides(u)) }} بخش تفکیکی
|
||||
</small>
|
||||
</span>
|
||||
<select
|
||||
:value="u.dataVisibility ?? 'own'"
|
||||
:disabled="!canEdit"
|
||||
title="سطح پیشفرض"
|
||||
@change="
|
||||
setVisibility(u.id, {
|
||||
dataVisibility: ($event.target as HTMLSelectElement).value as DataVisibility,
|
||||
})
|
||||
"
|
||||
>
|
||||
<option v-for="v in DATA_VISIBILITIES" :key="v" :value="v">
|
||||
پیشفرض: {{ DATA_VISIBILITY_LABELS[v] }}
|
||||
</option>
|
||||
</select>
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
@click="expandedVisibility = expandedVisibility === u.id ? null : u.id"
|
||||
>
|
||||
{{ expandedVisibility === u.id ? 'بستن' : 'تنظیم تفکیکی' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
<div v-if="expandedVisibility === u.id" class="vis-scopes">
|
||||
<label v-for="s in DATA_SCOPES" :key="s" class="vis-scope">
|
||||
<span>{{ DATA_SCOPE_LABELS[s] }}</span>
|
||||
<select
|
||||
:value="u.dataVisibilityScopes?.[s] ?? ''"
|
||||
:disabled="!canEdit"
|
||||
:class="{ custom: Boolean(u.dataVisibilityScopes?.[s]) }"
|
||||
@change="
|
||||
setVisibility(u.id, {
|
||||
scopes: {
|
||||
[s]: (($event.target as HTMLSelectElement).value || null) as DataVisibility | null,
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
<option value="">
|
||||
مثل پیشفرض ({{ DATA_VISIBILITY_LABELS[u.dataVisibility ?? 'own'] }})
|
||||
</option>
|
||||
<option v-for="v in DATA_VISIBILITIES" :key="v" :value="v">
|
||||
{{ DATA_VISIBILITY_LABELS[v] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string | null | undefined>;
|
||||
};
|
||||
|
||||
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)) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import { useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -181,9 +182,9 @@ const canPostInActive = computed(() => {
|
||||
});
|
||||
|
||||
const filteredPeers = computed(() => {
|
||||
const q = peerPickerQuery.value.trim().toLowerCase();
|
||||
const q = peerPickerQuery.value.trim();
|
||||
if (!q) return peers.value;
|
||||
return peers.value.filter((p) => p.fullName.toLowerCase().includes(q));
|
||||
return peers.value.filter((p) => matchesSearch(q, [p.fullName]));
|
||||
});
|
||||
|
||||
const selectedGroupPeers = computed(() =>
|
||||
@@ -251,34 +252,32 @@ const sortedRooms = computed(() =>
|
||||
);
|
||||
|
||||
const filteredRooms = computed(() => {
|
||||
const q = roomListQuery.value.trim().toLowerCase();
|
||||
const q = roomListQuery.value.trim();
|
||||
return sortedRooms.value.filter((room) => {
|
||||
if (roomListTab.value === 'dm' && room.kind !== 'dm') return false;
|
||||
if (roomListTab.value === 'group' && room.kind !== 'group') return false;
|
||||
if (roomListTab.value === 'channel' && room.kind !== 'channel') return false;
|
||||
if (!q) return true;
|
||||
const title = roomTitle(room).toLowerCase();
|
||||
const preview = (room.lastMessagePreview || '').toLowerCase();
|
||||
return title.includes(q) || preview.includes(q);
|
||||
return matchesSearch(q, [roomTitle(room), room.lastMessagePreview]);
|
||||
});
|
||||
});
|
||||
|
||||
const mentionCandidates = computed(() => {
|
||||
const state = mentionState.value;
|
||||
if (!state) return [] as ConversationPeerDto[];
|
||||
const q = state.query.trim().toLowerCase();
|
||||
const q = state.query.trim();
|
||||
return peers.value
|
||||
.filter((p) => !q || p.fullName.toLowerCase().includes(q))
|
||||
.filter((p) => !q || matchesSearch(q, [p.fullName]))
|
||||
.slice(0, 8);
|
||||
});
|
||||
|
||||
const chatSearchHits = computed(() => {
|
||||
const q = chatSearchQuery.value.trim().toLowerCase();
|
||||
const q = chatSearchQuery.value.trim();
|
||||
if (!q || !chatSearchOpen.value) return [] as ChatMessage[];
|
||||
return messages.value.filter((m) => {
|
||||
if (m.deleted) return false;
|
||||
if (m.type === 'text') return (m.content || '').toLowerCase().includes(q);
|
||||
return previewLabelForMessage(m).toLowerCase().includes(q);
|
||||
if (m.type === 'text') return matchesSearch(q, [m.content]);
|
||||
return matchesSearch(q, [previewLabelForMessage(m)]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -764,9 +763,13 @@ async function searchCrmLinks() {
|
||||
} else {
|
||||
const { data } = await api.get<PipelineBoardDto>('/pipelines/board');
|
||||
const deals = (data.stages || []).flatMap((s) => s.deals || []);
|
||||
const ql = q.toLowerCase();
|
||||
crmLinkResults.value = deals
|
||||
.filter((d) => !ql || (d.title || '').toLowerCase().includes(ql) || d.id.includes(q))
|
||||
.filter(
|
||||
(d) =>
|
||||
!q.trim() ||
|
||||
d.id.includes(q) ||
|
||||
matchesSearch(q, [d.title, d.person?.fullName, d.company?.name]),
|
||||
)
|
||||
.slice(0, 20)
|
||||
.map((d) => ({ id: d.id, title: d.title || 'معامله' }));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -219,13 +220,10 @@ const currencySelectOptions = computed(() => {
|
||||
});
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
const q = productSearch.value.trim().toLowerCase();
|
||||
const q = productSearch.value.trim();
|
||||
if (!q) return products.value;
|
||||
return products.value.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
(p.sku && p.sku.toLowerCase().includes(q)) ||
|
||||
(p.description && p.description.toLowerCase().includes(q)),
|
||||
return products.value.filter((p) =>
|
||||
matchesSearch(q, [p.name, p.sku, p.description, p.category]),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
@@ -204,13 +205,9 @@ const filtered = computed(() => {
|
||||
if (Number.isFinite(amax) && amountMax.value !== '') {
|
||||
list = list.filter((r) => Math.abs(r.amount) <= amax);
|
||||
}
|
||||
const query = q.value.trim().toLowerCase();
|
||||
if (query) {
|
||||
if (q.value.trim()) {
|
||||
list = list.filter((r) =>
|
||||
[r.number, r.customerName, r.personName, r.statusLabel, String(r.amount)]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
matchesSearch(q.value, [r.number, r.customerName, r.personName, r.statusLabel, String(r.amount)]),
|
||||
);
|
||||
}
|
||||
return list;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -79,7 +80,7 @@ const visibleItems = computed(() => {
|
||||
if (statusFilter.value === 'active' && !row.isActive) return false;
|
||||
if (statusFilter.value === 'inactive' && row.isActive) return false;
|
||||
if (!q) return true;
|
||||
return `${row.name} ${row.description ?? ''} ${row.body}`.includes(q);
|
||||
return matchesSearch(q, [row.name, row.description, row.body]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
||||
import { useRouter } from 'vue-router';
|
||||
@@ -59,15 +60,8 @@ const categories = computed(() => {
|
||||
|
||||
const filtered = computed(() => {
|
||||
let list = [...products.value];
|
||||
const query = q.value.trim().toLowerCase();
|
||||
if (query) {
|
||||
list = list.filter((p) => {
|
||||
const hay = [p.name, p.sku, p.description, p.category]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return hay.includes(query);
|
||||
});
|
||||
if (q.value.trim()) {
|
||||
list = list.filter((p) => matchesSearch(q.value, [p.name, p.sku, p.description, p.category]));
|
||||
}
|
||||
if (category.value) {
|
||||
list = list.filter((p) => p.category === category.value);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
@@ -93,9 +94,8 @@ const filteredGoals = computed(() => {
|
||||
if (statusFilter.value !== 'all') {
|
||||
list = list.filter((g) => g.status === statusFilter.value);
|
||||
}
|
||||
const query = q.value.trim().toLowerCase();
|
||||
if (query) {
|
||||
list = list.filter((g) => g.title.toLowerCase().includes(query));
|
||||
if (q.value.trim()) {
|
||||
list = list.filter((g) => matchesSearch(q.value, [g.title]));
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
@@ -123,13 +124,7 @@ const filteredUsers = computed(() => {
|
||||
const q = query.value.trim();
|
||||
let list = users.value;
|
||||
if (q) {
|
||||
const needle = q.toLowerCase();
|
||||
list = list.filter(
|
||||
(u) =>
|
||||
u.fullName.toLowerCase().includes(needle) ||
|
||||
(u.mobile ?? '').includes(needle) ||
|
||||
(u.orgRoleName ?? '').includes(q),
|
||||
);
|
||||
list = list.filter((u) => matchesSearch(q, [u.fullName, u.mobile, u.email, u.orgRoleName]));
|
||||
}
|
||||
return [...list].sort((a, b) => {
|
||||
const at = a.lastLoginAt ? new Date(a.lastLoginAt).getTime() : 0;
|
||||
|
||||
@@ -317,6 +317,8 @@ export interface OrgUserDto {
|
||||
commissionPercent?: number;
|
||||
/** own = فقط خود | team = همتیمیها | org = کل سازمان */
|
||||
dataVisibility?: DataVisibility;
|
||||
/** سطح دسترسی تفکیکی هر بخش (فقط بخشهایی که با پیشفرض فرق دارند) */
|
||||
dataVisibilityScopes?: DataVisibilityScopes;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -330,8 +332,46 @@ export const DATA_VISIBILITY_LABELS: Record<DataVisibility, string> = {
|
||||
org: "کل سازمان",
|
||||
};
|
||||
|
||||
/** بخشهایی که سطح دسترسی به پروندهها برایشان جداگانه تعیین میشود */
|
||||
export type DataScope =
|
||||
| "contacts"
|
||||
| "deals"
|
||||
| "activities"
|
||||
| "leads"
|
||||
| "finance"
|
||||
| "tickets"
|
||||
| "notes"
|
||||
| "projects";
|
||||
|
||||
export const DATA_SCOPES: DataScope[] = [
|
||||
"contacts",
|
||||
"deals",
|
||||
"activities",
|
||||
"leads",
|
||||
"finance",
|
||||
"tickets",
|
||||
"notes",
|
||||
"projects",
|
||||
];
|
||||
|
||||
export const DATA_SCOPE_LABELS: Record<DataScope, string> = {
|
||||
contacts: "مشتریان (اشخاص و شرکتها)",
|
||||
deals: "معاملات و اهداف فروش",
|
||||
activities: "فعالیتها و کارها",
|
||||
leads: "سرنخها",
|
||||
finance: "فاکتور، پیشفاکتور و چک",
|
||||
tickets: "تیکتها",
|
||||
notes: "یادداشتها",
|
||||
projects: "پروژهها",
|
||||
};
|
||||
|
||||
export type DataVisibilityScopes = Partial<Record<DataScope, DataVisibility>>;
|
||||
|
||||
export interface UpdateUserVisibilityRequest {
|
||||
dataVisibility: DataVisibility;
|
||||
/** سطح پیشفرض برای بخشهایی که جداگانه تنظیم نشدهاند */
|
||||
dataVisibility?: DataVisibility;
|
||||
/** سطح هر بخش؛ مقدار null یعنی «مثل پیشفرض» */
|
||||
scopes?: Partial<Record<DataScope, DataVisibility | null>>;
|
||||
}
|
||||
|
||||
export type RolePermissionKey =
|
||||
|
||||
Reference in New Issue
Block a user