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:
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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[] }> {
|
||||
|
||||
Reference in New Issue
Block a user