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:
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user