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
@@ -0,0 +1,84 @@
-- AlterTable
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "ProjectCard" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- CreateTable
CREATE TABLE IF NOT EXISTS "AuditLog" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"actorUserId" TEXT,
"action" TEXT NOT NULL,
"method" TEXT NOT NULL,
"path" TEXT NOT NULL,
"entityType" TEXT,
"entityId" TEXT,
"summary" TEXT NOT NULL,
"meta" JSONB,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "SavedView" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"ownerUserId" TEXT,
"name" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"filters" JSONB NOT NULL DEFAULT '{}',
"isShared" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SavedView_pkey" PRIMARY KEY ("id")
);
-- CreateTable (implicit many-to-many ProjectCard <-> User assignees)
CREATE TABLE IF NOT EXISTS "_ProjectCardAssignees" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_createdAt_idx" ON "AuditLog"("organizationId", "createdAt");
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_actorUserId_createdAt_idx" ON "AuditLog"("organizationId", "actorUserId", "createdAt");
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_entityType_createdAt_idx" ON "AuditLog"("organizationId", "entityType", "createdAt");
CREATE INDEX IF NOT EXISTS "SavedView_organizationId_scope_idx" ON "SavedView"("organizationId", "scope");
CREATE INDEX IF NOT EXISTS "SavedView_organizationId_ownerUserId_idx" ON "SavedView"("organizationId", "ownerUserId");
CREATE UNIQUE INDEX IF NOT EXISTS "_ProjectCardAssignees_AB_unique" ON "_ProjectCardAssignees"("A", "B");
CREATE INDEX IF NOT EXISTS "_ProjectCardAssignees_B_index" ON "_ProjectCardAssignees"("B");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "SavedView" ADD CONSTRAINT "SavedView_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "SavedView" ADD CONSTRAINT "SavedView_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "_ProjectCardAssignees" ADD CONSTRAINT "_ProjectCardAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "ProjectCard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "_ProjectCardAssignees" ADD CONSTRAINT "_ProjectCardAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+51
View File
@@ -192,6 +192,8 @@ model Organization {
partyOpenings PartyOpeningBalance[]
commissionEntries CommissionEntry[]
cheques Cheque[]
auditLogs AuditLog[]
savedViews SavedView[]
}
model User {
@@ -233,6 +235,9 @@ model User {
confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy")
memberships Membership[]
billingAccount BillingAccount?
auditLogs AuditLog[] @relation("AuditLogActor")
savedViews SavedView[] @relation("SavedViewOwner")
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -329,6 +334,7 @@ model Company {
website String?
address String?
notes String?
tags String[] @default([])
customFields Json @default("{}")
people Person[]
deals Deal[]
@@ -368,6 +374,7 @@ model Person {
referrer String?
notes String?
mood PersonMood @default(newcomer)
tags String[] @default([])
customFields Json @default("{}")
loyaltyPoints Int @default(0)
loyaltyTierId String?
@@ -493,6 +500,7 @@ model Activity {
/// پیامک به موبایل مسئول هنگام رسیدن یادآور
reminderNotifySms Boolean @default(false)
completedAt DateTime?
tags String[] @default([])
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
companyId String?
@@ -1067,6 +1075,7 @@ model ProjectCard {
stage ProjectBoardStage @relation(fields: [stageId], references: [id], onDelete: Restrict)
title String
notes String?
tags String[] @default([])
position Int @default(0)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
@@ -1076,6 +1085,7 @@ model ProjectCard {
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
ownerId String?
owner User? @relation("ProjectCardOwner", fields: [ownerId], references: [id], onDelete: SetNull)
assignees User[] @relation("ProjectCardAssignees")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1546,3 +1556,44 @@ model PartyOpeningBalance {
@@unique([organizationId, personId])
@@index([personId])
}
/// لاگ فعالیت کاربران روی منابع سازمان
model AuditLog {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
actorUserId String?
actor User? @relation("AuditLogActor", fields: [actorUserId], references: [id], onDelete: SetNull)
action String
method String
path String
entityType String?
entityId String?
summary String
meta Json?
ip String?
createdAt DateTime @default(now())
@@index([organizationId, createdAt])
@@index([organizationId, actorUserId, createdAt])
@@index([organizationId, entityType, createdAt])
}
/// فیلترهای ذخیره‌شده برای لیست‌ها و کاریزها
model SavedView {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
ownerUserId String?
owner User? @relation("SavedViewOwner", fields: [ownerUserId], references: [id], onDelete: SetNull)
name String
/// deals | people | companies | activities | project_cards
scope String
filters Json @default("{}")
isShared Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, scope])
@@index([organizationId, ownerUserId])
}
@@ -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(),
};
}
}