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(),
@@ -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,
+9 -1
View File
@@ -30,12 +30,20 @@ const RETENTION_DAYS: Record<DeletionRetention, number | null> = {
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: [
+25 -21
View File
@@ -182,27 +182,31 @@ export class LeadsService {
async board(actor: Actor): Promise<LeadBoardColumnDto[]> {
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<string, LeadDto[]>();
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<LeadDto & { events: LeadEventDto[] }> {