diff --git a/.gitignore b/.gitignore index b9e54ec..39bdb51 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,8 @@ docs/~$* *.py[cod] apps/stt/.venv/ crm-node_modules.tgz + +# Graphify (local architecture graph — do not commit) +graphify-out/ +**/graphify-out/ +.cursor/rules/graphify.mdc diff --git a/apps/api/prisma/migrations/20260926140000_audit_tags_assignees_saved_views/migration.sql b/apps/api/prisma/migrations/20260926140000_audit_tags_assignees_saved_views/migration.sql new file mode 100644 index 0000000..44db1ab --- /dev/null +++ b/apps/api/prisma/migrations/20260926140000_audit_tags_assignees_saved_views/migration.sql @@ -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 $$; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 8e0f630..e7c38bb 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index dcc0f5a..c664e15 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -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; @@ -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, diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index d62839f..57b21b6 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -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() diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6111494..0270b53 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/audit-logs/audit-logs.controller.ts b/apps/api/src/audit-logs/audit-logs.controller.ts new file mode 100644 index 0000000..e236b1e --- /dev/null +++ b/apps/api/src/audit-logs/audit-logs.controller.ts @@ -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, + }); + } +} diff --git a/apps/api/src/audit-logs/audit-logs.interceptor.ts b/apps/api/src/audit-logs/audit-logs.interceptor.ts new file mode 100644 index 0000000..7a8e411 --- /dev/null +++ b/apps/api/src/audit-logs/audit-logs.interceptor.ts @@ -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 { + const http = context.switchToHttp(); + const req = http.getRequest(); + 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, + }); + }, + }), + ); + } +} diff --git a/apps/api/src/audit-logs/audit-logs.module.ts b/apps/api/src/audit-logs/audit-logs.module.ts new file mode 100644 index 0000000..80b9362 --- /dev/null +++ b/apps/api/src/audit-logs/audit-logs.module.ts @@ -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 {} diff --git a/apps/api/src/audit-logs/audit-logs.service.ts b/apps/api/src/audit-logs/audit-logs.service.ts new file mode 100644 index 0000000..b30bebf --- /dev/null +++ b/apps/api/src/audit-logs/audit-logs.service.ts @@ -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 | null; + ip?: string | null; +}; + +@Injectable() +export class AuditLogsService { + constructor(private readonly prisma: PrismaService) {} + + async write(input: AuditWriteInput): Promise { + 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 { + 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(), + }; + } +} diff --git a/apps/api/src/common/tags.ts b/apps/api/src/common/tags.ts new file mode 100644 index 0000000..03d91cc --- /dev/null +++ b/apps/api/src/common/tags.ts @@ -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); +} diff --git a/apps/api/src/contacts/companies.service.ts b/apps/api/src/contacts/companies.service.ts index bacceb6..b527e74 100644 --- a/apps/api/src/contacts/companies.service.ts +++ b/apps/api/src/contacts/companies.service.ts @@ -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, diff --git a/apps/api/src/contacts/dto.ts b/apps/api/src/contacts/dto.ts index d82e742..bbe2884 100644 --- a/apps/api/src/contacts/dto.ts +++ b/apps/api/src/contacts/dto.ts @@ -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; @@ -124,6 +130,11 @@ export class UpdateCompanyDto { @MaxLength(2000) notes?: string; + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + @IsOptional() @IsObject() customFields?: Record; @@ -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() diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index c7c3c8e..eb25ea0 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -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, diff --git a/apps/api/src/project-boards/dto.ts b/apps/api/src/project-boards/dto.ts index 4783932..129b84b 100644 --- a/apps/api/src/project-boards/dto.ts +++ b/apps/api/src/project-boards/dto.ts @@ -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 { diff --git a/apps/api/src/project-boards/project-boards.service.ts b/apps/api/src/project-boards/project-boards.service.ts index cc49849..63d54e3 100644 --- a/apps/api/src/project-boards/project-boards.service.ts +++ b/apps/api/src/project-boards/project-boards.service.ts @@ -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; @@ -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(), }; diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts index a555e72..640003a 100644 --- a/apps/api/src/reports/reports.service.ts +++ b/apps/api/src/reports/reports.service.ts @@ -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(); + 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 = { call: 'تماس', diff --git a/apps/api/src/saved-views/saved-views.controller.ts b/apps/api/src/saved-views/saved-views.controller.ts new file mode 100644 index 0000000..5e5081d --- /dev/null +++ b/apps/api/src/saved-views/saved-views.controller.ts @@ -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; + + @IsOptional() + @IsBoolean() + isShared?: boolean; +} + +class UpdateSavedViewDto { + @IsOptional() + @IsString() + @MaxLength(120) + name?: string; + + @IsOptional() + @IsIn(SCOPES) + scope?: (typeof SCOPES)[number]; + + @IsOptional() + @IsObject() + filters?: Record; + + @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 }; + } +} diff --git a/apps/api/src/saved-views/saved-views.module.ts b/apps/api/src/saved-views/saved-views.module.ts new file mode 100644 index 0000000..965eb59 --- /dev/null +++ b/apps/api/src/saved-views/saved-views.module.ts @@ -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 {} diff --git a/apps/api/src/saved-views/saved-views.service.ts b/apps/api/src/saved-views/saved-views.service.ts new file mode 100644 index 0000000..ca7c0d5 --- /dev/null +++ b/apps/api/src/saved-views/saved-views.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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) + : {}, + isShared: row.isShared, + owner: row.owner, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} diff --git a/apps/web/src/components/dashboard/HomeAiAssistant.vue b/apps/web/src/components/dashboard/HomeAiAssistant.vue index fc38d75..809fd36 100644 --- a/apps/web/src/components/dashboard/HomeAiAssistant.vue +++ b/apps/web/src/components/dashboard/HomeAiAssistant.vue @@ -1,9 +1,11 @@ + + + + diff --git a/apps/web/src/views/CompanyFormView.vue b/apps/web/src/views/CompanyFormView.vue index c1fb701..e9640c5 100644 --- a/apps/web/src/views/CompanyFormView.vue +++ b/apps/web/src/views/CompanyFormView.vue @@ -45,8 +45,20 @@ const email = ref(''); const website = ref(''); const address = ref(''); const notes = ref(''); +const tagsRaw = ref(''); const customFields = ref>({}); +function parseTags(raw: string): string[] { + return [ + ...new Set( + raw + .split(/[,،#\n]/) + .map((t) => t.trim()) + .filter(Boolean), + ), + ].slice(0, 40); +} + function fill(c: CompanyDto) { name.value = c.name; nationalId.value = c.nationalId ?? ''; @@ -55,6 +67,7 @@ function fill(c: CompanyDto) { website.value = c.website ?? ''; address.value = c.address ?? ''; notes.value = c.notes ?? ''; + tagsRaw.value = (c.tags ?? []).join('، '); customFields.value = { ...(c.customFields ?? {}) }; } @@ -81,6 +94,7 @@ function payload(): CreateCompanyRequest { website: website.value || undefined, address: address.value || undefined, notes: notes.value || undefined, + tags: parseTags(tagsRaw.value), customFields: customFields.value, }; } @@ -236,6 +250,14 @@ onMounted(load); /> + + + + ('newcomer'); const customFields = ref>({}); @@ -205,6 +206,7 @@ function fill(p: PersonDto) { website.value = p.website ?? ''; referrer.value = p.referrer ?? ''; notes.value = p.notes ?? ''; + tagsRaw.value = (p.tags ?? []).join('، '); companyId.value = p.company?.id ?? ''; mood.value = p.mood || 'newcomer'; customFields.value = { ...(p.customFields ?? {}) }; @@ -231,6 +233,17 @@ async function load() { } } +function parseTags(raw: string): string[] { + return [ + ...new Set( + raw + .split(/[,،#\n]/) + .map((t) => t.trim()) + .filter(Boolean), + ), + ].slice(0, 40); +} + function payload(): CreatePersonRequest { return { firstName: firstName.value, @@ -242,6 +255,7 @@ function payload(): CreatePersonRequest { website: website.value || undefined, referrer: referrer.value || undefined, notes: notes.value || undefined, + tags: parseTags(tagsRaw.value), companyId: companyId.value || null, mood: mood.value, customFields: customFields.value, @@ -463,6 +477,16 @@ onMounted(load); +
+ + + +
+
diff --git a/apps/web/src/views/ProjectCardFormView.vue b/apps/web/src/views/ProjectCardFormView.vue index e557466..5925dbe 100644 --- a/apps/web/src/views/ProjectCardFormView.vue +++ b/apps/web/src/views/ProjectCardFormView.vue @@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'; import { RouterLink, useRoute, useRouter } from 'vue-router'; import axios from 'axios'; import { api } from '@/lib/api'; -import type { ProjectCardDto } from '@mento/shared'; +import type { OrgUserDto, ProjectCardDto } from '@mento/shared'; import UiIcon from '@/components/UiIcon.vue'; const props = withDefaults( @@ -35,6 +35,29 @@ const saving = ref(false); const error = ref(''); const title = ref(''); const notes = ref(''); +const tagsRaw = ref(''); +const selectedAssigneeIds = ref([]); +const users = ref([]); + +function parseTags(raw: string): string[] { + return [ + ...new Set( + raw + .split(/[,،#\n]/) + .map((t) => t.trim()) + .filter(Boolean), + ), + ].slice(0, 40); +} + +async function loadUsers() { + try { + const { data } = await api.get('/users'); + users.value = data; + } catch { + users.value = []; + } +} async function load() { if (isNew.value) return; @@ -46,6 +69,8 @@ async function load() { const { data } = await api.get(`/project-cards/${cardId}`); title.value = data.title; notes.value = data.notes ?? ''; + tagsRaw.value = (data.tags ?? []).join('، '); + selectedAssigneeIds.value = (data.assignees ?? []).map((a) => a.id); } catch (e) { if (axios.isAxiosError(e) && !e.response) { error.value = 'اتصال به API برقرار نشد.'; @@ -71,6 +96,8 @@ async function save() { const { data } = await api.post('/project-cards', { title: title.value.trim(), notes: notes.value.trim() || undefined, + tags: parseTags(tagsRaw.value), + assigneeIds: selectedAssigneeIds.value, boardId, stageId: props.embedded ? props.initialStageId : undefined, personId: @@ -85,6 +112,8 @@ async function save() { await api.patch(`/project-cards/${id.value}`, { title: title.value.trim(), notes: notes.value.trim() || null, + tags: parseTags(tagsRaw.value), + assigneeIds: selectedAssigneeIds.value, }); if (props.embedded) { emit('saved'); @@ -121,7 +150,10 @@ function cancel() { void router.push({ name: 'projects' }); } -onMounted(load); +onMounted(async () => { + await loadUsers(); + await load(); +});