diff --git a/apps/api/src/audit-logs/audit-detail.ts b/apps/api/src/audit-logs/audit-detail.ts new file mode 100644 index 0000000..829853c --- /dev/null +++ b/apps/api/src/audit-logs/audit-detail.ts @@ -0,0 +1,78 @@ +const FIELD_LABELS: Record = { + title: 'عنوان', + name: 'نام', + firstName: 'نام', + lastName: 'نام خانوادگی', + fullName: 'نام', + description: 'توضیحات', + body: 'متن', + mobile: 'موبایل', + phone: 'تلفن', + email: 'ایمیل', + status: 'وضعیت', + stageId: 'مرحله', + priority: 'اولویت', + dueAt: 'موعد', + amount: 'مبلغ', + subject: 'موضوع', + type: 'نوع', + city: 'شهر', + address: 'آدرس', + notes: 'یادداشت', + contactName: 'نام مخاطب', +}; + +const SECRET = /password|token|secret|otp|pin/i; + +function textOf(value: unknown) { + if (typeof value === 'string') return value.trim().replace(/\s+/g, ' '); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return ''; +} + +function labelOf(source: unknown) { + if (!source || typeof source !== 'object' || Array.isArray(source)) return ''; + const body = source as Record; + const person = [textOf(body.firstName), textOf(body.lastName)].filter(Boolean).join(' '); + return ( + person || + textOf(body.title) || + textOf(body.name) || + textOf(body.subject) || + textOf(body.fullName) + ).slice(0, 120); +} + +function changesOf(source: unknown) { + if (!source || typeof source !== 'object' || Array.isArray(source)) return ''; + const bits: string[] = []; + for (const [key, value] of Object.entries(source as Record)) { + if (SECRET.test(key)) continue; + const name = FIELD_LABELS[key]; + if (!name) continue; + if (key.endsWith('Id')) { + bits.push(name); + } else { + const text = textOf(value).slice(0, 40); + if (!text) continue; + bits.push(`${name}: ${text}`); + } + if (bits.length >= 4) break; + } + return bits.join('، '); +} + +export function auditStory( + method: string, + requestBody: unknown, + responseBody: unknown, +) { + const label = labelOf(responseBody) || labelOf(requestBody); + const detail = method === 'DELETE' ? '' : changesOf(requestBody); + const summary = [label, detail].filter(Boolean).join(' — ').slice(0, 500); + return { + label, + detail: detail.slice(0, 240), + summary, + }; +} diff --git a/apps/api/src/audit-logs/audit-logs.controller.ts b/apps/api/src/audit-logs/audit-logs.controller.ts index e236b1e..571bf97 100644 --- a/apps/api/src/audit-logs/audit-logs.controller.ts +++ b/apps/api/src/audit-logs/audit-logs.controller.ts @@ -22,6 +22,10 @@ class ListAuditQueryDto { @IsString() entityType?: string; + @IsOptional() + @IsString() + action?: string; + @IsOptional() @Type(() => Number) @IsInt() @@ -52,6 +56,7 @@ export class AuditLogsController { q: query.q, actorUserId: query.actorUserId, entityType: query.entityType, + action: query.action, 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 index 7a8e411..1c4f7d2 100644 --- a/apps/api/src/audit-logs/audit-logs.interceptor.ts +++ b/apps/api/src/audit-logs/audit-logs.interceptor.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import type { Request } from 'express'; import { Observable, tap } from 'rxjs'; +import { auditStory } from './audit-detail'; import { AuditLogsService } from './audit-logs.service'; const SKIP_PREFIXES = [ @@ -73,7 +74,6 @@ export class AuditLogsInterceptor implements NestInterceptor { 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 || @@ -81,7 +81,11 @@ export class AuditLogsInterceptor implements NestInterceptor { return next.handle().pipe( tap({ - next: () => { + next: (response) => { + const story = auditStory(method, req.body, response); + const summary = + story.summary || + `${action} ${entityType ?? 'منبع'} از مسیر ${path.split('?')[0]}`; void this.audit.write({ organizationId, actorUserId, @@ -91,6 +95,7 @@ export class AuditLogsInterceptor implements NestInterceptor { entityType, entityId, summary, + meta: story.label || story.detail ? { label: story.label, detail: story.detail } : null, ip, }); }, diff --git a/apps/api/src/audit-logs/audit-logs.service.ts b/apps/api/src/audit-logs/audit-logs.service.ts index 4cf4f9b..78baa11 100644 --- a/apps/api/src/audit-logs/audit-logs.service.ts +++ b/apps/api/src/audit-logs/audit-logs.service.ts @@ -51,6 +51,7 @@ export class AuditLogsService { q?: string; actorUserId?: string; entityType?: string; + action?: string; page?: number; limit?: number; }, @@ -61,6 +62,7 @@ export class AuditLogsService { organizationId, ...(opts.actorUserId ? { actorUserId: opts.actorUserId } : {}), ...(opts.entityType ? { entityType: opts.entityType } : {}), + ...(opts.action ? { action: opts.action } : {}), }; const search = buildSearchWhere(opts.q, (t) => [ { summary: ci(t) }, @@ -97,10 +99,17 @@ export class AuditLogsService { entityType: string | null; entityId: string | null; summary: string; + meta: Prisma.JsonValue | null; ip: string | null; createdAt: Date; actor: { id: string; fullName: string } | null; }): AuditLogDto { + const meta = + row.meta && typeof row.meta === 'object' && !Array.isArray(row.meta) + ? (row.meta as { label?: unknown; detail?: unknown }) + : null; + const label = typeof meta?.label === 'string' ? meta.label : null; + const detail = typeof meta?.detail === 'string' ? meta.detail : null; return { id: row.id, action: row.action, @@ -109,6 +118,8 @@ export class AuditLogsService { entityType: row.entityType, entityId: row.entityId, summary: row.summary, + label, + detail, actor: row.actor, ip: row.ip, createdAt: row.createdAt.toISOString(), diff --git a/apps/api/src/deletions/deletions.controller.ts b/apps/api/src/deletions/deletions.controller.ts index 2683be4..82dfba4 100644 --- a/apps/api/src/deletions/deletions.controller.ts +++ b/apps/api/src/deletions/deletions.controller.ts @@ -22,6 +22,10 @@ class ListDeletionsQueryDto { @IsString() q?: string; + @IsOptional() + @IsString() + deletedById?: string; + @IsOptional() @Type(() => Number) @IsInt() @@ -47,7 +51,7 @@ export class DeletionsController { constructor(private readonly deletions: DeletionsService) {} @Get() - @Roles('owner', 'admin') + @Roles('owner') list( @CurrentUser() user: { organizationId: string }, @Query() query: ListDeletionsQueryDto, @@ -57,11 +61,12 @@ export class DeletionsController { query.page, query.limit, query.q, + query.deletedById, ); } @Patch('settings') - @Roles('owner', 'admin') + @Roles('owner') updateSettings( @CurrentUser() user: { organizationId: string }, @Body() dto: RetentionDto, @@ -70,7 +75,7 @@ export class DeletionsController { } @Post(':id/restore') - @Roles('owner', 'admin') + @Roles('owner') restore( @CurrentUser() user: { organizationId: string }, @Param('id') id: string, @@ -79,7 +84,7 @@ export class DeletionsController { } @Delete(':id') - @Roles('owner', 'admin') + @Roles('owner') purge( @CurrentUser() user: { organizationId: string }, @Param('id') id: string, diff --git a/apps/api/src/deletions/deletions.service.ts b/apps/api/src/deletions/deletions.service.ts index f6751e9..205b8ba 100644 --- a/apps/api/src/deletions/deletions.service.ts +++ b/apps/api/src/deletions/deletions.service.ts @@ -30,12 +30,20 @@ const RETENTION_DAYS: Record = { export class DeletionsService { constructor(private readonly prisma: PrismaService) {} - async list(organizationId: string, page = 1, limit = 30, q?: string) { + async list( + organizationId: string, + page = 1, + limit = 30, + q?: string, + deletedById?: string, + ) { const take = Math.min(100, Math.max(1, limit)); const skip = (Math.max(1, page) - 1) * take; const query = q?.trim(); + const actorId = deletedById?.trim(); const where = { organizationId, + ...(actorId ? { deletedById: actorId } : {}), ...(query ? { OR: [ diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index 3714bb5..c0f70b1 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -182,27 +182,31 @@ export class LeadsService { async board(actor: Actor): Promise { const stages = await this.stages.list(actor.organizationId); const boardStages = stages.filter((s) => s.showOnBoard); - const leads = await this.prisma.lead.findMany({ - where: { - organizationId: actor.organizationId, - ...leadAccessWhere(actor), - archivedAt: null, - stageId: { in: boardStages.map((s) => s.id) }, - }, - include: leadInclude, - orderBy: [{ stageId: 'asc' }, { position: 'asc' }, { updatedAt: 'desc' }], - take: 500, - }); - const byStage = new Map(); - for (const s of boardStages) byStage.set(s.id, []); - for (const row of leads) { - const list = byStage.get(row.stageId); - if (list) list.push(this.toDto(row)); - } - return boardStages.map((stage) => ({ - stage, - leads: byStage.get(stage.id) ?? [], - })); + const whereBase = { + organizationId: actor.organizationId, + ...leadAccessWhere(actor), + archivedAt: null, + }; + const perColumn = 120; + return Promise.all( + boardStages.map(async (stage) => { + const where = { ...whereBase, stageId: stage.id }; + const [total, rows] = await Promise.all([ + this.prisma.lead.count({ where }), + this.prisma.lead.findMany({ + where, + include: leadInclude, + orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }], + take: perColumn, + }), + ]); + return { + stage, + total, + leads: rows.map((row) => this.toDto(row)), + }; + }), + ); } async get(actor: Actor, id: string): Promise { diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 4e88fc9..b1efbad 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -132,7 +132,9 @@ const hubVisible = computed(() => { for (const hub of NAV_HUBS) { map[hub.key] = hub.cards.some( (card) => - (!card.admin || canSettings.value) && moduleAllowed(card.module), + (!card.ownerOnly || auth.user?.role === 'owner') && + (!card.admin || canSettings.value) && + moduleAllowed(card.module), ); } return map; @@ -144,7 +146,9 @@ const openHub = computed(() => { if (!hub) return null; const cards = hub.cards.filter( (card) => - (!card.admin || canSettings.value) && moduleAllowed(card.module), + (!card.ownerOnly || auth.user?.role === 'owner') && + (!card.admin || canSettings.value) && + moduleAllowed(card.module), ); if ( hub.key === 'customers' && diff --git a/apps/web/src/lib/nav-hubs.ts b/apps/web/src/lib/nav-hubs.ts index d11a609..c7015b3 100644 --- a/apps/web/src/lib/nav-hubs.ts +++ b/apps/web/src/lib/nav-hubs.ts @@ -48,6 +48,8 @@ export type NavHubCard = { icon: NavIconName; tint: NavHubTint; admin?: boolean; + /** فقط نقش مالک؛ مدیر سازمان این کارت را نمی‌بیند */ + ownerOnly?: boolean; module?: OrgModuleKey; /** اگر ست باشد، به‌جای مسیر، هاب تو در تو باز می‌شود (موبایل: مشتریان → لیدها) */ hub?: NavHubKey; @@ -440,11 +442,11 @@ export const NAV_HUBS: NavHubDef[] = [ }, { to: '/settings/deletions', - title: 'لاگ حذفیات', - desc: 'چه کسی چه چیزی را حذف کرده، بازگردانی یا حذف کامل', + title: 'تاریخچه کارها', + desc: 'ایجاد، ویرایش و حذف؛ بازگردانی فقط برای حذف‌ها', icon: 'trash', tint: 'rose', - admin: true, + ownerOnly: true, }, { to: '/settings/public-api', diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index d29d09b..a819685 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -365,7 +365,7 @@ export const router = createRouter({ path: 'settings/deletions', name: 'deletion-archives', component: () => import('@/views/DeletionArchivesView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, + meta: { roles: ['owner'] as UserRole[] }, }, { path: 'settings/public-api', diff --git a/apps/web/src/views/DeletionArchivesView.vue b/apps/web/src/views/DeletionArchivesView.vue index ebe8bb2..293497c 100644 --- a/apps/web/src/views/DeletionArchivesView.vue +++ b/apps/web/src/views/DeletionArchivesView.vue @@ -1,10 +1,59 @@