@hed-hog/contact 0.0.279 → 0.0.285
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/person/dto/create-followup.dto.d.ts +5 -0
- package/dist/person/dto/create-followup.dto.d.ts.map +1 -0
- package/dist/person/dto/create-followup.dto.js +31 -0
- package/dist/person/dto/create-followup.dto.js.map +1 -0
- package/dist/person/dto/create-interaction.dto.d.ts +12 -0
- package/dist/person/dto/create-interaction.dto.d.ts.map +1 -0
- package/dist/person/dto/create-interaction.dto.js +39 -0
- package/dist/person/dto/create-interaction.dto.js.map +1 -0
- package/dist/person/dto/create.dto.d.ts +24 -0
- package/dist/person/dto/create.dto.d.ts.map +1 -1
- package/dist/person/dto/create.dto.js +56 -1
- package/dist/person/dto/create.dto.js.map +1 -1
- package/dist/person/dto/duplicates-query.dto.d.ts +8 -0
- package/dist/person/dto/duplicates-query.dto.d.ts.map +1 -0
- package/dist/person/dto/duplicates-query.dto.js +45 -0
- package/dist/person/dto/duplicates-query.dto.js.map +1 -0
- package/dist/person/dto/merge.dto.d.ts +6 -0
- package/dist/person/dto/merge.dto.d.ts.map +1 -0
- package/dist/person/dto/merge.dto.js +35 -0
- package/dist/person/dto/merge.dto.js.map +1 -0
- package/dist/person/dto/update-lifecycle-stage.dto.d.ts +13 -0
- package/dist/person/dto/update-lifecycle-stage.dto.d.ts.map +1 -0
- package/dist/person/dto/update-lifecycle-stage.dto.js +34 -0
- package/dist/person/dto/update-lifecycle-stage.dto.js.map +1 -0
- package/dist/person/dto/update.dto.d.ts +8 -1
- package/dist/person/dto/update.dto.d.ts.map +1 -1
- package/dist/person/dto/update.dto.js +36 -0
- package/dist/person/dto/update.dto.js.map +1 -1
- package/dist/person/person.controller.d.ts +57 -1
- package/dist/person/person.controller.d.ts.map +1 -1
- package/dist/person/person.controller.js +85 -3
- package/dist/person/person.controller.js.map +1 -1
- package/dist/person/person.service.d.ts +79 -0
- package/dist/person/person.service.d.ts.map +1 -1
- package/dist/person/person.service.js +730 -9
- package/dist/person/person.service.js.map +1 -1
- package/hedhog/data/route.yaml +18 -0
- package/hedhog/frontend/app/_components/crm-coming-soon.tsx.ejs +110 -110
- package/hedhog/frontend/app/_components/crm-nav.tsx.ejs +73 -73
- package/hedhog/frontend/app/_lib/crm-mocks.ts.ejs +498 -256
- package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +81 -81
- package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +477 -0
- package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +62 -0
- package/hedhog/frontend/app/accounts/page.tsx.ejs +886 -15
- package/hedhog/frontend/app/activities/page.tsx.ejs +15 -15
- package/hedhog/frontend/app/contact-type/page.tsx.ejs +105 -91
- package/hedhog/frontend/app/dashboard/page.tsx.ejs +506 -573
- package/hedhog/frontend/app/document-type/page.tsx.ejs +105 -91
- package/hedhog/frontend/app/follow-ups/page.tsx.ejs +15 -15
- package/hedhog/frontend/app/page.tsx.ejs +5 -5
- package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +1440 -1103
- package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +4 -3
- package/hedhog/frontend/app/person/_components/person-types.ts.ejs +14 -0
- package/hedhog/frontend/app/person/page.tsx.ejs +108 -190
- package/hedhog/frontend/app/pipeline/_components/lead-detail-sheet.tsx.ejs +599 -0
- package/hedhog/frontend/app/pipeline/page.tsx.ejs +1074 -299
- package/hedhog/frontend/app/reports/page.tsx.ejs +15 -15
- package/hedhog/frontend/messages/en.json +107 -0
- package/hedhog/frontend/messages/pt.json +106 -0
- package/package.json +6 -6
- package/src/person/dto/create-followup.dto.ts +15 -0
- package/src/person/dto/create-interaction.dto.ts +23 -0
- package/src/person/dto/create.dto.ts +50 -0
- package/src/person/dto/duplicates-query.dto.ts +34 -0
- package/src/person/dto/merge.dto.ts +15 -0
- package/src/person/dto/update-lifecycle-stage.dto.ts +19 -0
- package/src/person/dto/update.dto.ts +31 -1
- package/src/person/person.controller.ts +63 -2
- package/src/person/person.service.ts +1096 -7
|
@@ -4,13 +4,21 @@ import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
|
|
|
4
4
|
import { Prisma, PrismaService } from '@hed-hog/api-prisma';
|
|
5
5
|
import { FileService, SettingService } from '@hed-hog/core';
|
|
6
6
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
BadRequestException,
|
|
8
|
+
Inject,
|
|
9
|
+
Injectable,
|
|
10
|
+
NotFoundException,
|
|
11
|
+
forwardRef,
|
|
12
12
|
} from '@nestjs/common';
|
|
13
|
+
import { CreateFollowupDTO } from './dto/create-followup.dto';
|
|
14
|
+
import {
|
|
15
|
+
CreateInteractionDTO,
|
|
16
|
+
PersonInteractionTypeDTO,
|
|
17
|
+
} from './dto/create-interaction.dto';
|
|
13
18
|
import { CreateDTO } from './dto/create.dto';
|
|
19
|
+
import { CheckPersonDuplicatesQueryDTO } from './dto/duplicates-query.dto';
|
|
20
|
+
import { MergePersonDTO } from './dto/merge.dto';
|
|
21
|
+
import { UpdateLifecycleStageDTO } from './dto/update-lifecycle-stage.dto';
|
|
14
22
|
|
|
15
23
|
const CONTACT_ALLOW_COMPANY_REGISTRATION_SETTING =
|
|
16
24
|
'contact-allow-company-registration';
|
|
@@ -23,6 +31,33 @@ const CONTACT_OWNER_ALLOWED_ROLE_SLUGS = [
|
|
|
23
31
|
type PersonActivityAction = 'created' | 'updated' | 'interaction_created';
|
|
24
32
|
const EMPLOYER_COMPANY_METADATA_KEY = 'employer_company_id';
|
|
25
33
|
const NOTES_METADATA_KEY = 'notes';
|
|
34
|
+
const MERGED_INTO_PERSON_METADATA_KEY = 'merged_into_person_id';
|
|
35
|
+
const OWNER_USER_METADATA_KEY = 'owner_user_id';
|
|
36
|
+
const SOURCE_METADATA_KEY = 'source';
|
|
37
|
+
const LIFECYCLE_STAGE_METADATA_KEY = 'lifecycle_stage';
|
|
38
|
+
const NEXT_ACTION_AT_METADATA_KEY = 'next_action_at';
|
|
39
|
+
const SCORE_METADATA_KEY = 'score';
|
|
40
|
+
const DEAL_VALUE_METADATA_KEY = 'deal_value';
|
|
41
|
+
const TAGS_METADATA_KEY = 'tags';
|
|
42
|
+
const LAST_INTERACTION_AT_METADATA_KEY = 'last_interaction_at';
|
|
43
|
+
const INTERACTIONS_METADATA_KEY = 'interactions';
|
|
44
|
+
|
|
45
|
+
type DuplicateReason = 'email' | 'phone' | 'document';
|
|
46
|
+
|
|
47
|
+
type DuplicateMatch = {
|
|
48
|
+
id: number;
|
|
49
|
+
name: string;
|
|
50
|
+
reasons: DuplicateReason[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type PersonInteractionRecord = {
|
|
54
|
+
id: number;
|
|
55
|
+
type: CreateInteractionDTO['type'];
|
|
56
|
+
notes: string | null;
|
|
57
|
+
created_at: string;
|
|
58
|
+
user_id: number | null;
|
|
59
|
+
user_name: string | null;
|
|
60
|
+
};
|
|
26
61
|
|
|
27
62
|
@Injectable()
|
|
28
63
|
export class PersonService {
|
|
@@ -125,6 +160,227 @@ export class PersonService {
|
|
|
125
160
|
return Array.from(byId.values());
|
|
126
161
|
}
|
|
127
162
|
|
|
163
|
+
async checkDuplicates(query: CheckPersonDuplicatesQueryDTO) {
|
|
164
|
+
const excludedPersonId = this.coerceNumber(query.person_id);
|
|
165
|
+
const normalizedEmail = this.normalizeEmail(query.email);
|
|
166
|
+
const normalizedPhone = this.normalizeDigits(query.phone || '');
|
|
167
|
+
const normalizedDocument = this.normalizeDigits(query.document_value || '');
|
|
168
|
+
const documentTypeId = this.coerceNumber(query.document_type_id);
|
|
169
|
+
|
|
170
|
+
if (!normalizedEmail && !normalizedPhone && !normalizedDocument) {
|
|
171
|
+
return { hasDuplicates: false, matches: [] as DuplicateMatch[] };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const reasonsByPersonId = new Map<number, Set<DuplicateReason>>();
|
|
175
|
+
|
|
176
|
+
if (normalizedEmail) {
|
|
177
|
+
const emailRows = await this.prismaService.contact.findMany({
|
|
178
|
+
where: {
|
|
179
|
+
...(excludedPersonId > 0
|
|
180
|
+
? {
|
|
181
|
+
person_id: {
|
|
182
|
+
not: excludedPersonId,
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
: {}),
|
|
186
|
+
contact_type: {
|
|
187
|
+
code: {
|
|
188
|
+
equals: 'EMAIL',
|
|
189
|
+
mode: 'insensitive',
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
value: {
|
|
193
|
+
equals: normalizedEmail,
|
|
194
|
+
mode: 'insensitive',
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
select: {
|
|
198
|
+
person_id: true,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
for (const row of emailRows) {
|
|
203
|
+
this.addDuplicateReason(reasonsByPersonId, row.person_id, 'email');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (normalizedPhone) {
|
|
208
|
+
const excludedPersonFilter =
|
|
209
|
+
excludedPersonId > 0
|
|
210
|
+
? Prisma.sql` AND c.person_id <> ${excludedPersonId}`
|
|
211
|
+
: Prisma.empty;
|
|
212
|
+
|
|
213
|
+
const phoneRows = await this.prismaService.$queryRaw<
|
|
214
|
+
Array<{ person_id: number }>
|
|
215
|
+
>(
|
|
216
|
+
Prisma.sql`
|
|
217
|
+
SELECT DISTINCT c.person_id
|
|
218
|
+
FROM contact c
|
|
219
|
+
JOIN contact_type ct ON ct.id = c.contact_type_id
|
|
220
|
+
WHERE UPPER(ct.code) IN ('PHONE', 'MOBILE', 'WHATSAPP')
|
|
221
|
+
AND regexp_replace(COALESCE(c.value, ''), '[^0-9]+', '', 'g') = ${normalizedPhone}
|
|
222
|
+
${excludedPersonFilter}
|
|
223
|
+
`,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
for (const row of phoneRows) {
|
|
227
|
+
this.addDuplicateReason(reasonsByPersonId, row.person_id, 'phone');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (normalizedDocument) {
|
|
232
|
+
const excludedPersonFilter =
|
|
233
|
+
excludedPersonId > 0
|
|
234
|
+
? Prisma.sql` AND d.person_id <> ${excludedPersonId}`
|
|
235
|
+
: Prisma.empty;
|
|
236
|
+
const documentTypeFilter =
|
|
237
|
+
documentTypeId > 0
|
|
238
|
+
? Prisma.sql` AND d.document_type_id = ${documentTypeId}`
|
|
239
|
+
: Prisma.empty;
|
|
240
|
+
|
|
241
|
+
const documentRows = await this.prismaService.$queryRaw<
|
|
242
|
+
Array<{ person_id: number }>
|
|
243
|
+
>(
|
|
244
|
+
Prisma.sql`
|
|
245
|
+
SELECT DISTINCT d.person_id
|
|
246
|
+
FROM document d
|
|
247
|
+
WHERE regexp_replace(COALESCE(d.value, ''), '[^0-9]+', '', 'g') = ${normalizedDocument}
|
|
248
|
+
${excludedPersonFilter}
|
|
249
|
+
${documentTypeFilter}
|
|
250
|
+
`,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
for (const row of documentRows) {
|
|
254
|
+
this.addDuplicateReason(reasonsByPersonId, row.person_id, 'document');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const duplicateIds = Array.from(reasonsByPersonId.keys());
|
|
259
|
+
if (duplicateIds.length === 0) {
|
|
260
|
+
return { hasDuplicates: false, matches: [] as DuplicateMatch[] };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const people = await this.prismaService.person.findMany({
|
|
264
|
+
where: {
|
|
265
|
+
id: {
|
|
266
|
+
in: duplicateIds,
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
select: {
|
|
270
|
+
id: true,
|
|
271
|
+
name: true,
|
|
272
|
+
},
|
|
273
|
+
orderBy: {
|
|
274
|
+
name: 'asc',
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const matches = people.map((person) => ({
|
|
279
|
+
id: person.id,
|
|
280
|
+
name: person.name,
|
|
281
|
+
reasons: Array.from(reasonsByPersonId.get(person.id) || []),
|
|
282
|
+
}));
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
hasDuplicates: matches.length > 0,
|
|
286
|
+
matches,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async merge(data: MergePersonDTO, locale: string) {
|
|
291
|
+
const sourcePersonId = this.coerceNumber(data.source_person_id);
|
|
292
|
+
const targetPersonId = this.coerceNumber(data.target_person_id);
|
|
293
|
+
|
|
294
|
+
if (sourcePersonId <= 0 || targetPersonId <= 0) {
|
|
295
|
+
throw new BadRequestException(
|
|
296
|
+
getLocaleText(
|
|
297
|
+
'validation.idMustBeInteger',
|
|
298
|
+
locale,
|
|
299
|
+
'Source and target person IDs must be valid integers.',
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (sourcePersonId === targetPersonId) {
|
|
305
|
+
throw new BadRequestException(
|
|
306
|
+
getLocaleText(
|
|
307
|
+
'personMergeSameRecord',
|
|
308
|
+
locale,
|
|
309
|
+
'Source and target person must be different records.',
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const [sourcePerson, targetPerson] = await Promise.all([
|
|
315
|
+
this.prismaService.person.findUnique({
|
|
316
|
+
where: { id: sourcePersonId },
|
|
317
|
+
select: { id: true, name: true, type: true },
|
|
318
|
+
}),
|
|
319
|
+
this.prismaService.person.findUnique({
|
|
320
|
+
where: { id: targetPersonId },
|
|
321
|
+
select: { id: true, name: true, type: true },
|
|
322
|
+
}),
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
if (!sourcePerson) {
|
|
326
|
+
throw new NotFoundException(
|
|
327
|
+
getLocaleText(
|
|
328
|
+
'personNotFound',
|
|
329
|
+
locale,
|
|
330
|
+
`Person with ID ${sourcePersonId} not found`,
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!targetPerson) {
|
|
336
|
+
throw new NotFoundException(
|
|
337
|
+
getLocaleText(
|
|
338
|
+
'personNotFound',
|
|
339
|
+
locale,
|
|
340
|
+
`Person with ID ${targetPersonId} not found`,
|
|
341
|
+
),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (sourcePerson.type !== targetPerson.type) {
|
|
346
|
+
throw new BadRequestException(
|
|
347
|
+
getLocaleText(
|
|
348
|
+
'personMergeTypeMismatch',
|
|
349
|
+
locale,
|
|
350
|
+
'Only records with the same type can be merged.',
|
|
351
|
+
),
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
await this.prismaService.$transaction(async (tx) => {
|
|
356
|
+
await this.mergeContacts(tx, sourcePersonId, targetPersonId);
|
|
357
|
+
await this.mergeDocuments(tx, sourcePersonId, targetPersonId);
|
|
358
|
+
await this.mergeAddresses(tx, sourcePersonId, targetPersonId);
|
|
359
|
+
await this.mergeMetadata(tx, sourcePersonId, targetPersonId);
|
|
360
|
+
|
|
361
|
+
await tx.person.update({
|
|
362
|
+
where: { id: sourcePersonId },
|
|
363
|
+
data: {
|
|
364
|
+
status: 'inactive',
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
await this.upsertMetadataValue(
|
|
369
|
+
tx,
|
|
370
|
+
sourcePersonId,
|
|
371
|
+
MERGED_INTO_PERSON_METADATA_KEY,
|
|
372
|
+
targetPersonId,
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
success: true,
|
|
378
|
+
source_person_id: sourcePersonId,
|
|
379
|
+
target_person_id: targetPersonId,
|
|
380
|
+
strategy: 'contact_only',
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
128
384
|
async list(
|
|
129
385
|
paginationParams: PaginationDTO & {
|
|
130
386
|
type?: string;
|
|
@@ -164,6 +420,54 @@ export class PersonService {
|
|
|
164
420
|
where.status = paginationParams.status;
|
|
165
421
|
}
|
|
166
422
|
|
|
423
|
+
const ownerUserId = this.resolveRequestedOwnerUserId(
|
|
424
|
+
paginationParams.owner_user_id,
|
|
425
|
+
paginationParams.mine,
|
|
426
|
+
currentUserId,
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const metadataFilters: any[] = [];
|
|
430
|
+
|
|
431
|
+
if (ownerUserId > 0) {
|
|
432
|
+
metadataFilters.push({
|
|
433
|
+
person_metadata: {
|
|
434
|
+
some: {
|
|
435
|
+
key: OWNER_USER_METADATA_KEY,
|
|
436
|
+
value: ownerUserId as any,
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (paginationParams.source && paginationParams.source !== 'all') {
|
|
443
|
+
metadataFilters.push({
|
|
444
|
+
person_metadata: {
|
|
445
|
+
some: {
|
|
446
|
+
key: SOURCE_METADATA_KEY,
|
|
447
|
+
value: paginationParams.source as any,
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (
|
|
454
|
+
paginationParams.lifecycle_stage &&
|
|
455
|
+
paginationParams.lifecycle_stage !== 'all'
|
|
456
|
+
) {
|
|
457
|
+
metadataFilters.push({
|
|
458
|
+
person_metadata: {
|
|
459
|
+
some: {
|
|
460
|
+
key: LIFECYCLE_STAGE_METADATA_KEY,
|
|
461
|
+
value: paginationParams.lifecycle_stage as any,
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (metadataFilters.length > 0) {
|
|
468
|
+
where.AND = [...(Array.isArray(where.AND) ? where.AND : []), ...metadataFilters];
|
|
469
|
+
}
|
|
470
|
+
|
|
167
471
|
if (search) {
|
|
168
472
|
where.OR = await this.buildSearchFilters(search);
|
|
169
473
|
}
|
|
@@ -233,6 +537,139 @@ export class PersonService {
|
|
|
233
537
|
return normalized;
|
|
234
538
|
}
|
|
235
539
|
|
|
540
|
+
async listInteractions(id: number, locale: string) {
|
|
541
|
+
const person = await this.ensurePersonAccessible(id, locale);
|
|
542
|
+
const metadata = await this.prismaService.person_metadata.findFirst({
|
|
543
|
+
where: {
|
|
544
|
+
person_id: person.id,
|
|
545
|
+
key: INTERACTIONS_METADATA_KEY,
|
|
546
|
+
},
|
|
547
|
+
select: {
|
|
548
|
+
value: true,
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
return this.metadataToInteractions(metadata?.value);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async createInteraction(
|
|
556
|
+
id: number,
|
|
557
|
+
data: CreateInteractionDTO,
|
|
558
|
+
locale: string,
|
|
559
|
+
user: { id?: number; name?: string | null },
|
|
560
|
+
) {
|
|
561
|
+
const person = await this.ensurePersonAccessible(id, locale);
|
|
562
|
+
const interaction = this.buildInteractionRecord(data, user);
|
|
563
|
+
|
|
564
|
+
await this.prismaService.$transaction(async (tx) => {
|
|
565
|
+
const currentInteractions = await this.loadInteractionsFromTx(tx, person.id);
|
|
566
|
+
const nextInteractions = this.sortInteractions([
|
|
567
|
+
interaction,
|
|
568
|
+
...currentInteractions,
|
|
569
|
+
]);
|
|
570
|
+
|
|
571
|
+
await this.upsertMetadataValue(
|
|
572
|
+
tx,
|
|
573
|
+
person.id,
|
|
574
|
+
INTERACTIONS_METADATA_KEY,
|
|
575
|
+
nextInteractions,
|
|
576
|
+
);
|
|
577
|
+
await this.upsertMetadataValue(
|
|
578
|
+
tx,
|
|
579
|
+
person.id,
|
|
580
|
+
LAST_INTERACTION_AT_METADATA_KEY,
|
|
581
|
+
interaction.created_at,
|
|
582
|
+
);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
return interaction;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async scheduleFollowup(
|
|
589
|
+
id: number,
|
|
590
|
+
data: CreateFollowupDTO,
|
|
591
|
+
locale: string,
|
|
592
|
+
user: { id?: number; name?: string | null },
|
|
593
|
+
) {
|
|
594
|
+
const person = await this.ensurePersonAccessible(id, locale);
|
|
595
|
+
const normalizedNextActionAt = this.normalizeDateTimeOrNull(data.next_action_at);
|
|
596
|
+
|
|
597
|
+
if (!normalizedNextActionAt) {
|
|
598
|
+
throw new BadRequestException(
|
|
599
|
+
getLocaleText(
|
|
600
|
+
'validation.dateMustBeString',
|
|
601
|
+
locale,
|
|
602
|
+
'next_action_at must be a valid datetime.',
|
|
603
|
+
),
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
await this.prismaService.$transaction(async (tx) => {
|
|
608
|
+
await this.upsertMetadataValue(
|
|
609
|
+
tx,
|
|
610
|
+
person.id,
|
|
611
|
+
NEXT_ACTION_AT_METADATA_KEY,
|
|
612
|
+
normalizedNextActionAt,
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
const notes = this.normalizeTextOrNull(data.notes);
|
|
616
|
+
if (notes) {
|
|
617
|
+
const interaction = this.buildInteractionRecord(
|
|
618
|
+
{
|
|
619
|
+
type: PersonInteractionTypeDTO.NOTE,
|
|
620
|
+
notes,
|
|
621
|
+
},
|
|
622
|
+
user,
|
|
623
|
+
);
|
|
624
|
+
const currentInteractions = await this.loadInteractionsFromTx(tx, person.id);
|
|
625
|
+
const nextInteractions = this.sortInteractions([
|
|
626
|
+
interaction,
|
|
627
|
+
...currentInteractions,
|
|
628
|
+
]);
|
|
629
|
+
|
|
630
|
+
await this.upsertMetadataValue(
|
|
631
|
+
tx,
|
|
632
|
+
person.id,
|
|
633
|
+
INTERACTIONS_METADATA_KEY,
|
|
634
|
+
nextInteractions,
|
|
635
|
+
);
|
|
636
|
+
await this.upsertMetadataValue(
|
|
637
|
+
tx,
|
|
638
|
+
person.id,
|
|
639
|
+
LAST_INTERACTION_AT_METADATA_KEY,
|
|
640
|
+
interaction.created_at,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
success: true,
|
|
647
|
+
next_action_at: normalizedNextActionAt,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async updateLifecycleStage(
|
|
652
|
+
id: number,
|
|
653
|
+
data: UpdateLifecycleStageDTO,
|
|
654
|
+
locale: string,
|
|
655
|
+
) {
|
|
656
|
+
const person = await this.ensurePersonAccessible(id, locale);
|
|
657
|
+
|
|
658
|
+
await this.prismaService.$transaction(async (tx) => {
|
|
659
|
+
await this.upsertMetadataValue(
|
|
660
|
+
tx,
|
|
661
|
+
person.id,
|
|
662
|
+
LIFECYCLE_STAGE_METADATA_KEY,
|
|
663
|
+
data.lifecycle_stage,
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
success: true,
|
|
669
|
+
lifecycle_stage: data.lifecycle_stage,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
236
673
|
async create(data: CreateDTO, locale: string) {
|
|
237
674
|
const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
|
|
238
675
|
|
|
@@ -257,6 +694,13 @@ export class PersonService {
|
|
|
257
694
|
employer_company_id: allowCompanyRegistration
|
|
258
695
|
? data.employer_company_id ?? null
|
|
259
696
|
: null,
|
|
697
|
+
owner_user_id: data.owner_user_id,
|
|
698
|
+
source: data.source,
|
|
699
|
+
lifecycle_stage: data.lifecycle_stage,
|
|
700
|
+
next_action_at: data.next_action_at,
|
|
701
|
+
score: data.score,
|
|
702
|
+
deal_value: data.deal_value,
|
|
703
|
+
tags: data.tags,
|
|
260
704
|
});
|
|
261
705
|
|
|
262
706
|
return person;
|
|
@@ -315,6 +759,13 @@ export class PersonService {
|
|
|
315
759
|
? undefined
|
|
316
760
|
: data.employer_company_id
|
|
317
761
|
: null,
|
|
762
|
+
owner_user_id: data.owner_user_id,
|
|
763
|
+
source: data.source,
|
|
764
|
+
lifecycle_stage: data.lifecycle_stage,
|
|
765
|
+
next_action_at: data.next_action_at,
|
|
766
|
+
score: data.score,
|
|
767
|
+
deal_value: data.deal_value,
|
|
768
|
+
tags: data.tags,
|
|
318
769
|
});
|
|
319
770
|
|
|
320
771
|
await this.syncContacts(tx, id, incomingContacts);
|
|
@@ -487,6 +938,89 @@ export class PersonService {
|
|
|
487
938
|
}
|
|
488
939
|
}
|
|
489
940
|
|
|
941
|
+
const ownerUserIdByPersonId = new Map<number, number>();
|
|
942
|
+
const sourceByPersonId = new Map<number, string | null>();
|
|
943
|
+
const lifecycleStageByPersonId = new Map<number, string | null>();
|
|
944
|
+
const nextActionAtByPersonId = new Map<number, string | null>();
|
|
945
|
+
const scoreByPersonId = new Map<number, number | null>();
|
|
946
|
+
const dealValueByPersonId = new Map<number, number | null>();
|
|
947
|
+
const tagsByPersonId = new Map<number, string[]>();
|
|
948
|
+
const lastInteractionAtByPersonId = new Map<number, string | null>();
|
|
949
|
+
const interactionCountByPersonId = new Map<number, number>();
|
|
950
|
+
|
|
951
|
+
for (const person of people) {
|
|
952
|
+
const metadata = this.metadataArrayToMap(person.person_metadata);
|
|
953
|
+
const ownerUserId = this.metadataToNumber(metadata.get(OWNER_USER_METADATA_KEY));
|
|
954
|
+
if (ownerUserId > 0) {
|
|
955
|
+
ownerUserIdByPersonId.set(person.id, ownerUserId);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const source = this.metadataToString(metadata.get(SOURCE_METADATA_KEY));
|
|
959
|
+
if (source) {
|
|
960
|
+
sourceByPersonId.set(person.id, source);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const lifecycleStage = this.metadataToString(
|
|
964
|
+
metadata.get(LIFECYCLE_STAGE_METADATA_KEY),
|
|
965
|
+
);
|
|
966
|
+
if (lifecycleStage) {
|
|
967
|
+
lifecycleStageByPersonId.set(person.id, lifecycleStage);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const nextActionAt = this.metadataToIsoString(
|
|
971
|
+
metadata.get(NEXT_ACTION_AT_METADATA_KEY),
|
|
972
|
+
);
|
|
973
|
+
if (nextActionAt) {
|
|
974
|
+
nextActionAtByPersonId.set(person.id, nextActionAt);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const score = this.metadataToNumber(metadata.get(SCORE_METADATA_KEY));
|
|
978
|
+
if (score != null) {
|
|
979
|
+
scoreByPersonId.set(person.id, score);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const dealValue = this.metadataToNumber(metadata.get(DEAL_VALUE_METADATA_KEY));
|
|
983
|
+
if (dealValue != null) {
|
|
984
|
+
dealValueByPersonId.set(person.id, dealValue);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const tags = this.metadataToStringArray(metadata.get(TAGS_METADATA_KEY));
|
|
988
|
+
if (tags.length > 0) {
|
|
989
|
+
tagsByPersonId.set(person.id, tags);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const lastInteractionAt = this.metadataToIsoString(
|
|
993
|
+
metadata.get(LAST_INTERACTION_AT_METADATA_KEY),
|
|
994
|
+
);
|
|
995
|
+
if (lastInteractionAt) {
|
|
996
|
+
lastInteractionAtByPersonId.set(person.id, lastInteractionAt);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
interactionCountByPersonId.set(
|
|
1000
|
+
person.id,
|
|
1001
|
+
this.metadataToInteractions(metadata.get(INTERACTIONS_METADATA_KEY)).length,
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const ownerUserIds = Array.from(new Set(ownerUserIdByPersonId.values()));
|
|
1006
|
+
const ownerUsers =
|
|
1007
|
+
ownerUserIds.length > 0
|
|
1008
|
+
? await this.prismaService.user.findMany({
|
|
1009
|
+
where: {
|
|
1010
|
+
id: {
|
|
1011
|
+
in: ownerUserIds,
|
|
1012
|
+
},
|
|
1013
|
+
},
|
|
1014
|
+
select: {
|
|
1015
|
+
id: true,
|
|
1016
|
+
name: true,
|
|
1017
|
+
},
|
|
1018
|
+
})
|
|
1019
|
+
: [];
|
|
1020
|
+
const ownerUserById = new Map(
|
|
1021
|
+
ownerUsers.map((item) => [item.id, { id: item.id, name: item.name || `#${item.id}` }]),
|
|
1022
|
+
);
|
|
1023
|
+
|
|
490
1024
|
const employerCompanyIds = Array.from(
|
|
491
1025
|
new Set(Array.from(employerCompanyIdByPersonId.values())),
|
|
492
1026
|
);
|
|
@@ -533,6 +1067,7 @@ export class PersonService {
|
|
|
533
1067
|
employerCompanyById.get(employerCompanyId),
|
|
534
1068
|
)
|
|
535
1069
|
: null;
|
|
1070
|
+
const ownerUserId = ownerUserIdByPersonId.get(person.id) ?? null;
|
|
536
1071
|
|
|
537
1072
|
return {
|
|
538
1073
|
id: person.id,
|
|
@@ -551,6 +1086,17 @@ export class PersonService {
|
|
|
551
1086
|
headquarter_id: companyData?.headquarter_id ?? null,
|
|
552
1087
|
branch_ids: branchesByHeadquarterId.get(person.id) || [],
|
|
553
1088
|
notes: this.metadataToString(metadata.get(NOTES_METADATA_KEY)),
|
|
1089
|
+
owner_user_id: ownerUserId,
|
|
1090
|
+
owner_user: ownerUserId ? ownerUserById.get(ownerUserId) ?? null : null,
|
|
1091
|
+
source: sourceByPersonId.get(person.id) ?? null,
|
|
1092
|
+
lifecycle_stage: lifecycleStageByPersonId.get(person.id) ?? 'new',
|
|
1093
|
+
next_action_at: nextActionAtByPersonId.get(person.id) ?? null,
|
|
1094
|
+
score: scoreByPersonId.get(person.id) ?? 0,
|
|
1095
|
+
deal_value: dealValueByPersonId.get(person.id) ?? 0,
|
|
1096
|
+
tags: tagsByPersonId.get(person.id) ?? [],
|
|
1097
|
+
last_interaction_at:
|
|
1098
|
+
lastInteractionAtByPersonId.get(person.id) ?? person.created_at?.toISOString?.() ?? null,
|
|
1099
|
+
interaction_count: interactionCountByPersonId.get(person.id) ?? 0,
|
|
554
1100
|
employer_company_id: allowCompanyRegistration ? employerCompanyId : null,
|
|
555
1101
|
employer_company: allowCompanyRegistration ? employerCompany : null,
|
|
556
1102
|
contact: person.contact || [],
|
|
@@ -584,6 +1130,72 @@ export class PersonService {
|
|
|
584
1130
|
return null;
|
|
585
1131
|
}
|
|
586
1132
|
|
|
1133
|
+
private metadataToNumber(value: unknown): number | null {
|
|
1134
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
1135
|
+
return value;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
if (typeof value === 'string') {
|
|
1139
|
+
const parsed = Number(value);
|
|
1140
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
return null;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
private metadataToIsoString(value: unknown): string | null {
|
|
1147
|
+
if (!value) return null;
|
|
1148
|
+
const parsed = new Date(String(value));
|
|
1149
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
private metadataToStringArray(value: unknown): string[] {
|
|
1153
|
+
if (Array.isArray(value)) {
|
|
1154
|
+
return value
|
|
1155
|
+
.map((item) => this.normalizeTextOrNull(item))
|
|
1156
|
+
.filter((item): item is string => Boolean(item));
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
if (typeof value === 'string') {
|
|
1160
|
+
return value
|
|
1161
|
+
.split(',')
|
|
1162
|
+
.map((item) => item.trim())
|
|
1163
|
+
.filter(Boolean);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
return [];
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
private metadataToInteractions(value: unknown): PersonInteractionRecord[] {
|
|
1170
|
+
if (!Array.isArray(value)) {
|
|
1171
|
+
return [];
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
return this.sortInteractions(
|
|
1175
|
+
value
|
|
1176
|
+
.map((item) => {
|
|
1177
|
+
if (!item || typeof item !== 'object') return null;
|
|
1178
|
+
const interaction = item as Record<string, unknown>;
|
|
1179
|
+
const createdAt = this.metadataToIsoString(interaction.created_at);
|
|
1180
|
+
const type = this.normalizeTextOrNull(interaction.type);
|
|
1181
|
+
|
|
1182
|
+
if (!createdAt || !type) {
|
|
1183
|
+
return null;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
return {
|
|
1187
|
+
id: this.coerceNumber(interaction.id) || Date.now(),
|
|
1188
|
+
type: type as CreateInteractionDTO['type'],
|
|
1189
|
+
notes: this.normalizeTextOrNull(interaction.notes),
|
|
1190
|
+
created_at: createdAt,
|
|
1191
|
+
user_id: this.coerceNumber(interaction.user_id) || null,
|
|
1192
|
+
user_name: this.normalizeTextOrNull(interaction.user_name),
|
|
1193
|
+
};
|
|
1194
|
+
})
|
|
1195
|
+
.filter((item): item is PersonInteractionRecord => item != null),
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
587
1199
|
private async syncPersonSubtypeData(
|
|
588
1200
|
tx: any,
|
|
589
1201
|
personId: number,
|
|
@@ -752,7 +1364,19 @@ export class PersonService {
|
|
|
752
1364
|
private async syncPersonMetadata(
|
|
753
1365
|
tx: any,
|
|
754
1366
|
personId: number,
|
|
755
|
-
data: {
|
|
1367
|
+
data: {
|
|
1368
|
+
notes?: unknown;
|
|
1369
|
+
employer_company_id?: unknown;
|
|
1370
|
+
owner_user_id?: unknown;
|
|
1371
|
+
source?: unknown;
|
|
1372
|
+
lifecycle_stage?: unknown;
|
|
1373
|
+
next_action_at?: unknown;
|
|
1374
|
+
score?: unknown;
|
|
1375
|
+
deal_value?: unknown;
|
|
1376
|
+
tags?: unknown;
|
|
1377
|
+
last_interaction_at?: unknown;
|
|
1378
|
+
interactions?: unknown;
|
|
1379
|
+
},
|
|
756
1380
|
) {
|
|
757
1381
|
if (data.notes !== undefined) {
|
|
758
1382
|
await this.upsertMetadataValue(tx, personId, NOTES_METADATA_KEY, data.notes);
|
|
@@ -766,6 +1390,77 @@ export class PersonService {
|
|
|
766
1390
|
data.employer_company_id,
|
|
767
1391
|
);
|
|
768
1392
|
}
|
|
1393
|
+
|
|
1394
|
+
if (data.owner_user_id !== undefined) {
|
|
1395
|
+
await this.upsertMetadataValue(
|
|
1396
|
+
tx,
|
|
1397
|
+
personId,
|
|
1398
|
+
OWNER_USER_METADATA_KEY,
|
|
1399
|
+
data.owner_user_id,
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
if (data.source !== undefined) {
|
|
1404
|
+
await this.upsertMetadataValue(tx, personId, SOURCE_METADATA_KEY, data.source);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
if (data.lifecycle_stage !== undefined) {
|
|
1408
|
+
await this.upsertMetadataValue(
|
|
1409
|
+
tx,
|
|
1410
|
+
personId,
|
|
1411
|
+
LIFECYCLE_STAGE_METADATA_KEY,
|
|
1412
|
+
data.lifecycle_stage,
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
if (data.next_action_at !== undefined) {
|
|
1417
|
+
await this.upsertMetadataValue(
|
|
1418
|
+
tx,
|
|
1419
|
+
personId,
|
|
1420
|
+
NEXT_ACTION_AT_METADATA_KEY,
|
|
1421
|
+
this.normalizeDateTimeOrNull(data.next_action_at),
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
if (data.score !== undefined) {
|
|
1426
|
+
await this.upsertMetadataValue(tx, personId, SCORE_METADATA_KEY, data.score);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
if (data.deal_value !== undefined) {
|
|
1430
|
+
await this.upsertMetadataValue(
|
|
1431
|
+
tx,
|
|
1432
|
+
personId,
|
|
1433
|
+
DEAL_VALUE_METADATA_KEY,
|
|
1434
|
+
data.deal_value,
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (data.tags !== undefined) {
|
|
1439
|
+
const normalizedTags = Array.isArray(data.tags)
|
|
1440
|
+
? data.tags
|
|
1441
|
+
.map((item) => this.normalizeTextOrNull(item))
|
|
1442
|
+
.filter((item): item is string => Boolean(item))
|
|
1443
|
+
: null;
|
|
1444
|
+
await this.upsertMetadataValue(tx, personId, TAGS_METADATA_KEY, normalizedTags);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
if (data.last_interaction_at !== undefined) {
|
|
1448
|
+
await this.upsertMetadataValue(
|
|
1449
|
+
tx,
|
|
1450
|
+
personId,
|
|
1451
|
+
LAST_INTERACTION_AT_METADATA_KEY,
|
|
1452
|
+
this.normalizeDateTimeOrNull(data.last_interaction_at),
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
if (data.interactions !== undefined) {
|
|
1457
|
+
await this.upsertMetadataValue(
|
|
1458
|
+
tx,
|
|
1459
|
+
personId,
|
|
1460
|
+
INTERACTIONS_METADATA_KEY,
|
|
1461
|
+
data.interactions,
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
769
1464
|
}
|
|
770
1465
|
|
|
771
1466
|
private async upsertMetadataValue(
|
|
@@ -808,7 +1503,7 @@ export class PersonService {
|
|
|
808
1503
|
});
|
|
809
1504
|
}
|
|
810
1505
|
|
|
811
|
-
private normalizeMetadataValue(value: unknown):
|
|
1506
|
+
private normalizeMetadataValue(value: unknown): Prisma.InputJsonValue | null {
|
|
812
1507
|
if (value == null) return null;
|
|
813
1508
|
|
|
814
1509
|
if (typeof value === 'string') {
|
|
@@ -824,6 +1519,14 @@ export class PersonService {
|
|
|
824
1519
|
return value;
|
|
825
1520
|
}
|
|
826
1521
|
|
|
1522
|
+
if (Array.isArray(value) || typeof value === 'object') {
|
|
1523
|
+
try {
|
|
1524
|
+
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
|
1525
|
+
} catch {
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
827
1530
|
return null;
|
|
828
1531
|
}
|
|
829
1532
|
|
|
@@ -927,6 +1630,310 @@ export class PersonService {
|
|
|
927
1630
|
}
|
|
928
1631
|
}
|
|
929
1632
|
|
|
1633
|
+
private addDuplicateReason(
|
|
1634
|
+
reasonsByPersonId: Map<number, Set<DuplicateReason>>,
|
|
1635
|
+
personId: number,
|
|
1636
|
+
reason: DuplicateReason,
|
|
1637
|
+
) {
|
|
1638
|
+
if (!reasonsByPersonId.has(personId)) {
|
|
1639
|
+
reasonsByPersonId.set(personId, new Set<DuplicateReason>());
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
reasonsByPersonId.get(personId)?.add(reason);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
private async mergeContacts(tx: any, sourcePersonId: number, targetPersonId: number) {
|
|
1646
|
+
const [sourceContacts, targetContacts] = await Promise.all([
|
|
1647
|
+
tx.contact.findMany({ where: { person_id: sourcePersonId } }),
|
|
1648
|
+
tx.contact.findMany({ where: { person_id: targetPersonId } }),
|
|
1649
|
+
]);
|
|
1650
|
+
|
|
1651
|
+
const existingByKey = new Set(
|
|
1652
|
+
targetContacts.map((contact: any) =>
|
|
1653
|
+
this.buildContactDedupKey(contact.contact_type_id, contact.value),
|
|
1654
|
+
),
|
|
1655
|
+
);
|
|
1656
|
+
const hasPrimaryType = new Set(
|
|
1657
|
+
targetContacts
|
|
1658
|
+
.filter((contact: any) => contact.is_primary)
|
|
1659
|
+
.map((contact: any) => Number(contact.contact_type_id)),
|
|
1660
|
+
);
|
|
1661
|
+
|
|
1662
|
+
for (const sourceContact of sourceContacts) {
|
|
1663
|
+
const dedupKey = this.buildContactDedupKey(
|
|
1664
|
+
sourceContact.contact_type_id,
|
|
1665
|
+
sourceContact.value,
|
|
1666
|
+
);
|
|
1667
|
+
|
|
1668
|
+
if (existingByKey.has(dedupKey)) {
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
const keepPrimary =
|
|
1673
|
+
sourceContact.is_primary &&
|
|
1674
|
+
!hasPrimaryType.has(Number(sourceContact.contact_type_id));
|
|
1675
|
+
|
|
1676
|
+
await tx.contact.create({
|
|
1677
|
+
data: {
|
|
1678
|
+
person_id: targetPersonId,
|
|
1679
|
+
contact_type_id: sourceContact.contact_type_id,
|
|
1680
|
+
value: sourceContact.value,
|
|
1681
|
+
is_primary: keepPrimary,
|
|
1682
|
+
},
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
existingByKey.add(dedupKey);
|
|
1686
|
+
|
|
1687
|
+
if (keepPrimary) {
|
|
1688
|
+
hasPrimaryType.add(Number(sourceContact.contact_type_id));
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
await tx.contact.deleteMany({
|
|
1693
|
+
where: {
|
|
1694
|
+
person_id: sourcePersonId,
|
|
1695
|
+
},
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
private async mergeDocuments(tx: any, sourcePersonId: number, targetPersonId: number) {
|
|
1700
|
+
const [sourceDocuments, targetDocuments] = await Promise.all([
|
|
1701
|
+
tx.document.findMany({ where: { person_id: sourcePersonId } }),
|
|
1702
|
+
tx.document.findMany({ where: { person_id: targetPersonId } }),
|
|
1703
|
+
]);
|
|
1704
|
+
|
|
1705
|
+
const existingByKey = new Set(
|
|
1706
|
+
targetDocuments.map((document: any) =>
|
|
1707
|
+
this.buildDocumentDedupKey(document.document_type_id, document.value),
|
|
1708
|
+
),
|
|
1709
|
+
);
|
|
1710
|
+
|
|
1711
|
+
for (const sourceDocument of sourceDocuments) {
|
|
1712
|
+
const dedupKey = this.buildDocumentDedupKey(
|
|
1713
|
+
sourceDocument.document_type_id,
|
|
1714
|
+
sourceDocument.value,
|
|
1715
|
+
);
|
|
1716
|
+
|
|
1717
|
+
if (existingByKey.has(dedupKey)) {
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
await tx.document.create({
|
|
1722
|
+
data: {
|
|
1723
|
+
person_id: targetPersonId,
|
|
1724
|
+
document_type_id: sourceDocument.document_type_id,
|
|
1725
|
+
value: sourceDocument.value,
|
|
1726
|
+
},
|
|
1727
|
+
});
|
|
1728
|
+
|
|
1729
|
+
existingByKey.add(dedupKey);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
await tx.document.deleteMany({
|
|
1733
|
+
where: {
|
|
1734
|
+
person_id: sourcePersonId,
|
|
1735
|
+
},
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
private async mergeAddresses(tx: any, sourcePersonId: number, targetPersonId: number) {
|
|
1740
|
+
const [sourceLinks, targetLinks] = await Promise.all([
|
|
1741
|
+
tx.person_address.findMany({
|
|
1742
|
+
where: { person_id: sourcePersonId },
|
|
1743
|
+
include: { address: true },
|
|
1744
|
+
}),
|
|
1745
|
+
tx.person_address.findMany({
|
|
1746
|
+
where: { person_id: targetPersonId },
|
|
1747
|
+
include: { address: true },
|
|
1748
|
+
}),
|
|
1749
|
+
]);
|
|
1750
|
+
|
|
1751
|
+
const targetByKey = new Map<string, any>();
|
|
1752
|
+
const hasPrimaryType = new Set<string>();
|
|
1753
|
+
|
|
1754
|
+
for (const link of targetLinks) {
|
|
1755
|
+
if (!link.address) continue;
|
|
1756
|
+
|
|
1757
|
+
targetByKey.set(this.buildAddressDedupKey(link.address), link.address);
|
|
1758
|
+
if (link.address.is_primary && link.address.address_type) {
|
|
1759
|
+
hasPrimaryType.add(String(link.address.address_type));
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
for (const sourceLink of sourceLinks) {
|
|
1764
|
+
if (!sourceLink.address) {
|
|
1765
|
+
await tx.person_address.delete({ where: { id: sourceLink.id } });
|
|
1766
|
+
continue;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
const sourceAddress = sourceLink.address;
|
|
1770
|
+
const dedupKey = this.buildAddressDedupKey(sourceAddress);
|
|
1771
|
+
const existingTargetAddress = targetByKey.get(dedupKey);
|
|
1772
|
+
|
|
1773
|
+
if (existingTargetAddress) {
|
|
1774
|
+
if (
|
|
1775
|
+
sourceAddress.is_primary &&
|
|
1776
|
+
sourceAddress.address_type &&
|
|
1777
|
+
!hasPrimaryType.has(String(sourceAddress.address_type))
|
|
1778
|
+
) {
|
|
1779
|
+
await tx.address.update({
|
|
1780
|
+
where: { id: existingTargetAddress.id },
|
|
1781
|
+
data: {
|
|
1782
|
+
is_primary: true,
|
|
1783
|
+
},
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
hasPrimaryType.add(String(sourceAddress.address_type));
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
await tx.person_address.delete({ where: { id: sourceLink.id } });
|
|
1790
|
+
await tx.address.delete({ where: { id: sourceAddress.id } });
|
|
1791
|
+
continue;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
const keepPrimary =
|
|
1795
|
+
sourceAddress.is_primary &&
|
|
1796
|
+
sourceAddress.address_type &&
|
|
1797
|
+
!hasPrimaryType.has(String(sourceAddress.address_type));
|
|
1798
|
+
|
|
1799
|
+
await tx.person_address.update({
|
|
1800
|
+
where: { id: sourceLink.id },
|
|
1801
|
+
data: {
|
|
1802
|
+
person_id: targetPersonId,
|
|
1803
|
+
},
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
if (Boolean(sourceAddress.is_primary) !== Boolean(keepPrimary)) {
|
|
1807
|
+
await tx.address.update({
|
|
1808
|
+
where: { id: sourceAddress.id },
|
|
1809
|
+
data: {
|
|
1810
|
+
is_primary: Boolean(keepPrimary),
|
|
1811
|
+
},
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
targetByKey.set(dedupKey, sourceAddress);
|
|
1816
|
+
if (keepPrimary && sourceAddress.address_type) {
|
|
1817
|
+
hasPrimaryType.add(String(sourceAddress.address_type));
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
private async mergeMetadata(tx: any, sourcePersonId: number, targetPersonId: number) {
|
|
1823
|
+
const [sourceMetadata, targetMetadata] = await Promise.all([
|
|
1824
|
+
tx.person_metadata.findMany({ where: { person_id: sourcePersonId } }),
|
|
1825
|
+
tx.person_metadata.findMany({ where: { person_id: targetPersonId } }),
|
|
1826
|
+
]);
|
|
1827
|
+
|
|
1828
|
+
const targetByKey = new Map<string, any>();
|
|
1829
|
+
|
|
1830
|
+
for (const metadata of targetMetadata) {
|
|
1831
|
+
if (!targetByKey.has(metadata.key)) {
|
|
1832
|
+
targetByKey.set(metadata.key, metadata);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
for (const sourceItem of sourceMetadata) {
|
|
1837
|
+
if (sourceItem.key === MERGED_INTO_PERSON_METADATA_KEY) {
|
|
1838
|
+
await tx.person_metadata.delete({ where: { id: sourceItem.id } });
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
const targetItem = targetByKey.get(sourceItem.key);
|
|
1843
|
+
if (!targetItem) {
|
|
1844
|
+
await tx.person_metadata.update({
|
|
1845
|
+
where: { id: sourceItem.id },
|
|
1846
|
+
data: {
|
|
1847
|
+
person_id: targetPersonId,
|
|
1848
|
+
},
|
|
1849
|
+
});
|
|
1850
|
+
targetByKey.set(sourceItem.key, sourceItem);
|
|
1851
|
+
continue;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
if (sourceItem.key === NOTES_METADATA_KEY) {
|
|
1855
|
+
const targetNotes = this.metadataToString(targetItem.value);
|
|
1856
|
+
const sourceNotes = this.metadataToString(sourceItem.value);
|
|
1857
|
+
|
|
1858
|
+
if (sourceNotes && sourceNotes !== targetNotes) {
|
|
1859
|
+
const nextNotes = [targetNotes, sourceNotes].filter(Boolean).join('\n\n');
|
|
1860
|
+
await tx.person_metadata.update({
|
|
1861
|
+
where: { id: targetItem.id },
|
|
1862
|
+
data: {
|
|
1863
|
+
value: nextNotes,
|
|
1864
|
+
},
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
} else if (sourceItem.key === TAGS_METADATA_KEY) {
|
|
1868
|
+
const mergedTags = Array.from(
|
|
1869
|
+
new Set([
|
|
1870
|
+
...this.metadataToStringArray(targetItem.value),
|
|
1871
|
+
...this.metadataToStringArray(sourceItem.value),
|
|
1872
|
+
]),
|
|
1873
|
+
);
|
|
1874
|
+
|
|
1875
|
+
await tx.person_metadata.update({
|
|
1876
|
+
where: { id: targetItem.id },
|
|
1877
|
+
data: {
|
|
1878
|
+
value: mergedTags,
|
|
1879
|
+
},
|
|
1880
|
+
});
|
|
1881
|
+
} else if (sourceItem.key === INTERACTIONS_METADATA_KEY) {
|
|
1882
|
+
const mergedInteractions = this.sortInteractions([
|
|
1883
|
+
...this.metadataToInteractions(targetItem.value),
|
|
1884
|
+
...this.metadataToInteractions(sourceItem.value),
|
|
1885
|
+
]);
|
|
1886
|
+
|
|
1887
|
+
await tx.person_metadata.update({
|
|
1888
|
+
where: { id: targetItem.id },
|
|
1889
|
+
data: {
|
|
1890
|
+
value: mergedInteractions,
|
|
1891
|
+
},
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
await tx.person_metadata.delete({ where: { id: sourceItem.id } });
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
private buildContactDedupKey(contactTypeId: number, value: string) {
|
|
1900
|
+
return `${contactTypeId}::${this.normalizeText(value).toLowerCase()}`;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
private buildDocumentDedupKey(documentTypeId: number, value: string) {
|
|
1904
|
+
const normalizedDigits = this.normalizeDigits(value || '');
|
|
1905
|
+
const normalizedValue = normalizedDigits || this.normalizeText(value).toLowerCase();
|
|
1906
|
+
return `${documentTypeId}::${normalizedValue}`;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
private buildAddressDedupKey(address: {
|
|
1910
|
+
line1?: string | null;
|
|
1911
|
+
line2?: string | null;
|
|
1912
|
+
city?: string | null;
|
|
1913
|
+
state?: string | null;
|
|
1914
|
+
country_code?: string | null;
|
|
1915
|
+
postal_code?: string | null;
|
|
1916
|
+
}) {
|
|
1917
|
+
const parts = [
|
|
1918
|
+
this.normalizeText(address.line1 || ''),
|
|
1919
|
+
this.normalizeText(address.line2 || ''),
|
|
1920
|
+
this.normalizeText(address.city || ''),
|
|
1921
|
+
this.normalizeText(address.state || ''),
|
|
1922
|
+
this.normalizeText(address.country_code || ''),
|
|
1923
|
+
this.normalizeDigits(address.postal_code || ''),
|
|
1924
|
+
];
|
|
1925
|
+
|
|
1926
|
+
return parts.join('::').toLowerCase();
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
private normalizeEmail(value?: string | null) {
|
|
1930
|
+
return this.normalizeText(value || '').toLowerCase();
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
private normalizeText(value: string) {
|
|
1934
|
+
return String(value || '').trim();
|
|
1935
|
+
}
|
|
1936
|
+
|
|
930
1937
|
private validateSinglePrimaryPerType(
|
|
931
1938
|
items: any[],
|
|
932
1939
|
groupKey: string,
|
|
@@ -965,11 +1972,93 @@ export class PersonService {
|
|
|
965
1972
|
return trimmed.length > 0 ? trimmed : null;
|
|
966
1973
|
}
|
|
967
1974
|
|
|
1975
|
+
private normalizeDateTimeOrNull(value: unknown): string | null {
|
|
1976
|
+
if (!value) return null;
|
|
1977
|
+
const date = value instanceof Date ? value : new Date(String(value));
|
|
1978
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
1979
|
+
}
|
|
1980
|
+
|
|
968
1981
|
private coerceNumber(value: unknown): number {
|
|
969
1982
|
const parsed = Number(value);
|
|
970
1983
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
971
1984
|
}
|
|
972
1985
|
|
|
1986
|
+
private resolveRequestedOwnerUserId(
|
|
1987
|
+
ownerUserId: string | number | undefined,
|
|
1988
|
+
mine: string | boolean | undefined,
|
|
1989
|
+
currentUserId?: number,
|
|
1990
|
+
) {
|
|
1991
|
+
if (
|
|
1992
|
+
mine === true ||
|
|
1993
|
+
mine === 'true' ||
|
|
1994
|
+
mine === '1'
|
|
1995
|
+
) {
|
|
1996
|
+
return Number(currentUserId) > 0 ? Number(currentUserId) : 0;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
return this.coerceNumber(ownerUserId);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
private async ensurePersonAccessible(id: number, locale: string) {
|
|
2003
|
+
const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
|
|
2004
|
+
const person = await this.prismaService.person.findUnique({
|
|
2005
|
+
where: { id },
|
|
2006
|
+
select: {
|
|
2007
|
+
id: true,
|
|
2008
|
+
type: true,
|
|
2009
|
+
},
|
|
2010
|
+
});
|
|
2011
|
+
|
|
2012
|
+
if (!person) {
|
|
2013
|
+
throw new BadRequestException(
|
|
2014
|
+
getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
|
|
2015
|
+
);
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
if (!allowCompanyRegistration && person.type === 'company') {
|
|
2019
|
+
throw new NotFoundException(
|
|
2020
|
+
getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
return person;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
private async loadInteractionsFromTx(tx: any, personId: number) {
|
|
2028
|
+
const metadata = await tx.person_metadata.findFirst({
|
|
2029
|
+
where: {
|
|
2030
|
+
person_id: personId,
|
|
2031
|
+
key: INTERACTIONS_METADATA_KEY,
|
|
2032
|
+
},
|
|
2033
|
+
select: {
|
|
2034
|
+
value: true,
|
|
2035
|
+
},
|
|
2036
|
+
});
|
|
2037
|
+
|
|
2038
|
+
return this.metadataToInteractions(metadata?.value);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
private buildInteractionRecord(
|
|
2042
|
+
data: Pick<CreateInteractionDTO, 'type' | 'notes'>,
|
|
2043
|
+
user: { id?: number; name?: string | null },
|
|
2044
|
+
): PersonInteractionRecord {
|
|
2045
|
+
return {
|
|
2046
|
+
id: Date.now() + Math.floor(Math.random() * 1000),
|
|
2047
|
+
type: data.type,
|
|
2048
|
+
notes: this.normalizeTextOrNull(data.notes),
|
|
2049
|
+
created_at: new Date().toISOString(),
|
|
2050
|
+
user_id: Number(user?.id) > 0 ? Number(user.id) : null,
|
|
2051
|
+
user_name: this.normalizeTextOrNull(user?.name) || null,
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
private sortInteractions(interactions: PersonInteractionRecord[]) {
|
|
2056
|
+
return [...interactions].sort(
|
|
2057
|
+
(left, right) =>
|
|
2058
|
+
new Date(right.created_at).getTime() - new Date(left.created_at).getTime(),
|
|
2059
|
+
);
|
|
2060
|
+
}
|
|
2061
|
+
|
|
973
2062
|
private async buildSearchFilters(search: string) {
|
|
974
2063
|
const normalizedDigits = this.normalizeDigits(search);
|
|
975
2064
|
const filters: any[] = [];
|