Add Didar gap features: lost-reason reports, audit logs, tags, card assignees, and saved-views API.

Ignore Graphify local artifacts and expose new modules in superadmin.
This commit is contained in:
2026-09-26 14:59:48 +03:30
parent 35a618d0af
commit 6463275c3d
30 changed files with 1406 additions and 21 deletions
@@ -17,6 +17,7 @@ import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { type Actor, assigneeWhere, ownerWhere } from '../common/record-scope';
import { normalizeTags } from '../common/tags';
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
@@ -33,6 +34,7 @@ type ActivityRow = {
reminderNotifyApp: boolean;
reminderNotifySms: boolean;
completedAt: Date | null;
tags: string[];
createdAt: Date;
updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null;
@@ -191,6 +193,7 @@ export class ActivitiesService {
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null,
reminderNotifyApp: dto.reminderNotifyApp ?? true,
reminderNotifySms: dto.reminderNotifySms ?? false,
tags: normalizeTags(dto.tags),
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null),
@@ -247,6 +250,7 @@ export class ActivitiesService {
if ('reminderAt' in dto) data.reminderAt = dto.reminderAt ? new Date(dto.reminderAt) : null;
if (dto.reminderNotifyApp !== undefined) data.reminderNotifyApp = dto.reminderNotifyApp;
if (dto.reminderNotifySms !== undefined) data.reminderNotifySms = dto.reminderNotifySms;
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
@@ -477,6 +481,7 @@ export class ActivitiesService {
reminderNotifyApp: row.reminderNotifyApp,
reminderNotifySms: row.reminderNotifySms,
completedAt: row.completedAt?.toISOString() ?? null,
tags: row.tags ?? [],
person: row.person
? {
id: row.person.id,
+11
View File
@@ -1,5 +1,6 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsDateString,
IsIn,
@@ -107,6 +108,11 @@ export class CreateActivityDto {
@IsBoolean()
reminderNotifySms?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -177,6 +183,11 @@ export class UpdateActivityDto {
@IsBoolean()
reminderNotifySms?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
+4
View File
@@ -34,6 +34,8 @@ import { InventoryModule } from './inventory/inventory.module';
import { FinanceModule } from './finance/finance.module';
import { PlatformModule } from './platform/platform.module';
import { BillingModule } from './billing/billing.module';
import { AuditLogsModule } from './audit-logs/audit-logs.module';
import { SavedViewsModule } from './saved-views/saved-views.module';
@Module({
imports: [
@@ -75,6 +77,8 @@ import { BillingModule } from './billing/billing.module';
MessageAutomationsModule,
MemoryModule,
TahesabModule,
AuditLogsModule,
SavedViewsModule,
],
})
export class AppModule {}
@@ -0,0 +1,59 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { AuditLogsService } from './audit-logs.service';
class ListAuditQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsString()
actorUserId?: string;
@IsOptional()
@IsString()
entityType?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
@Controller('audit-logs')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('audit_logs')
@Roles('owner', 'admin')
export class AuditLogsController {
constructor(private readonly auditLogs: AuditLogsService) {}
@Get()
list(
@CurrentUser() user: { organizationId: string },
@Query() query: ListAuditQueryDto,
) {
return this.auditLogs.list(user.organizationId, {
q: query.q,
actorUserId: query.actorUserId,
entityType: query.entityType,
page: query.page,
limit: query.limit,
});
}
}
@@ -0,0 +1,100 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import type { Request } from 'express';
import { Observable, tap } from 'rxjs';
import { AuditLogsService } from './audit-logs.service';
const SKIP_PREFIXES = [
'/api/health',
'/api/auth/login',
'/api/auth/register',
'/api/auth/otp',
'/api/auth/refresh',
'/api/portal',
'/api/public',
'/api/platform',
'/api/audit-logs',
];
type AuthedRequest = Request & {
user?: {
userId?: string;
id?: string;
organizationId?: string;
};
};
function inferEntity(path: string): { entityType: string | null; entityId: string | null } {
const clean = path.replace(/^\/api\/?/, '').split('?')[0] ?? '';
const parts = clean.split('/').filter(Boolean);
if (!parts.length) return { entityType: null, entityId: null };
const entityType = parts[0] ?? null;
const maybeId = parts[1];
const entityId =
maybeId && !['new', 'me', 'summary', 'dashboard', 'board'].includes(maybeId)
? maybeId
: null;
return { entityType, entityId };
}
function actionLabel(method: string, path: string): string {
const m = method.toUpperCase();
if (m === 'POST') return 'ایجاد';
if (m === 'PATCH' || m === 'PUT') return 'ویرایش';
if (m === 'DELETE') return 'حذف';
return m;
void path;
}
@Injectable()
export class AuditLogsInterceptor implements NestInterceptor {
constructor(private readonly audit: AuditLogsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const req = http.getRequest<AuthedRequest>();
const method = (req.method ?? 'GET').toUpperCase();
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
return next.handle();
}
const path = req.originalUrl || req.url || '';
if (SKIP_PREFIXES.some((p) => path.startsWith(p))) {
return next.handle();
}
const organizationId = req.user?.organizationId;
if (!organizationId) return next.handle();
const actorUserId = req.user?.userId ?? req.user?.id ?? null;
const { entityType, entityId } = inferEntity(path);
const action = actionLabel(method, path);
const summary = `${action} ${entityType ?? 'منبع'} از مسیر ${path.split('?')[0]}`;
const ip =
(req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ||
req.ip ||
null;
return next.handle().pipe(
tap({
next: () => {
void this.audit.write({
organizationId,
actorUserId,
action,
method,
path: path.split('?')[0] ?? path,
entityType,
entityId,
summary,
ip,
});
},
}),
);
}
}
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { AuditLogsController } from './audit-logs.controller';
import { AuditLogsInterceptor } from './audit-logs.interceptor';
import { AuditLogsService } from './audit-logs.service';
@Module({
imports: [AuthModule, BillingModule],
controllers: [AuditLogsController],
providers: [
AuditLogsService,
{
provide: APP_INTERCEPTOR,
useClass: AuditLogsInterceptor,
},
],
exports: [AuditLogsService],
})
export class AuditLogsModule {}
@@ -0,0 +1,118 @@
import { Injectable } from '@nestjs/common';
import type { AuditLogDto, AuditLogListResponse } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export type AuditWriteInput = {
organizationId: string;
actorUserId?: string | null;
action: string;
method: string;
path: string;
entityType?: string | null;
entityId?: string | null;
summary: string;
meta?: Record<string, unknown> | null;
ip?: string | null;
};
@Injectable()
export class AuditLogsService {
constructor(private readonly prisma: PrismaService) {}
async write(input: AuditWriteInput): Promise<void> {
try {
await this.prisma.auditLog.create({
data: {
organizationId: input.organizationId,
actorUserId: input.actorUserId ?? null,
action: input.action,
method: input.method.slice(0, 16),
path: input.path.slice(0, 400),
entityType: input.entityType ?? null,
entityId: input.entityId ?? null,
summary: input.summary.slice(0, 500),
meta:
input.meta == null
? undefined
: (input.meta as Prisma.InputJsonValue),
ip: input.ip ?? null,
},
});
} catch {
// never break the main request because of audit write failures
}
}
async list(
organizationId: string,
opts: {
q?: string;
actorUserId?: string;
entityType?: string;
page?: number;
limit?: number;
},
): Promise<AuditLogListResponse> {
const page = Math.max(1, opts.page ?? 1);
const limit = Math.min(100, Math.max(1, opts.limit ?? 30));
const where: Prisma.AuditLogWhereInput = {
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 [total, rows] = await Promise.all([
this.prisma.auditLog.count({ where }),
this.prisma.auditLog.findMany({
where,
include: { actor: { select: { id: true, fullName: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
]);
return {
total,
page,
limit,
items: rows.map((row) => this.toDto(row)),
};
}
private toDto(row: {
id: string;
action: string;
method: string;
path: string;
entityType: string | null;
entityId: string | null;
summary: string;
ip: string | null;
createdAt: Date;
actor: { id: string; fullName: string } | null;
}): AuditLogDto {
return {
id: row.id,
action: row.action,
method: row.method,
path: row.path,
entityType: row.entityType,
entityId: row.entityId,
summary: row.summary,
actor: row.actor,
ip: row.ip,
createdAt: row.createdAt.toISOString(),
};
}
}
+10
View File
@@ -0,0 +1,10 @@
/** Normalize free-form tag lists (max 40 unique non-empty tags). */
export function normalizeTags(input?: string[] | null): string[] {
if (!input?.length) return [];
const cleaned = input
.map((t) => String(t ?? '').trim())
.filter(Boolean)
.map((t) => (t.startsWith('#') ? t.slice(1).trim() : t))
.filter(Boolean);
return [...new Set(cleaned)].slice(0, 40);
}
@@ -23,6 +23,7 @@ import {
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
import { normalizeTags } from '../common/tags';
const companyInclude = {
_count: { select: { people: true } },
@@ -38,6 +39,7 @@ type CompanyRow = {
website: string | null;
address: string | null;
notes: string | null;
tags: string[];
customFields: unknown;
createdAt: Date;
updatedAt: Date;
@@ -108,6 +110,7 @@ export class CompaniesService {
website: emptyToNull(dto.website),
address: emptyToNull(dto.address),
notes: emptyToNull(dto.notes),
tags: normalizeTags(dto.tags),
ownerId: resolveOwnerId(actor, dto.ownerId),
customFields: toJsonCustomFields(dto.customFields),
};
@@ -134,6 +137,7 @@ export class CompaniesService {
if ('website' in dto) data.website = emptyToNull(dto.website);
if ('address' in dto) data.address = emptyToNull(dto.address);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
@@ -228,6 +232,7 @@ export class CompaniesService {
website: row.website,
address: row.address,
notes: row.notes,
tags: row.tags ?? [],
customFields: asCustomFields(row.customFields),
peopleCount: row._count.people,
owner: row.owner,
+21
View File
@@ -1,5 +1,6 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsEmail,
IsIn,
IsInt,
@@ -77,6 +78,11 @@ export class CreateCompanyDto {
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@@ -124,6 +130,11 @@ export class UpdateCompanyDto {
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@@ -184,6 +195,11 @@ export class CreatePersonDto {
@IsIn([...PERSON_MOODS])
mood?: (typeof PERSON_MOODS)[number];
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -249,6 +265,11 @@ export class UpdatePersonDto {
@IsIn([...PERSON_MOODS])
mood?: (typeof PERSON_MOODS)[number];
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
+5
View File
@@ -27,6 +27,7 @@ import {
import { LoyaltyService } from '../loyalty/loyalty.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { normalizeTags } from '../common/tags';
const personInclude = {
company: { select: { id: true, name: true } },
@@ -48,6 +49,7 @@ type PersonRow = {
referrer: string | null;
notes: string | null;
mood: PersonMood;
tags: string[];
customFields: unknown;
loyaltyPoints: number;
createdAt: Date;
@@ -146,6 +148,7 @@ export class PeopleService {
referrer: emptyToNull(dto.referrer),
notes: emptyToNull(dto.notes),
mood: dto.mood ?? 'newcomer',
tags: normalizeTags(dto.tags),
companyId,
ownerId: resolveOwnerId(actor, dto.ownerId),
customFields: toJsonCustomFields(dto.customFields),
@@ -193,6 +196,7 @@ export class PeopleService {
if ('referrer' in dto) data.referrer = emptyToNull(dto.referrer);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
if (dto.mood !== undefined) data.mood = dto.mood;
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
@@ -278,6 +282,7 @@ export class PeopleService {
referrer: row.referrer,
notes: row.notes,
mood: row.mood,
tags: row.tags ?? [],
customFields: asCustomFields(row.customFields),
loyaltyPoints: row.loyaltyPoints ?? 0,
loyaltyTier: row.loyaltyTier,
+20
View File
@@ -57,6 +57,11 @@ export class CreateProjectCardDto {
@MaxLength(4000)
notes?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -76,6 +81,11 @@ export class CreateProjectCardDto {
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
}
export class UpdateProjectCardDto {
@@ -90,6 +100,11 @@ export class UpdateProjectCardDto {
@MaxLength(4000)
notes?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -109,6 +124,11 @@ export class UpdateProjectCardDto {
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
}
export class MoveProjectCardDto {
@@ -19,6 +19,7 @@ import {
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
import { normalizeTags } from '../common/tags';
const DEFAULT_STAGES = ['صف', 'در حال انجام', 'بازبینی', 'انجام‌شده'] as const;
@@ -27,6 +28,7 @@ const cardInclude = {
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
owner: { select: { id: true, fullName: true } },
assignees: { select: { id: true, fullName: true } },
} satisfies Prisma.ProjectCardInclude;
type CardActor = Pick<Actor, 'userId' | 'role'>;
@@ -192,6 +194,7 @@ export class ProjectBoardsService {
_max: { position: true },
});
const assigneeIds = [...new Set((dto.assigneeIds ?? []).map((id) => id.trim()).filter(Boolean))];
const created = await this.prisma.projectCard.create({
data: {
organizationId,
@@ -199,11 +202,15 @@ export class ProjectBoardsService {
stageId: stage.id,
title: dto.title.trim(),
notes: emptyToNull(dto.notes),
tags: normalizeTags(dto.tags),
personId: emptyToNull(dto.personId),
companyId: emptyToNull(dto.companyId),
dealId: emptyToNull(dto.dealId),
ownerId: resolveOwnerId(actor, dto.ownerId),
position: (maxPos._max.position ?? -1) + 1,
...(assigneeIds.length
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
: {}),
},
include: cardInclude,
});
@@ -234,6 +241,7 @@ export class ProjectBoardsService {
const data: Prisma.ProjectCardUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
@@ -246,7 +254,18 @@ export class ProjectBoardsService {
const row = await this.prisma.projectCard.update({
where: { id },
data,
data: {
...data,
...(dto.assigneeIds
? {
assignees: {
set: [...new Set(dto.assigneeIds.map((x) => x.trim()).filter(Boolean))].map(
(uid) => ({ id: uid }),
),
},
}
: {}),
},
include: cardInclude,
});
return this.toCardDto(row);
@@ -411,6 +430,7 @@ export class ProjectBoardsService {
id: string;
title: string;
notes: string | null;
tags?: string[];
position: number;
boardId: string;
stageId: string;
@@ -420,11 +440,13 @@ export class ProjectBoardsService {
company: { id: string; name: string } | null;
deal: { id: string; title: string } | null;
owner: { id: string; fullName: string } | null;
assignees?: Array<{ id: string; fullName: string }>;
}): ProjectCardDto {
return {
id: row.id,
title: row.title,
notes: row.notes,
tags: row.tags ?? [],
position: row.position,
boardId: row.boardId,
stageId: row.stageId,
@@ -437,6 +459,7 @@ export class ProjectBoardsService {
company: row.company,
deal: row.deal,
owner: row.owner,
assignees: row.assignees ?? [],
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
+34
View File
@@ -76,6 +76,7 @@ export class ReportsService {
stageId: true,
ownerId: true,
source: true,
lostReason: true,
updatedAt: true,
createdAt: true,
lineItems: {
@@ -402,6 +403,27 @@ export class ReportsService {
}))
.sort((a, b) => b.count - a.count);
const lostReasonMap = new Map<string, { count: number; amount: number }>();
for (const d of lostInPeriod) {
const label = normalizeLostReason(d.lostReason);
const prev = lostReasonMap.get(label) ?? { count: 0, amount: 0 };
prev.count += 1;
prev.amount += decimalToBase(d.amount, d.currency, rates);
lostReasonMap.set(label, prev);
}
const lostReasonTotal =
[...lostReasonMap.values()].reduce((a, b) => a + b.count, 0) || 1;
const lostReasons: ReportsDashboardDto['lostReasons'] = [
...lostReasonMap.entries(),
]
.map(([label, row]) => ({
label,
count: row.count,
amount: Math.round(row.amount),
percent: Math.round((row.count / lostReasonTotal) * 1000) / 10,
}))
.sort((a, b) => b.count - a.count);
type LineAgg = {
name: string;
category: string;
@@ -636,6 +658,11 @@ export class ReportsService {
`بیشترین منبع معاملات برنده «${sources[0].label}» بوده است.`,
);
}
if (lostReasons[0] && lostReasons[0].count > 0) {
insights.push(
`بیشترین دلیل شکست معاملات «${lostReasons[0].label}» بوده است (${lostReasons[0].count} مورد).`,
);
}
}
return {
@@ -647,6 +674,7 @@ export class ReportsService {
monthlyComparison,
customerGrowth,
sources,
lostReasons,
salesByCategory,
topProducts,
customerStatus,
@@ -1052,6 +1080,12 @@ function normalizeSource(raw: string | null | undefined): string {
return map[value.toLowerCase()] ?? value;
}
function normalizeLostReason(raw: string | null | undefined): string {
const value = raw?.trim();
if (!value) return 'بدون دلیل ثبت‌شده';
return value;
}
function activityTypeLabel(type: string): string {
const map: Record<string, string> = {
call: 'تماس',
@@ -0,0 +1,124 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
IsBoolean,
IsIn,
IsObject,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { SavedViewsService } from './saved-views.service';
const SCOPES = [
'deals',
'people',
'companies',
'activities',
'project_cards',
] as const;
class CreateSavedViewDto {
@IsString()
@MaxLength(120)
name!: string;
@IsIn(SCOPES)
scope!: (typeof SCOPES)[number];
@IsObject()
filters!: Record<string, unknown>;
@IsOptional()
@IsBoolean()
isShared?: boolean;
}
class UpdateSavedViewDto {
@IsOptional()
@IsString()
@MaxLength(120)
name?: string;
@IsOptional()
@IsIn(SCOPES)
scope?: (typeof SCOPES)[number];
@IsOptional()
@IsObject()
filters?: Record<string, unknown>;
@IsOptional()
@IsBoolean()
isShared?: boolean;
}
@Controller('saved-views')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('saved_views')
export class SavedViewsController {
constructor(private readonly savedViews: SavedViewsService) {}
@Get()
list(
@CurrentUser() user: { organizationId: string; userId: string },
@Query('scope') scope?: string,
) {
return this.savedViews.list(user.organizationId, user.userId, scope);
}
@Post()
create(
@CurrentUser() user: { organizationId: string; userId: string },
@Body() dto: CreateSavedViewDto,
) {
return this.savedViews.create(user.organizationId, user.userId, dto);
}
@Patch(':id')
update(
@CurrentUser()
user: { organizationId: string; userId: string; role: string },
@Param('id') id: string,
@Body() dto: UpdateSavedViewDto,
) {
const isAdmin = user.role === 'owner' || user.role === 'admin';
return this.savedViews.update(
user.organizationId,
user.userId,
id,
dto,
isAdmin,
);
}
@Delete(':id')
async remove(
@CurrentUser()
user: { organizationId: string; userId: string; role: string },
@Param('id') id: string,
) {
const isAdmin = user.role === 'owner' || user.role === 'admin';
await this.savedViews.remove(
user.organizationId,
user.userId,
id,
isAdmin,
);
return { ok: true };
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { SavedViewsController } from './saved-views.controller';
import { SavedViewsService } from './saved-views.service';
@Module({
imports: [AuthModule, BillingModule],
controllers: [SavedViewsController],
providers: [SavedViewsService],
exports: [SavedViewsService],
})
export class SavedViewsModule {}
@@ -0,0 +1,148 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateSavedViewRequest,
SavedViewDto,
SavedViewScope,
UpdateSavedViewRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const SCOPES: SavedViewScope[] = [
'deals',
'people',
'companies',
'activities',
'project_cards',
];
@Injectable()
export class SavedViewsService {
constructor(private readonly prisma: PrismaService) {}
async list(
organizationId: string,
userId: string,
scope?: string,
): Promise<SavedViewDto[]> {
const rows = await this.prisma.savedView.findMany({
where: {
organizationId,
...(scope ? { scope } : {}),
OR: [{ isShared: true }, { ownerUserId: userId }],
},
include: { owner: { select: { id: true, fullName: true } } },
orderBy: [{ updatedAt: 'desc' }],
});
return rows.map((row) => this.toDto(row));
}
async create(
organizationId: string,
userId: string,
dto: CreateSavedViewRequest,
): Promise<SavedViewDto> {
const scope = this.assertScope(dto.scope);
const name = dto.name?.trim();
if (!name) throw new BadRequestException('نام فیلتر الزامی است');
const row = await this.prisma.savedView.create({
data: {
organizationId,
ownerUserId: userId,
name,
scope,
filters: (dto.filters ?? {}) as Prisma.InputJsonValue,
isShared: Boolean(dto.isShared),
},
include: { owner: { select: { id: true, fullName: true } } },
});
return this.toDto(row);
}
async update(
organizationId: string,
userId: string,
id: string,
dto: UpdateSavedViewRequest,
isAdmin: boolean,
): Promise<SavedViewDto> {
const existing = await this.prisma.savedView.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('فیلتر پیدا نشد');
if (!isAdmin && existing.ownerUserId !== userId) {
throw new BadRequestException('فقط مالک فیلتر می‌تواند آن را ویرایش کند');
}
const data: Prisma.SavedViewUpdateInput = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('نام فیلتر الزامی است');
data.name = name;
}
if (dto.scope !== undefined) data.scope = this.assertScope(dto.scope);
if (dto.filters !== undefined) {
data.filters = dto.filters as Prisma.InputJsonValue;
}
if (dto.isShared !== undefined) data.isShared = Boolean(dto.isShared);
const row = await this.prisma.savedView.update({
where: { id },
data,
include: { owner: { select: { id: true, fullName: true } } },
});
return this.toDto(row);
}
async remove(
organizationId: string,
userId: string,
id: string,
isAdmin: boolean,
): Promise<void> {
const existing = await this.prisma.savedView.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('فیلتر پیدا نشد');
if (!isAdmin && existing.ownerUserId !== userId) {
throw new BadRequestException('فقط مالک فیلتر می‌تواند آن را حذف کند');
}
await this.prisma.savedView.delete({ where: { id } });
}
private assertScope(scope: string): SavedViewScope {
if (!SCOPES.includes(scope as SavedViewScope)) {
throw new BadRequestException('محدوده فیلتر معتبر نیست');
}
return scope as SavedViewScope;
}
private toDto(row: {
id: string;
name: string;
scope: string;
filters: unknown;
isShared: boolean;
createdAt: Date;
updatedAt: Date;
owner: { id: string; fullName: string } | null;
}): SavedViewDto {
return {
id: row.id,
name: row.name,
scope: row.scope as SavedViewScope,
filters:
row.filters && typeof row.filters === 'object'
? (row.filters as Record<string, unknown>)
: {},
isShared: row.isShared,
owner: row.owner,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}