feat: show the full work history and load every lead stage on the board

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-14 19:47:32 +03:30
co-authored by Cursor
parent 2cc81bb596
commit 831c682eb3
13 changed files with 669 additions and 66 deletions
+78
View File
@@ -0,0 +1,78 @@
const FIELD_LABELS: Record<string, string> = {
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<string, unknown>;
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<string, unknown>)) {
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,
};
}
@@ -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,
});
@@ -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,
});
},
@@ -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<Prisma.AuditLogWhereInput>(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(),