Unified search algorithm across all lists; per-section record visibility (contacts, deals, activities, leads, finance, tickets, notes, projects)

This commit is contained in:
2026-10-06 15:04:46 +03:30
parent 43717392b2
commit e08f1452ef
38 changed files with 699 additions and 420 deletions
+19 -18
View File
@@ -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,
+4 -4
View File
@@ -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 }),
+34 -22
View File
@@ -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,
+59 -25
View File
@@ -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 } }],
+85
View File
@@ -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 };
}
+14 -13
View File
@@ -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([
+14 -16
View File
@@ -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 -8
View File
@@ -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' }],
+28 -38
View File
@@ -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 } } }],
};
}
+22 -14
View File
@@ -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,
+16 -13
View File
@@ -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 =
+11 -12
View File
@@ -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] } : {}),
};
}
+6 -9
View File
@@ -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,
+11 -19
View File
@@ -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 -20
View File
@@ -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 }),
+20 -12
View File
@@ -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,
+6 -6
View File
@@ -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);
+9 -6
View File
@@ -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 };
+13 -56
View File
@@ -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,
+8 -1
View File
@@ -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>;
}
+1 -1
View File
@@ -107,7 +107,7 @@ export class UsersController {
user.organizationId,
user.role,
id,
dto.dataVisibility,
dto,
);
}
+40 -4
View File
@@ -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(),
};
}