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:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user