Track person import source and previous names; add source report and edit UX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-04 12:01:21 +03:30
co-authored by Cursor
parent e144b2277a
commit 8b37525f2f
15 changed files with 632 additions and 25 deletions
@@ -0,0 +1,27 @@
-- AlterTable
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousFirstName" TEXT;
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousLastName" TEXT;
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual';
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT;
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Person_organizationId_importSource_idx" ON "Person"("organizationId", "importSource");
CREATE INDEX IF NOT EXISTS "Person_organizationId_sourceRef_idx" ON "Person"("organizationId", "sourceRef");
-- Backfill from legacy customFields / notes
UPDATE "Person"
SET
"importSource" = 'didar',
"sourceRef" = COALESCE(NULLIF(BTRIM("sourceRef"), ''), NULLIF(BTRIM("customFields"->>'didarId'), ''))
WHERE COALESCE(BTRIM("customFields"->>'didarId'), '') <> '';
UPDATE "Person"
SET "importSource" = 'tahesab'
WHERE "importSource" = 'manual'
AND notes ILIKE '%ته‌حساب%';
UPDATE "Person"
SET "importSource" = 'excel'
WHERE "importSource" = 'manual'
AND COALESCE(BTRIM("customFields"->>'customerCode'), '') <> ''
AND COALESCE(BTRIM("customFields"->>'didarId'), '') = '';
+9
View File
@@ -433,6 +433,13 @@ model Person {
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
firstName String
lastName String
/// نام قبل از همگام‌سازی مبدأ (مثلاً ته‌حساب)
previousFirstName String?
previousLastName String?
/// منبع ورود: manual | tahesab | didar | excel | phone | lead_form | other
importSource String @default("manual")
/// شناسه در مبدأ (کد دیدار، کد مشتری ته‌حساب، …)
sourceRef String?
email String?
phone String?
mobile String?
@@ -478,6 +485,8 @@ model Person {
@@index([organizationId, email])
@@index([organizationId, mobile])
@@index([organizationId, lastName, firstName])
@@index([organizationId, importSource])
@@index([organizationId, sourceRef])
@@index([organizationId, loyaltyPoints])
@@index([companyId])
@@index([ownerId])
@@ -22,6 +22,7 @@ import {
} from '../common/excel-import-session';
import { CompaniesService } from './companies.service';
import { PeopleService } from './people.service';
import { normalizePersonImportSource } from './person-source';
type ImportChunkOpts = {
sessionId?: string;
@@ -459,6 +460,16 @@ export class ContactsExcelService {
})();
try {
const didarId = customFields.didarId?.trim() || '';
const customerCode = customFields.customerCode?.trim() || '';
const sourceCol = customFields.source?.trim() || '';
const importSource = didarId
? 'didar'
: sourceCol
? normalizePersonImportSource(sourceCol)
: 'excel';
const sourceRef = didarId || customerCode || null;
await this.people.create(actor, {
firstName: names.firstName,
lastName: names.lastName,
@@ -474,6 +485,8 @@ export class ContactsExcelService {
companyId,
ownerId: resolveOwnerId(actor, ownerId ?? null),
customFields,
importSource,
sourceRef,
registeredAt: registeredAt?.toISOString() ?? null,
});
result.created += 1;
+54 -1
View File
@@ -13,7 +13,7 @@ import {
MinLength,
ValidateIf,
} from 'class-validator';
import { PERSON_MOODS } from '@mento/shared';
import { PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
export class ListQueryDto {
@IsOptional()
@@ -40,6 +40,12 @@ export class ListQueryDto {
@IsOptional()
@IsIn(['name', 'newest'])
sort?: 'name' | 'newest';
/** فیلتر منبع ورود شخص */
@IsOptional()
@IsString()
@MaxLength(40)
importSource?: string;
}
export class CreateCompanyDto {
@@ -156,6 +162,30 @@ export class CreatePersonDto {
@MaxLength(80)
lastName?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(80)
previousFirstName?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(80)
previousLastName?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
@IsIn([...PERSON_IMPORT_SOURCES])
importSource?: (typeof PERSON_IMPORT_SOURCES)[number];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(120)
sourceRef?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsEmail()
@@ -241,6 +271,29 @@ export class UpdatePersonDto {
@MaxLength(80)
lastName?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(80)
previousFirstName?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(80)
previousLastName?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
importSource?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(120)
sourceRef?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsEmail()
+10 -1
View File
@@ -48,10 +48,19 @@ export class PeopleController {
query.q,
query.page ?? 1,
query.limit ?? 20,
{ mood: query.mood, sort: query.sort },
{
mood: query.mood,
sort: query.sort,
importSource: query.importSource,
},
);
}
@Get('source-stats')
sourceStats(@CurrentUser() user: Actor) {
return this.peopleService.sourceStats(user);
}
@Get('export')
async export(
@CurrentUser() user: Actor,
+60 -1
View File
@@ -7,6 +7,7 @@ import type {
CreatePersonRequest,
PaginatedResponse,
PersonDto,
PersonImportSourceStatDto,
PersonMood,
UpdatePersonRequest,
} from '@mento/shared';
@@ -31,6 +32,10 @@ import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service';
import { normalizeTags } from '../common/tags';
import {
normalizePersonImportSource,
personImportSourceLabel,
} from './person-source';
const personInclude = {
company: { select: { id: true, name: true } },
@@ -44,6 +49,10 @@ type PersonRow = {
id: string;
firstName: string;
lastName: string;
previousFirstName: string | null;
previousLastName: string | null;
importSource: string;
sourceRef: string | null;
email: string | null;
phone: string | null;
mobile: string | null;
@@ -85,20 +94,31 @@ export class PeopleService {
q?: string,
page = 1,
limit = 20,
opts?: { mood?: PersonMood; sort?: 'name' | 'newest' },
opts?: {
mood?: PersonMood;
sort?: 'name' | 'newest';
importSource?: string;
},
): Promise<PaginatedResponse<PersonDto>> {
const sourceFilter = opts?.importSource?.trim()
? normalizePersonImportSource(opts.importSource)
: undefined;
const where: Prisma.PersonWhereInput = {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(opts?.mood ? { mood: opts.mood } : {}),
...(sourceFilter ? { importSource: sourceFilter } : {}),
...(q?.trim()
? {
OR: [
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
{ previousFirstName: { contains: q.trim(), mode: 'insensitive' } },
{ previousLastName: { contains: q.trim(), mode: 'insensitive' } },
{ email: { contains: q.trim(), mode: 'insensitive' } },
{ mobile: { contains: q.trim() } },
{ phone: { contains: q.trim() } },
{ sourceRef: { contains: q.trim() } },
{ company: { name: { contains: q.trim(), mode: 'insensitive' } } },
],
}
@@ -127,6 +147,24 @@ export class PeopleService {
};
}
async sourceStats(actor: Actor): Promise<PersonImportSourceStatDto[]> {
const rows = await this.prisma.person.groupBy({
by: ['importSource'],
where: {
organizationId: actor.organizationId,
...ownerWhere(actor),
},
_count: { _all: true },
});
return rows
.map((row) => ({
source: row.importSource || 'manual',
label: personImportSourceLabel(row.importSource || 'manual'),
count: row._count._all,
}))
.sort((a, b) => b.count - a.count);
}
async get(actor: Actor, id: string): Promise<PersonDto> {
const row = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
@@ -147,6 +185,10 @@ export class PeopleService {
organizationId: actor.organizationId,
firstName: (dto.firstName ?? '').trim(),
lastName: (dto.lastName ?? '').trim(),
previousFirstName: emptyToNull(dto.previousFirstName),
previousLastName: emptyToNull(dto.previousLastName),
importSource: normalizePersonImportSource(dto.importSource),
sourceRef: emptyToNull(dto.sourceRef),
email: normalizeEmail(dto.email),
phone: normalizePhone(dto.phone),
mobile: normalizePhone(dto.mobile),
@@ -207,6 +249,16 @@ export class PeopleService {
const data: Prisma.PersonUncheckedUpdateInput = {};
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
if ('previousFirstName' in dto) {
data.previousFirstName = emptyToNull(dto.previousFirstName);
}
if ('previousLastName' in dto) {
data.previousLastName = emptyToNull(dto.previousLastName);
}
if (dto.importSource !== undefined) {
data.importSource = normalizePersonImportSource(dto.importSource);
}
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
@@ -297,10 +349,17 @@ export class PeopleService {
}
private toDto(row: PersonRow): PersonDto {
const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim();
const importSource = row.importSource || 'manual';
return {
id: row.id,
firstName: row.firstName,
lastName: row.lastName,
previousFirstName: row.previousFirstName,
previousLastName: row.previousLastName,
previousFullName: previousFullName || null,
importSource,
sourceRef: row.sourceRef,
fullName: `${row.firstName} ${row.lastName}`.trim(),
email: row.email,
phone: row.phone,
+41
View File
@@ -0,0 +1,41 @@
import {
PERSON_IMPORT_SOURCE_LABELS,
PERSON_IMPORT_SOURCES,
type PersonImportSource,
} from '@mento/shared';
export function normalizePersonImportSource(
raw?: string | null,
): PersonImportSource {
const v = String(raw ?? '')
.trim()
.toLowerCase();
if (!v) return 'manual';
if ((PERSON_IMPORT_SOURCES as readonly string[]).includes(v)) {
return v as PersonImportSource;
}
if (/دیدار|didar/.test(v)) return 'didar';
if (/ته.?حساب|tahesab|accounting/.test(v)) return 'tahesab';
if (/اکسل|excel|xlsx|csv/.test(v)) return 'excel';
if (/گوشی|phone|مخاطب|vcard|contact/.test(v)) return 'phone';
if (/لید|lead|فرم/.test(v)) return 'lead_form';
return 'other';
}
export function personImportSourceLabel(source: string): string {
if (source in PERSON_IMPORT_SOURCE_LABELS) {
return PERSON_IMPORT_SOURCE_LABELS[source as PersonImportSource];
}
return source || PERSON_IMPORT_SOURCE_LABELS.manual;
}
export function namesDiffer(
aFirst: string,
aLast: string,
bFirst: string,
bLast: string,
): boolean {
const a = `${aFirst} ${aLast}`.trim().replace(/\s+/g, ' ');
const b = `${bFirst} ${bLast}`.trim().replace(/\s+/g, ' ');
return Boolean(a && b && a !== b);
}
@@ -493,22 +493,82 @@ export class TahesabPersonLinkService {
const parts = name.trim().split(/\s+/);
const firstName = parts[0] || name;
const lastName = parts.slice(1).join(' ') || '—';
const mobile = tel || null;
const sourceRef = String(dto.moshtariCode);
let personId = dto.personId;
if (personId) {
await this.getPerson(organizationId, personId);
} else {
const created = await this.prisma.person.create({
const existing = await this.getPerson(organizationId, personId);
const prevFirst = existing.firstName;
const prevLast = existing.lastName;
const nameChanged =
`${prevFirst} ${prevLast}`.trim() !== `${firstName} ${lastName}`.trim();
await this.prisma.person.update({
where: { id: personId },
data: {
organizationId,
firstName,
lastName,
mobile: tel || null,
phone: tel || null,
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
...(nameChanged
? {
previousFirstName: prevFirst || null,
previousLastName: prevLast || null,
}
: {}),
importSource: 'tahesab',
sourceRef,
...(mobile
? {
mobile: existing.mobile || mobile,
phone: existing.phone || mobile,
}
: {}),
},
});
personId = created.id;
} else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین
const byMobile = mobile
? await this.prisma.person.findFirst({
where: {
organizationId,
OR: [{ mobile }, { phone: mobile }],
},
})
: null;
if (byMobile) {
const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim();
await this.prisma.person.update({
where: { id: byMobile.id },
data: {
firstName,
lastName,
...(nameChanged
? {
previousFirstName: byMobile.firstName || null,
previousLastName: byMobile.lastName || null,
}
: {}),
importSource: 'tahesab',
sourceRef,
},
});
personId = byMobile.id;
} else {
const created = await this.prisma.person.create({
data: {
organizationId,
firstName,
lastName,
mobile,
phone: mobile,
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
importSource: 'tahesab',
sourceRef,
},
});
personId = created.id;
}
}
const link = await this.prisma.personAccountingLink.upsert({
+2
View File
@@ -756,6 +756,8 @@ export class LeadsService {
.join('\n'),
ownerId: lead.assigneeId ?? actor.userId,
createdAt: lead.createdAt,
importSource: 'lead_form',
sourceRef: lead.id,
},
});
createdPerson = true;