@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.
Files changed (70) hide show
  1. package/README.md +2 -0
  2. package/dist/person/dto/create-followup.dto.d.ts +5 -0
  3. package/dist/person/dto/create-followup.dto.d.ts.map +1 -0
  4. package/dist/person/dto/create-followup.dto.js +31 -0
  5. package/dist/person/dto/create-followup.dto.js.map +1 -0
  6. package/dist/person/dto/create-interaction.dto.d.ts +12 -0
  7. package/dist/person/dto/create-interaction.dto.d.ts.map +1 -0
  8. package/dist/person/dto/create-interaction.dto.js +39 -0
  9. package/dist/person/dto/create-interaction.dto.js.map +1 -0
  10. package/dist/person/dto/create.dto.d.ts +24 -0
  11. package/dist/person/dto/create.dto.d.ts.map +1 -1
  12. package/dist/person/dto/create.dto.js +56 -1
  13. package/dist/person/dto/create.dto.js.map +1 -1
  14. package/dist/person/dto/duplicates-query.dto.d.ts +8 -0
  15. package/dist/person/dto/duplicates-query.dto.d.ts.map +1 -0
  16. package/dist/person/dto/duplicates-query.dto.js +45 -0
  17. package/dist/person/dto/duplicates-query.dto.js.map +1 -0
  18. package/dist/person/dto/merge.dto.d.ts +6 -0
  19. package/dist/person/dto/merge.dto.d.ts.map +1 -0
  20. package/dist/person/dto/merge.dto.js +35 -0
  21. package/dist/person/dto/merge.dto.js.map +1 -0
  22. package/dist/person/dto/update-lifecycle-stage.dto.d.ts +13 -0
  23. package/dist/person/dto/update-lifecycle-stage.dto.d.ts.map +1 -0
  24. package/dist/person/dto/update-lifecycle-stage.dto.js +34 -0
  25. package/dist/person/dto/update-lifecycle-stage.dto.js.map +1 -0
  26. package/dist/person/dto/update.dto.d.ts +8 -1
  27. package/dist/person/dto/update.dto.d.ts.map +1 -1
  28. package/dist/person/dto/update.dto.js +36 -0
  29. package/dist/person/dto/update.dto.js.map +1 -1
  30. package/dist/person/person.controller.d.ts +57 -1
  31. package/dist/person/person.controller.d.ts.map +1 -1
  32. package/dist/person/person.controller.js +85 -3
  33. package/dist/person/person.controller.js.map +1 -1
  34. package/dist/person/person.service.d.ts +79 -0
  35. package/dist/person/person.service.d.ts.map +1 -1
  36. package/dist/person/person.service.js +730 -9
  37. package/dist/person/person.service.js.map +1 -1
  38. package/hedhog/data/route.yaml +18 -0
  39. package/hedhog/frontend/app/_components/crm-coming-soon.tsx.ejs +110 -110
  40. package/hedhog/frontend/app/_components/crm-nav.tsx.ejs +73 -73
  41. package/hedhog/frontend/app/_lib/crm-mocks.ts.ejs +498 -256
  42. package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +81 -81
  43. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +477 -0
  44. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +62 -0
  45. package/hedhog/frontend/app/accounts/page.tsx.ejs +886 -15
  46. package/hedhog/frontend/app/activities/page.tsx.ejs +15 -15
  47. package/hedhog/frontend/app/contact-type/page.tsx.ejs +105 -91
  48. package/hedhog/frontend/app/dashboard/page.tsx.ejs +506 -573
  49. package/hedhog/frontend/app/document-type/page.tsx.ejs +105 -91
  50. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +15 -15
  51. package/hedhog/frontend/app/page.tsx.ejs +5 -5
  52. package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +1440 -1103
  53. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +4 -3
  54. package/hedhog/frontend/app/person/_components/person-types.ts.ejs +14 -0
  55. package/hedhog/frontend/app/person/page.tsx.ejs +108 -190
  56. package/hedhog/frontend/app/pipeline/_components/lead-detail-sheet.tsx.ejs +599 -0
  57. package/hedhog/frontend/app/pipeline/page.tsx.ejs +1074 -299
  58. package/hedhog/frontend/app/reports/page.tsx.ejs +15 -15
  59. package/hedhog/frontend/messages/en.json +107 -0
  60. package/hedhog/frontend/messages/pt.json +106 -0
  61. package/package.json +6 -6
  62. package/src/person/dto/create-followup.dto.ts +15 -0
  63. package/src/person/dto/create-interaction.dto.ts +23 -0
  64. package/src/person/dto/create.dto.ts +50 -0
  65. package/src/person/dto/duplicates-query.dto.ts +34 -0
  66. package/src/person/dto/merge.dto.ts +15 -0
  67. package/src/person/dto/update-lifecycle-stage.dto.ts +19 -0
  68. package/src/person/dto/update.dto.ts +31 -1
  69. package/src/person/person.controller.ts +63 -2
  70. 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
- BadRequestException,
8
- Inject,
9
- Injectable,
10
- NotFoundException,
11
- forwardRef,
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: { notes?: unknown; employer_company_id?: unknown },
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): string | number | boolean | null {
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[] = [];