@hed-hog/contact 0.0.270 → 0.0.275

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.
@@ -1,1071 +1,1137 @@
1
- import { DeleteDTO } from '@hed-hog/api';
2
- import { getLocaleText } from '@hed-hog/api-locale';
3
- import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
4
- import { Prisma, PrismaService } from '@hed-hog/api-prisma';
5
- import { FileService, SettingService } from '@hed-hog/core';
6
- import {
7
- BadRequestException,
8
- Inject,
9
- Injectable,
10
- NotFoundException,
11
- forwardRef,
12
- } from '@nestjs/common';
13
- import { CreateDTO } from './dto/create.dto';
14
-
15
- const CONTACT_ALLOW_COMPANY_REGISTRATION_SETTING =
16
- 'contact-allow-company-registration';
17
- const EMPLOYER_COMPANY_METADATA_KEY = 'employer_company_id';
18
- const NOTES_METADATA_KEY = 'notes';
19
-
20
- @Injectable()
21
- export class PersonService {
22
- constructor(
23
- @Inject(forwardRef(() => PrismaService))
24
- private readonly prismaService: PrismaService,
25
- @Inject(forwardRef(() => PaginationService))
26
- private readonly paginationService: PaginationService,
27
- @Inject(forwardRef(() => FileService))
28
- private readonly fileService: FileService,
29
- @Inject(forwardRef(() => SettingService))
30
- private readonly settingService: SettingService,
31
- ) {}
32
-
33
- async getStats() {
34
- const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
35
-
36
- if (!allowCompanyRegistration) {
37
- const [individual, active, inactive] = await Promise.all([
38
- this.prismaService.person.count({ where: { type: 'individual' } }),
39
- this.prismaService.person.count({
40
- where: { type: 'individual', status: 'active' },
41
- }),
42
- this.prismaService.person.count({
43
- where: { type: 'individual', status: 'inactive' },
44
- }),
45
- ]);
46
-
47
- return {
48
- total: individual,
49
- individual,
50
- company: 0,
51
- active,
52
- inactive,
53
- };
54
- }
55
-
56
- const [total, individual, company, active, inactive] = await Promise.all([
57
- this.prismaService.person.count(),
58
- this.prismaService.person.count({ where: { type: 'individual' } }),
59
- this.prismaService.person.count({ where: { type: 'company' } }),
60
- this.prismaService.person.count({ where: { status: 'active' } }),
61
- this.prismaService.person.count({ where: { status: 'inactive' } }),
62
- ]);
63
-
64
- return {
65
- total,
66
- individual,
67
- company,
68
- active,
69
- inactive,
70
- };
71
- }
72
-
73
- async list(paginationParams: PaginationDTO & { type: string; status: string }) {
74
- const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
75
- const where: any = {};
76
- const search = paginationParams.search?.trim();
77
- const requestedType = paginationParams.type;
78
-
79
- if (!allowCompanyRegistration && requestedType === 'company') {
80
- return this.paginationService.paginate(
81
- this.prismaService.person,
82
- paginationParams,
83
- {
84
- where: {
85
- id: -1,
86
- },
87
- },
88
- );
89
- }
90
-
91
- if (!allowCompanyRegistration) {
92
- where.type = 'individual';
93
- } else if (requestedType && requestedType !== 'all') {
94
- where.type = requestedType;
95
- }
96
-
97
- if (paginationParams.status && paginationParams.status !== 'all') {
98
- where.status = paginationParams.status;
99
- }
100
-
101
- if (search) {
102
- where.OR = await this.buildSearchFilters(search);
103
- }
104
-
105
- const result = await this.paginationService.paginate(
106
- this.prismaService.person,
107
- paginationParams,
108
- {
109
- where,
110
- include: {
111
- person_address: {
112
- include: {
113
- address: true,
114
- },
115
- },
116
- contact: true,
117
- document: true,
118
- person_metadata: true,
119
- },
120
- },
121
- );
122
-
123
- const enriched = await this.enrichPeople(
124
- result.data as any[],
125
- allowCompanyRegistration,
126
- );
127
-
128
- return {
129
- ...result,
130
- data: enriched,
131
- };
132
- }
133
-
134
- async get(locale: string, id: number) {
135
- const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
136
-
137
- const person = await this.prismaService.person.findUnique({
138
- where: { id },
139
- include: {
140
- person_address: {
141
- include: {
142
- address: true,
143
- },
144
- },
145
- contact: true,
146
- document: true,
147
- person_metadata: true,
148
- },
149
- });
150
-
151
- if (!person) {
152
- throw new BadRequestException(
153
- getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
154
- );
155
- }
156
-
157
- if (!allowCompanyRegistration && person.type === 'company') {
158
- throw new NotFoundException(
159
- getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
160
- );
161
- }
162
-
163
- const [normalized] = await this.enrichPeople(
164
- [person as any],
165
- allowCompanyRegistration,
166
- );
167
- return normalized;
168
- }
169
-
170
- async create(data: CreateDTO, locale: string) {
171
- const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
172
-
173
- await this.ensureCompanyRegistrationAllowed({
174
- nextType: data.type,
175
- locale,
176
- });
177
-
178
- return this.prismaService.$transaction(async (tx) => {
179
- const person = await tx.person.create({
180
- data: {
181
- name: data.name,
182
- type: data.type,
183
- status: data.status,
184
- avatar_id: data.avatar_id ?? null,
185
- },
186
- });
187
-
188
- await this.syncPersonSubtypeData(tx, person.id, null, data, locale);
189
- await this.syncPersonMetadata(tx, person.id, {
190
- notes: data.notes,
191
- employer_company_id: allowCompanyRegistration
192
- ? data.employer_company_id ?? null
193
- : null,
194
- });
195
-
196
- return person;
197
- });
198
- }
199
-
200
- async update(id: number, data: any, locale: string) {
201
- const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
202
- const person = await this.prismaService.person.findUnique({ where: { id } });
203
- if (!person) {
204
- throw new BadRequestException(
205
- getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
206
- );
207
- }
208
-
209
- if (!allowCompanyRegistration && person.type === 'company') {
210
- throw new NotFoundException(
211
- getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
212
- );
213
- }
214
-
215
- await this.ensureCompanyRegistrationAllowed({
216
- currentType: person.type,
217
- nextType: data.type,
218
- locale,
219
- });
220
-
221
- const incomingContacts = Array.isArray(data.contacts) ? data.contacts : [];
222
- const incomingAddresses = Array.isArray(data.addresses) ? data.addresses : [];
223
- const incomingDocuments = Array.isArray(data.documents) ? data.documents : [];
224
-
225
- this.validateSinglePrimaryPerType(incomingContacts, 'contact_type_id', locale, 'moreThanOnePrimaryContact', 'More than one contact of the same type cannot be marked as primary.');
226
- this.validateSinglePrimaryPerType(incomingAddresses, 'address_type', locale, 'moreThanOnePrimaryAddress', 'More than one address of the same type cannot be marked as primary.');
227
- this.validateSinglePrimaryPerType(incomingDocuments, 'document_type_id', locale, 'moreThanOnePrimaryDocument', 'More than one document of the same type cannot be marked as primary.');
228
-
229
- return this.prismaService
230
- .$transaction(async (tx) => {
231
- const nextType = data.type ?? person.type;
232
-
233
- await tx.person.update({
234
- where: { id },
235
- data: {
236
- name: data.name ?? person.name,
237
- type: nextType,
238
- status: data.status ?? person.status,
239
- avatar_id:
240
- data.avatar_id === undefined ? person.avatar_id : data.avatar_id,
241
- },
242
- });
243
-
244
- await this.syncPersonSubtypeData(tx, id, person.type, data, locale);
245
- await this.syncPersonMetadata(tx, id, {
246
- notes: data.notes,
247
- employer_company_id: allowCompanyRegistration
248
- ? data.employer_company_id === undefined
249
- ? undefined
250
- : data.employer_company_id
251
- : null,
252
- });
253
-
254
- await this.syncContacts(tx, id, incomingContacts);
255
- await this.syncAddresses(tx, id, incomingAddresses, locale);
256
- await this.syncDocuments(tx, id, incomingDocuments);
257
-
258
- return { success: true };
259
- })
260
- .then(async (result) => {
261
- await this.cleanupReplacedAvatar(locale, person.avatar_id, data.avatar_id);
262
- return result;
263
- });
264
- }
265
-
266
- async delete({ ids }: DeleteDTO, locale: string) {
267
- if (ids == undefined || ids == null) {
268
- throw new BadRequestException(
269
- getLocaleText(
270
- 'deleteItemsRequired',
271
- locale,
272
- 'You must select at least one item to delete.',
273
- ),
274
- );
275
- }
276
-
277
- const existing = await this.prismaService.person.findMany({
278
- where: { id: { in: ids } },
279
- select: { id: true },
280
- });
281
- const existingIds = existing.map((p) => p.id);
282
- const missingIds = ids.filter((itemId) => !existingIds.includes(itemId));
283
- if (missingIds.length > 0) {
284
- throw new BadRequestException(
285
- getLocaleText(
286
- 'personNotFound',
287
- locale,
288
- `Person(s) with ID(s) ${missingIds.join(', ')} not found.`,
289
- ),
290
- );
291
- }
292
-
293
- const personAddresses = await this.prismaService.person_address.findMany({
294
- where: {
295
- person_id: { in: ids },
296
- },
297
- select: {
298
- id: true,
299
- address_id: true,
300
- },
301
- });
302
-
303
- const addressIds = personAddresses.map((item) => item.address_id);
304
-
305
- return this.prismaService.$transaction([
306
- this.prismaService.contact.deleteMany({ where: { person_id: { in: ids } } }),
307
- this.prismaService.document.deleteMany({ where: { person_id: { in: ids } } }),
308
- this.prismaService.person_individual_relation.deleteMany({
309
- where: {
310
- OR: [
311
- { person_individual_id: { in: ids } },
312
- { related_person_individual_id: { in: ids } },
313
- ],
314
- },
315
- }),
316
- this.prismaService.person_company.deleteMany({
317
- where: {
318
- OR: [{ id: { in: ids } }, { headquarter_id: { in: ids } }],
319
- },
320
- }),
321
- this.prismaService.person_individual.deleteMany({
322
- where: { id: { in: ids } },
323
- }),
324
- this.prismaService.person_metadata.deleteMany({
325
- where: { person_id: { in: ids } },
326
- }),
327
- this.prismaService.person_address.deleteMany({
328
- where: { person_id: { in: ids } },
329
- }),
330
- this.prismaService.address.deleteMany({
331
- where: {
332
- id: {
333
- in: addressIds,
334
- },
335
- },
336
- }),
337
- this.prismaService.person.deleteMany({
338
- where: {
339
- id: {
340
- in: ids,
341
- },
342
- },
343
- }),
344
- ]);
345
- }
346
-
347
- async openPublicAvatar(locale: string, fileId: number, res: any) {
348
- const personWithAvatar = await this.prismaService.person.findFirst({
349
- where: {
350
- avatar_id: fileId,
351
- },
352
- select: {
353
- id: true,
354
- },
355
- });
356
-
357
- if (!personWithAvatar) {
358
- throw new NotFoundException(
359
- getLocaleText('personNotFound', locale, 'Person not found'),
360
- );
361
- }
362
-
363
- const { file, buffer } = await this.fileService.getBuffer(fileId);
364
-
365
- res.set({
366
- 'Content-Type': file.file_mimetype.name,
367
- 'Content-Length': buffer.length,
368
- 'Cache-Control': 'public, max-age=3600',
369
- });
370
-
371
- res.send(buffer);
372
- }
373
-
374
- private async enrichPeople(people: any[], allowCompanyRegistration = true) {
375
- if (people.length === 0) {
376
- return [];
377
- }
378
-
379
- const personIds = people.map((person) => person.id);
380
-
381
- const [companies, individuals, companyBranches, employerMetadata] =
382
- await Promise.all([
383
- this.prismaService.person_company.findMany({
384
- where: { id: { in: personIds } },
385
- }),
386
- this.prismaService.person_individual.findMany({
387
- where: { id: { in: personIds } },
388
- }),
389
- this.prismaService.person_company.findMany({
390
- where: { headquarter_id: { in: personIds } },
391
- select: { id: true, headquarter_id: true },
392
- }),
393
- this.prismaService.person_metadata.findMany({
394
- where: {
395
- person_id: { in: personIds },
396
- key: EMPLOYER_COMPANY_METADATA_KEY,
397
- },
398
- select: {
399
- person_id: true,
400
- value: true,
401
- },
402
- }).then((metadata) => (allowCompanyRegistration ? metadata : [])),
403
- ]);
404
-
405
- const companyById = new Map(companies.map((item) => [item.id, item]));
406
- const individualById = new Map(individuals.map((item) => [item.id, item]));
407
-
408
- const branchesByHeadquarterId = new Map<number, number[]>();
409
- for (const branch of companyBranches) {
410
- if (!branch.headquarter_id) continue;
411
- const current = branchesByHeadquarterId.get(branch.headquarter_id) || [];
412
- current.push(branch.id);
413
- branchesByHeadquarterId.set(branch.headquarter_id, current);
414
- }
415
-
416
- const employerCompanyIdByPersonId = new Map<number, number>();
417
- for (const meta of employerMetadata) {
418
- const employerId = this.coerceNumber(meta.value);
419
- if (employerId > 0) {
420
- employerCompanyIdByPersonId.set(meta.person_id, employerId);
421
- }
422
- }
423
-
424
- const employerCompanyIds = Array.from(
425
- new Set(Array.from(employerCompanyIdByPersonId.values())),
426
- );
427
- const employerCompanies =
428
- employerCompanyIds.length > 0
429
- ? await this.prismaService.person.findMany({
430
- where: {
431
- id: { in: employerCompanyIds },
432
- type: 'company',
433
- },
434
- })
435
- : [];
436
- const employerCompanyRows =
437
- employerCompanyIds.length > 0
438
- ? await this.prismaService.person_company.findMany({
439
- where: {
440
- id: { in: employerCompanyIds },
441
- },
442
- })
443
- : [];
444
- const employerCompanyRowById = new Map(
445
- employerCompanyRows.map((item) => [item.id, item]),
446
- );
447
- const employerCompanyById = new Map(
448
- employerCompanies.map((item: any) => [
449
- item.id,
450
- {
451
- ...item,
452
- person_company: employerCompanyRowById.get(item.id) ?? null,
453
- },
454
- ]),
455
- );
456
-
457
- return people.map((person) => {
458
- const metadata = this.metadataArrayToMap(person.person_metadata);
459
- const companyData = companyById.get(person.id);
460
- const individualData = individualById.get(person.id);
461
- const employerCompanyId = allowCompanyRegistration
462
- ? employerCompanyIdByPersonId.get(person.id) ?? null
463
- : null;
464
- const employerCompany =
465
- allowCompanyRegistration && employerCompanyId != null
466
- ? this.normalizeRelationPersonSummary(
467
- employerCompanyById.get(employerCompanyId),
468
- )
469
- : null;
470
-
471
- return {
472
- id: person.id,
473
- name: person.name,
474
- type: person.type,
475
- status: person.status,
476
- avatar_id: person.avatar_id ?? null,
477
- created_at: person.created_at,
478
- updated_at: person.updated_at,
479
- birth_date: individualData?.birth_date ?? null,
480
- gender: individualData?.gender ?? null,
481
- job_title: individualData?.job_title ?? null,
482
- trade_name: companyData?.trade_name ?? null,
483
- foundation_date: companyData?.foundation_date ?? null,
484
- legal_nature: companyData?.legal_nature ?? null,
485
- headquarter_id: companyData?.headquarter_id ?? null,
486
- branch_ids: branchesByHeadquarterId.get(person.id) || [],
487
- notes: this.metadataToString(metadata.get(NOTES_METADATA_KEY)),
488
- employer_company_id: allowCompanyRegistration ? employerCompanyId : null,
489
- employer_company: allowCompanyRegistration ? employerCompany : null,
490
- contact: person.contact || [],
491
- document: person.document || [],
492
- address: Array.isArray(person.person_address)
493
- ? person.person_address
494
- .map((item: any) => item.address)
495
- .filter((address: any) => address != null)
496
- : [],
497
- };
498
- });
499
- }
500
-
501
- private metadataArrayToMap(metadata: any[]) {
502
- const map = new Map<string, unknown>();
503
-
504
- for (const item of Array.isArray(metadata) ? metadata : []) {
505
- map.set(item.key, item.value);
506
- }
507
-
508
- return map;
509
- }
510
-
511
- private metadataToString(value: unknown): string | null {
512
- if (value == null) return null;
513
- if (typeof value === 'string') return value;
514
- if (typeof value === 'number' || typeof value === 'boolean') {
515
- return String(value);
516
- }
517
-
518
- return null;
519
- }
520
-
521
- private async syncPersonSubtypeData(
522
- tx: any,
523
- personId: number,
524
- currentType: string | null,
525
- data: any,
526
- locale: string,
527
- ) {
528
- const targetType = data.type ?? currentType;
529
- if (!targetType) return;
530
-
531
- if (targetType === 'individual') {
532
- await tx.person_company.deleteMany({ where: { id: personId } });
533
-
534
- await tx.person_individual.upsert({
535
- where: { id: personId },
536
- create: {
537
- id: personId,
538
- birth_date: this.parseDateOrNull(data.birth_date),
539
- gender: data.gender ?? null,
540
- job_title: this.normalizeTextOrNull(data.job_title),
541
- },
542
- update: {
543
- birth_date:
544
- data.birth_date === undefined
545
- ? undefined
546
- : this.parseDateOrNull(data.birth_date),
547
- gender: data.gender === undefined ? undefined : data.gender ?? null,
548
- job_title:
549
- data.job_title === undefined
550
- ? undefined
551
- : this.normalizeTextOrNull(data.job_title),
552
- },
553
- });
554
- return;
555
- }
556
-
557
- await tx.person_individual_relation.deleteMany({
558
- where: {
559
- OR: [
560
- { person_individual_id: personId },
561
- { related_person_individual_id: personId },
562
- ],
563
- },
564
- });
565
- await tx.person_individual.deleteMany({ where: { id: personId } });
566
-
567
- const normalizedHeadquarterId = this.coerceNumber(data.headquarter_id);
568
-
569
- if (normalizedHeadquarterId > 0) {
570
- if (normalizedHeadquarterId === personId) {
571
- throw new BadRequestException(
572
- getLocaleText(
573
- 'companyRelationSelfReference',
574
- locale,
575
- 'A company cannot be its own headquarter.',
576
- ),
577
- );
578
- }
579
-
580
- const headquarter = await tx.person.findFirst({
581
- where: { id: normalizedHeadquarterId, type: 'company' },
582
- select: { id: true },
583
- });
584
-
585
- if (!headquarter) {
586
- throw new BadRequestException(
587
- getLocaleText(
588
- 'companyRelationInvalidTarget',
589
- locale,
590
- 'Only company records can be linked as headquarters or branches.',
591
- ),
592
- );
593
- }
594
- }
595
-
596
- await tx.person_company.upsert({
597
- where: { id: personId },
598
- create: {
599
- id: personId,
600
- trade_name: this.normalizeTextOrNull(data.trade_name),
601
- foundation_date: this.parseDateOrNull(data.foundation_date),
602
- legal_nature: this.normalizeTextOrNull(data.legal_nature),
603
- headquarter_id: normalizedHeadquarterId > 0 ? normalizedHeadquarterId : null,
604
- },
605
- update: {
606
- trade_name:
607
- data.trade_name === undefined
608
- ? undefined
609
- : this.normalizeTextOrNull(data.trade_name),
610
- foundation_date:
611
- data.foundation_date === undefined
612
- ? undefined
613
- : this.parseDateOrNull(data.foundation_date),
614
- legal_nature:
615
- data.legal_nature === undefined
616
- ? undefined
617
- : this.normalizeTextOrNull(data.legal_nature),
618
- headquarter_id:
619
- data.headquarter_id === undefined
620
- ? undefined
621
- : normalizedHeadquarterId > 0
622
- ? normalizedHeadquarterId
623
- : null,
624
- },
625
- });
626
-
627
- if (Object.prototype.hasOwnProperty.call(data, 'branch_ids')) {
628
- await this.syncCompanyBranches(tx, personId, data.branch_ids, locale);
629
- }
630
- }
631
-
632
- private async syncCompanyBranches(
633
- tx: any,
634
- personId: number,
635
- branchIds: number[] | undefined,
636
- locale: string,
637
- ) {
638
- const normalizedBranchIds = Array.from(
639
- new Set(
640
- (Array.isArray(branchIds) ? branchIds : [])
641
- .map((value) => Number(value))
642
- .filter((value) => Number.isInteger(value) && value > 0 && value !== personId),
643
- ),
644
- );
645
-
646
- if (normalizedBranchIds.length > 0) {
647
- const companies = await tx.person.findMany({
648
- where: {
649
- id: { in: normalizedBranchIds },
650
- type: 'company',
651
- },
652
- select: { id: true },
653
- });
654
-
655
- if (companies.length !== normalizedBranchIds.length) {
656
- throw new BadRequestException(
657
- getLocaleText(
658
- 'companyRelationInvalidTarget',
659
- locale,
660
- 'Only company records can be linked as headquarters or branches.',
661
- ),
662
- );
663
- }
664
-
665
- await tx.person_company.updateMany({
666
- where: {
667
- id: { in: normalizedBranchIds },
668
- },
669
- data: {
670
- headquarter_id: personId,
671
- },
672
- });
673
- }
674
-
675
- await tx.person_company.updateMany({
676
- where: {
677
- headquarter_id: personId,
678
- id: { notIn: normalizedBranchIds.length > 0 ? normalizedBranchIds : [-1] },
679
- },
680
- data: {
681
- headquarter_id: null,
682
- },
683
- });
684
- }
685
-
686
- private async syncPersonMetadata(
687
- tx: any,
688
- personId: number,
689
- data: { notes?: unknown; employer_company_id?: unknown },
690
- ) {
691
- if (data.notes !== undefined) {
692
- await this.upsertMetadataValue(tx, personId, NOTES_METADATA_KEY, data.notes);
693
- }
694
-
695
- if (data.employer_company_id !== undefined) {
696
- await this.upsertMetadataValue(
697
- tx,
698
- personId,
699
- EMPLOYER_COMPANY_METADATA_KEY,
700
- data.employer_company_id,
701
- );
702
- }
703
- }
704
-
705
- private async upsertMetadataValue(
706
- tx: any,
707
- personId: number,
708
- key: string,
709
- value: unknown,
710
- ) {
711
- const existing = await tx.person_metadata.findFirst({
712
- where: {
713
- person_id: personId,
714
- key,
715
- },
716
- select: { id: true },
717
- });
718
-
719
- const normalizedValue = this.normalizeMetadataValue(value);
720
-
721
- if (normalizedValue == null) {
722
- if (existing) {
723
- await tx.person_metadata.delete({ where: { id: existing.id } });
724
- }
725
- return;
726
- }
727
-
728
- if (existing) {
729
- await tx.person_metadata.update({
730
- where: { id: existing.id },
731
- data: { value: normalizedValue },
732
- });
733
- return;
734
- }
735
-
736
- await tx.person_metadata.create({
737
- data: {
738
- person_id: personId,
739
- key,
740
- value: normalizedValue,
741
- },
742
- });
743
- }
744
-
745
- private normalizeMetadataValue(value: unknown): string | number | boolean | null {
746
- if (value == null) return null;
747
-
748
- if (typeof value === 'string') {
749
- const trimmed = value.trim();
750
- return trimmed.length > 0 ? trimmed : null;
751
- }
752
-
753
- if (typeof value === 'number') {
754
- return Number.isFinite(value) ? value : null;
755
- }
756
-
757
- if (typeof value === 'boolean') {
758
- return value;
759
- }
760
-
761
- return null;
762
- }
763
-
764
- private async syncContacts(tx: any, personId: number, incomingContacts: any[]) {
765
- const existingContacts = await tx.contact.findMany({ where: { person_id: personId } });
766
-
767
- for (const contact of incomingContacts) {
768
- if (contact.id) {
769
- await tx.contact.update({ where: { id: contact.id }, data: contact });
770
- } else {
771
- await tx.contact.create({ data: { ...contact, person_id: personId } });
772
- }
773
- }
774
-
775
- for (const old of existingContacts) {
776
- if (!incomingContacts.find((item: any) => item.id === old.id)) {
777
- await tx.contact.delete({ where: { id: old.id } });
778
- }
779
- }
780
- }
781
-
782
- private async syncAddresses(
783
- tx: any,
784
- personId: number,
785
- incomingAddresses: any[],
786
- locale: string,
787
- ) {
788
- const existingAddresses = await tx.person_address.findMany({
789
- where: { person_id: personId },
790
- include: { address: true },
791
- });
792
-
793
- for (const address of incomingAddresses) {
794
- const addressData = {
795
- line1: address.line1,
796
- line2: address.line2 || '',
797
- city: address.city,
798
- state: address.state,
799
- country_code: address.country_code || 'BRA',
800
- postal_code: address.postal_code || '',
801
- is_primary: address.is_primary,
802
- address_type: address.address_type,
803
- };
804
-
805
- if (address.id) {
806
- const existingLink = existingAddresses.find(
807
- (item: any) => item.address_id === address.id,
808
- );
809
-
810
- if (!existingLink) {
811
- throw new BadRequestException(
812
- getLocaleText(
813
- 'personNotFound',
814
- locale,
815
- `Address with ID ${address.id} not found for person ${personId}.`,
816
- ),
817
- );
818
- }
819
-
820
- await tx.address.update({
821
- where: { id: address.id },
822
- data: addressData,
823
- });
824
- } else {
825
- const createdAddress = await tx.address.create({
826
- data: addressData,
827
- });
828
-
829
- await tx.person_address.create({
830
- data: {
831
- person_id: personId,
832
- address_id: createdAddress.id,
833
- },
834
- });
835
- }
836
- }
837
-
838
- for (const old of existingAddresses) {
839
- if (!incomingAddresses.find((item: any) => item.id === old.address_id)) {
840
- await tx.person_address.delete({ where: { id: old.id } });
841
- await tx.address.delete({ where: { id: old.address_id } });
842
- }
843
- }
844
- }
845
-
846
- private async syncDocuments(tx: any, personId: number, incomingDocuments: any[]) {
847
- const existingDocuments = await tx.document.findMany({ where: { person_id: personId } });
848
-
849
- for (const document of incomingDocuments) {
850
- if (document.id) {
851
- await tx.document.update({ where: { id: document.id }, data: document });
852
- } else {
853
- await tx.document.create({ data: { ...document, person_id: personId } });
854
- }
855
- }
856
-
857
- for (const old of existingDocuments) {
858
- if (!incomingDocuments.find((item: any) => item.id === old.id)) {
859
- await tx.document.delete({ where: { id: old.id } });
860
- }
861
- }
862
- }
863
-
864
- private validateSinglePrimaryPerType(
865
- items: any[],
866
- groupKey: string,
867
- locale: string,
868
- localeKey: string,
869
- fallbackMessage: string,
870
- ) {
871
- const map = new Map<string, number>();
872
-
873
- for (const item of items) {
874
- if (!item.is_primary) continue;
875
-
876
- const key = String(item[groupKey]);
877
- map.set(key, (map.get(key) || 0) + 1);
878
- }
879
-
880
- for (const count of map.values()) {
881
- if (count > 1) {
882
- throw new BadRequestException(getLocaleText(localeKey, locale, fallbackMessage));
883
- }
884
- }
885
- }
886
-
887
- private parseDateOrNull(value: unknown): Date | null {
888
- if (!value) return null;
889
- const date = value instanceof Date ? value : new Date(String(value));
890
- return Number.isNaN(date.getTime()) ? null : date;
891
- }
892
-
893
- private normalizeTextOrNull(value: unknown): string | null {
894
- if (typeof value !== 'string') {
895
- return value == null ? null : String(value);
896
- }
897
-
898
- const trimmed = value.trim();
899
- return trimmed.length > 0 ? trimmed : null;
900
- }
901
-
902
- private coerceNumber(value: unknown): number {
903
- const parsed = Number(value);
904
- return Number.isFinite(parsed) ? parsed : 0;
905
- }
906
-
907
- private async buildSearchFilters(search: string) {
908
- const normalizedDigits = this.normalizeDigits(search);
909
- const filters: any[] = [];
910
-
911
- if (!Number.isNaN(+search)) {
912
- filters.push({ id: { equals: +search } });
913
- }
914
-
915
- filters.push(
916
- { name: { contains: search, mode: 'insensitive' } },
917
- {
918
- person_metadata: {
919
- some: {
920
- value: { path: [], equals: search } as any,
921
- },
922
- },
923
- },
924
- {
925
- contact: {
926
- some: {
927
- value: { contains: search, mode: 'insensitive' },
928
- },
929
- },
930
- },
931
- {
932
- document: {
933
- some: {
934
- value: { contains: search, mode: 'insensitive' },
935
- },
936
- },
937
- },
938
- {
939
- person_address: {
940
- some: {
941
- address: {
942
- OR: [
943
- { line1: { contains: search, mode: 'insensitive' } },
944
- { line2: { contains: search, mode: 'insensitive' } },
945
- { city: { contains: search, mode: 'insensitive' } },
946
- { state: { contains: search, mode: 'insensitive' } },
947
- { postal_code: { contains: search, mode: 'insensitive' } },
948
- { country_code: { contains: search, mode: 'insensitive' } },
949
- ],
950
- },
951
- },
952
- },
953
- },
954
- );
955
-
956
- if (normalizedDigits.length > 0) {
957
- const normalizedMatches =
958
- await this.findPersonIdsByNormalizedDigits(normalizedDigits);
959
-
960
- if (normalizedMatches.length > 0) {
961
- filters.push({
962
- id: {
963
- in: normalizedMatches,
964
- },
965
- });
966
- }
967
- }
968
-
969
- return filters;
970
- }
971
-
972
- private normalizeDigits(value: string) {
973
- return value.replace(/\D/g, '');
974
- }
975
-
976
- private async findPersonIdsByNormalizedDigits(normalizedDigits: string) {
977
- const likeValue = `%${normalizedDigits}%`;
978
-
979
- const rows = await this.prismaService.$queryRaw<Array<{ id: number }>>(
980
- Prisma.sql`
981
- SELECT DISTINCT p.id
982
- FROM person p
983
- LEFT JOIN contact c ON c.person_id = p.id
984
- LEFT JOIN document d ON d.person_id = p.id
985
- LEFT JOIN person_address pa ON pa.person_id = p.id
986
- LEFT JOIN address a ON a.id = pa.address_id
987
- WHERE regexp_replace(COALESCE(c.value, ''), '[^0-9]+', '', 'g') ILIKE ${likeValue}
988
- OR regexp_replace(COALESCE(d.value, ''), '[^0-9]+', '', 'g') ILIKE ${likeValue}
989
- OR regexp_replace(COALESCE(a.postal_code, ''), '[^0-9]+', '', 'g') ILIKE ${likeValue}
990
- `,
991
- );
992
-
993
- return rows.map((row) => row.id);
994
- }
995
-
996
- private async ensureCompanyRegistrationAllowed({
997
- currentType,
998
- nextType,
999
- locale,
1000
- }: {
1001
- currentType?: string;
1002
- nextType?: string;
1003
- locale: string;
1004
- }) {
1005
- const targetType = nextType ?? currentType;
1006
- const isNewCompanyRegistration =
1007
- targetType === 'company' && currentType !== 'company';
1008
-
1009
- if (!isNewCompanyRegistration) {
1010
- return;
1011
- }
1012
-
1013
- const settings = await this.settingService.getSettingValues(
1014
- CONTACT_ALLOW_COMPANY_REGISTRATION_SETTING,
1015
- );
1016
-
1017
- if (settings[CONTACT_ALLOW_COMPANY_REGISTRATION_SETTING] !== false) {
1018
- return;
1019
- }
1020
-
1021
- throw new BadRequestException(
1022
- getLocaleText(
1023
- 'companyRegistrationDisabled',
1024
- locale,
1025
- 'Company registration is disabled. Only individual records can be created.',
1026
- ),
1027
- );
1028
- }
1029
-
1030
- private async isCompanyRegistrationAllowed() {
1031
- const settings = await this.settingService.getSettingValues(
1032
- CONTACT_ALLOW_COMPANY_REGISTRATION_SETTING,
1033
- );
1034
-
1035
- return settings[CONTACT_ALLOW_COMPANY_REGISTRATION_SETTING] !== false;
1036
- }
1037
-
1038
- private async cleanupReplacedAvatar(
1039
- locale: string,
1040
- previousAvatarId?: number | null,
1041
- nextAvatarId?: number | null,
1042
- ) {
1043
- if (!previousAvatarId || previousAvatarId === nextAvatarId) {
1044
- return;
1045
- }
1046
-
1047
- try {
1048
- await this.fileService.delete(locale, { ids: [previousAvatarId] });
1049
- } catch {
1050
- // Keep the person update successful even if avatar cleanup fails.
1051
- }
1052
- }
1053
-
1054
- private normalizeRelationPersonSummary(person: any) {
1055
- if (!person) return null;
1056
-
1057
- const tradeName =
1058
- person.person_company?.trade_name != null
1059
- ? String(person.person_company.trade_name)
1060
- : null;
1061
-
1062
- return {
1063
- id: person.id,
1064
- name: person.name,
1065
- type: person.type,
1066
- status: person.status,
1067
- avatar_id: person.avatar_id ?? null,
1068
- trade_name: tradeName,
1069
- };
1070
- }
1071
- }
1
+ import { DeleteDTO } from '@hed-hog/api';
2
+ import { getLocaleText } from '@hed-hog/api-locale';
3
+ import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
4
+ import { Prisma, PrismaService } from '@hed-hog/api-prisma';
5
+ import { FileService, SettingService } from '@hed-hog/core';
6
+ import {
7
+ BadRequestException,
8
+ Inject,
9
+ Injectable,
10
+ NotFoundException,
11
+ forwardRef,
12
+ } from '@nestjs/common';
13
+ import { CreateDTO } from './dto/create.dto';
14
+
15
+ const CONTACT_ALLOW_COMPANY_REGISTRATION_SETTING =
16
+ 'contact-allow-company-registration';
17
+ const CONTACT_OWNER_ROLE_SLUG = 'owner-contact';
18
+ const CONTACT_OWNER_ALLOWED_ROLE_SLUGS = [
19
+ 'admin',
20
+ 'admin-contact',
21
+ CONTACT_OWNER_ROLE_SLUG,
22
+ ];
23
+ type PersonActivityAction = 'created' | 'updated' | 'interaction_created';
24
+ const EMPLOYER_COMPANY_METADATA_KEY = 'employer_company_id';
25
+ const NOTES_METADATA_KEY = 'notes';
26
+
27
+ @Injectable()
28
+ export class PersonService {
29
+ constructor(
30
+ @Inject(forwardRef(() => PrismaService))
31
+ private readonly prismaService: PrismaService,
32
+ @Inject(forwardRef(() => PaginationService))
33
+ private readonly paginationService: PaginationService,
34
+ @Inject(forwardRef(() => FileService))
35
+ private readonly fileService: FileService,
36
+ @Inject(forwardRef(() => SettingService))
37
+ private readonly settingService: SettingService,
38
+ ) {}
39
+
40
+ async getStats() {
41
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
42
+
43
+ if (!allowCompanyRegistration) {
44
+ const [individual, active, inactive] = await Promise.all([
45
+ this.prismaService.person.count({ where: { type: 'individual' } }),
46
+ this.prismaService.person.count({
47
+ where: { type: 'individual', status: 'active' },
48
+ }),
49
+ this.prismaService.person.count({
50
+ where: { type: 'individual', status: 'inactive' },
51
+ }),
52
+ ]);
53
+
54
+ return {
55
+ total: individual,
56
+ individual,
57
+ company: 0,
58
+ active,
59
+ inactive,
60
+ };
61
+ }
62
+
63
+ const [total, individual, company, active, inactive] = await Promise.all([
64
+ this.prismaService.person.count(),
65
+ this.prismaService.person.count({ where: { type: 'individual' } }),
66
+ this.prismaService.person.count({ where: { type: 'company' } }),
67
+ this.prismaService.person.count({ where: { status: 'active' } }),
68
+ this.prismaService.person.count({ where: { status: 'inactive' } }),
69
+ ]);
70
+
71
+ return {
72
+ total,
73
+ individual,
74
+ company,
75
+ active,
76
+ inactive,
77
+ };
78
+ }
79
+
80
+ async getOwnerOptions(currentUserId?: number) {
81
+ const where: Prisma.userWhereInput = {
82
+ OR: [
83
+ {
84
+ role_user: {
85
+ some: {
86
+ role: {
87
+ slug: {
88
+ in: CONTACT_OWNER_ALLOWED_ROLE_SLUGS,
89
+ },
90
+ },
91
+ },
92
+ },
93
+ },
94
+ ...(Number(currentUserId) > 0
95
+ ? [
96
+ {
97
+ id: Number(currentUserId),
98
+ },
99
+ ]
100
+ : []),
101
+ ],
102
+ };
103
+
104
+ const users = await this.prismaService.user.findMany({
105
+ where,
106
+ select: {
107
+ id: true,
108
+ name: true,
109
+ },
110
+ orderBy: {
111
+ name: 'asc',
112
+ },
113
+ take: 500,
114
+ });
115
+
116
+ const byId = new Map<number, { id: number; name: string }>();
117
+ for (const user of users) {
118
+ if (!user?.id) continue;
119
+ byId.set(user.id, {
120
+ id: user.id,
121
+ name: user.name || `#${user.id}`,
122
+ });
123
+ }
124
+
125
+ return Array.from(byId.values());
126
+ }
127
+
128
+ async list(
129
+ paginationParams: PaginationDTO & {
130
+ type?: string;
131
+ status?: string;
132
+ owner_user_id?: string | number;
133
+ source?: string;
134
+ lifecycle_stage?: string;
135
+ follow_up_status?: string;
136
+ mine?: string | boolean;
137
+ },
138
+ currentUserId?: number,
139
+ ) {
140
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
141
+ const where: any = {};
142
+ const search = paginationParams.search?.trim();
143
+ const requestedType = paginationParams.type;
144
+
145
+ if (!allowCompanyRegistration && requestedType === 'company') {
146
+ return this.paginationService.paginate(
147
+ this.prismaService.person,
148
+ paginationParams,
149
+ {
150
+ where: {
151
+ id: -1,
152
+ },
153
+ },
154
+ );
155
+ }
156
+
157
+ if (!allowCompanyRegistration) {
158
+ where.type = 'individual';
159
+ } else if (requestedType && requestedType !== 'all') {
160
+ where.type = requestedType;
161
+ }
162
+
163
+ if (paginationParams.status && paginationParams.status !== 'all') {
164
+ where.status = paginationParams.status;
165
+ }
166
+
167
+ if (search) {
168
+ where.OR = await this.buildSearchFilters(search);
169
+ }
170
+
171
+ const result = await this.paginationService.paginate(
172
+ this.prismaService.person,
173
+ paginationParams,
174
+ {
175
+ where,
176
+ include: {
177
+ person_address: {
178
+ include: {
179
+ address: true,
180
+ },
181
+ },
182
+ contact: true,
183
+ document: true,
184
+ person_metadata: true,
185
+ },
186
+ },
187
+ );
188
+
189
+ const enriched = await this.enrichPeople(
190
+ result.data as any[],
191
+ allowCompanyRegistration,
192
+ );
193
+
194
+ return {
195
+ ...result,
196
+ data: enriched,
197
+ };
198
+ }
199
+
200
+ async get(locale: string, id: number) {
201
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
202
+
203
+ const person = await this.prismaService.person.findUnique({
204
+ where: { id },
205
+ include: {
206
+ person_address: {
207
+ include: {
208
+ address: true,
209
+ },
210
+ },
211
+ contact: true,
212
+ document: true,
213
+ person_metadata: true,
214
+ },
215
+ });
216
+
217
+ if (!person) {
218
+ throw new BadRequestException(
219
+ getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
220
+ );
221
+ }
222
+
223
+ if (!allowCompanyRegistration && person.type === 'company') {
224
+ throw new NotFoundException(
225
+ getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
226
+ );
227
+ }
228
+
229
+ const [normalized] = await this.enrichPeople(
230
+ [person as any],
231
+ allowCompanyRegistration,
232
+ );
233
+ return normalized;
234
+ }
235
+
236
+ async create(data: CreateDTO, locale: string) {
237
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
238
+
239
+ await this.ensureCompanyRegistrationAllowed({
240
+ nextType: data.type,
241
+ locale,
242
+ });
243
+
244
+ return this.prismaService.$transaction(async (tx) => {
245
+ const person = await tx.person.create({
246
+ data: {
247
+ name: data.name,
248
+ type: data.type,
249
+ status: data.status,
250
+ avatar_id: data.avatar_id ?? null,
251
+ },
252
+ });
253
+
254
+ await this.syncPersonSubtypeData(tx, person.id, null, data, locale);
255
+ await this.syncPersonMetadata(tx, person.id, {
256
+ notes: data.notes,
257
+ employer_company_id: allowCompanyRegistration
258
+ ? data.employer_company_id ?? null
259
+ : null,
260
+ });
261
+
262
+ return person;
263
+ });
264
+ }
265
+
266
+ async update(id: number, data: any, locale: string) {
267
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
268
+ const person = await this.prismaService.person.findUnique({ where: { id } });
269
+ if (!person) {
270
+ throw new BadRequestException(
271
+ getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
272
+ );
273
+ }
274
+
275
+ if (!allowCompanyRegistration && person.type === 'company') {
276
+ throw new NotFoundException(
277
+ getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
278
+ );
279
+ }
280
+
281
+ await this.ensureCompanyRegistrationAllowed({
282
+ currentType: person.type,
283
+ nextType: data.type,
284
+ locale,
285
+ });
286
+
287
+ const incomingContacts = Array.isArray(data.contacts) ? data.contacts : [];
288
+ const incomingAddresses = Array.isArray(data.addresses) ? data.addresses : [];
289
+ const incomingDocuments = Array.isArray(data.documents) ? data.documents : [];
290
+
291
+ this.validateSinglePrimaryPerType(incomingContacts, 'contact_type_id', locale, 'moreThanOnePrimaryContact', 'More than one contact of the same type cannot be marked as primary.');
292
+ this.validateSinglePrimaryPerType(incomingAddresses, 'address_type', locale, 'moreThanOnePrimaryAddress', 'More than one address of the same type cannot be marked as primary.');
293
+ this.validateSinglePrimaryPerType(incomingDocuments, 'document_type_id', locale, 'moreThanOnePrimaryDocument', 'More than one document of the same type cannot be marked as primary.');
294
+
295
+ return this.prismaService
296
+ .$transaction(async (tx) => {
297
+ const nextType = data.type ?? person.type;
298
+
299
+ await tx.person.update({
300
+ where: { id },
301
+ data: {
302
+ name: data.name ?? person.name,
303
+ type: nextType,
304
+ status: data.status ?? person.status,
305
+ avatar_id:
306
+ data.avatar_id === undefined ? person.avatar_id : data.avatar_id,
307
+ },
308
+ });
309
+
310
+ await this.syncPersonSubtypeData(tx, id, person.type, data, locale);
311
+ await this.syncPersonMetadata(tx, id, {
312
+ notes: data.notes,
313
+ employer_company_id: allowCompanyRegistration
314
+ ? data.employer_company_id === undefined
315
+ ? undefined
316
+ : data.employer_company_id
317
+ : null,
318
+ });
319
+
320
+ await this.syncContacts(tx, id, incomingContacts);
321
+ await this.syncAddresses(tx, id, incomingAddresses, locale);
322
+ await this.syncDocuments(tx, id, incomingDocuments);
323
+
324
+ return { success: true };
325
+ })
326
+ .then(async (result) => {
327
+ await this.cleanupReplacedAvatar(locale, person.avatar_id, data.avatar_id);
328
+ return result;
329
+ });
330
+ }
331
+
332
+ async delete({ ids }: DeleteDTO, locale: string) {
333
+ if (ids == undefined || ids == null) {
334
+ throw new BadRequestException(
335
+ getLocaleText(
336
+ 'deleteItemsRequired',
337
+ locale,
338
+ 'You must select at least one item to delete.',
339
+ ),
340
+ );
341
+ }
342
+
343
+ const existing = await this.prismaService.person.findMany({
344
+ where: { id: { in: ids } },
345
+ select: { id: true },
346
+ });
347
+ const existingIds = existing.map((p) => p.id);
348
+ const missingIds = ids.filter((itemId) => !existingIds.includes(itemId));
349
+ if (missingIds.length > 0) {
350
+ throw new BadRequestException(
351
+ getLocaleText(
352
+ 'personNotFound',
353
+ locale,
354
+ `Person(s) with ID(s) ${missingIds.join(', ')} not found.`,
355
+ ),
356
+ );
357
+ }
358
+
359
+ const personAddresses = await this.prismaService.person_address.findMany({
360
+ where: {
361
+ person_id: { in: ids },
362
+ },
363
+ select: {
364
+ id: true,
365
+ address_id: true,
366
+ },
367
+ });
368
+
369
+ const addressIds = personAddresses.map((item) => item.address_id);
370
+
371
+ return this.prismaService.$transaction([
372
+ this.prismaService.contact.deleteMany({ where: { person_id: { in: ids } } }),
373
+ this.prismaService.document.deleteMany({ where: { person_id: { in: ids } } }),
374
+ this.prismaService.person_individual_relation.deleteMany({
375
+ where: {
376
+ OR: [
377
+ { person_individual_id: { in: ids } },
378
+ { related_person_individual_id: { in: ids } },
379
+ ],
380
+ },
381
+ }),
382
+ this.prismaService.person_company.deleteMany({
383
+ where: {
384
+ OR: [{ id: { in: ids } }, { headquarter_id: { in: ids } }],
385
+ },
386
+ }),
387
+ this.prismaService.person_individual.deleteMany({
388
+ where: { id: { in: ids } },
389
+ }),
390
+ this.prismaService.person_metadata.deleteMany({
391
+ where: { person_id: { in: ids } },
392
+ }),
393
+ this.prismaService.person_address.deleteMany({
394
+ where: { person_id: { in: ids } },
395
+ }),
396
+ this.prismaService.address.deleteMany({
397
+ where: {
398
+ id: {
399
+ in: addressIds,
400
+ },
401
+ },
402
+ }),
403
+ this.prismaService.person.deleteMany({
404
+ where: {
405
+ id: {
406
+ in: ids,
407
+ },
408
+ },
409
+ }),
410
+ ]);
411
+ }
412
+
413
+ async openPublicAvatar(locale: string, fileId: number, res: any) {
414
+ const personWithAvatar = await this.prismaService.person.findFirst({
415
+ where: {
416
+ avatar_id: fileId,
417
+ },
418
+ select: {
419
+ id: true,
420
+ },
421
+ });
422
+
423
+ if (!personWithAvatar) {
424
+ throw new NotFoundException(
425
+ getLocaleText('personNotFound', locale, 'Person not found'),
426
+ );
427
+ }
428
+
429
+ const { file, buffer } = await this.fileService.getBuffer(fileId);
430
+
431
+ res.set({
432
+ 'Content-Type': file.file_mimetype.name,
433
+ 'Content-Length': buffer.length,
434
+ 'Cache-Control': 'public, max-age=3600',
435
+ });
436
+
437
+ res.send(buffer);
438
+ }
439
+
440
+ private async enrichPeople(people: any[], allowCompanyRegistration = true) {
441
+ if (people.length === 0) {
442
+ return [];
443
+ }
444
+
445
+ const personIds = people.map((person) => person.id);
446
+
447
+ const [companies, individuals, companyBranches, employerMetadata] =
448
+ await Promise.all([
449
+ this.prismaService.person_company.findMany({
450
+ where: { id: { in: personIds } },
451
+ }),
452
+ this.prismaService.person_individual.findMany({
453
+ where: { id: { in: personIds } },
454
+ }),
455
+ this.prismaService.person_company.findMany({
456
+ where: { headquarter_id: { in: personIds } },
457
+ select: { id: true, headquarter_id: true },
458
+ }),
459
+ this.prismaService.person_metadata.findMany({
460
+ where: {
461
+ person_id: { in: personIds },
462
+ key: EMPLOYER_COMPANY_METADATA_KEY,
463
+ },
464
+ select: {
465
+ person_id: true,
466
+ value: true,
467
+ },
468
+ }).then((metadata) => (allowCompanyRegistration ? metadata : [])),
469
+ ]);
470
+
471
+ const companyById = new Map(companies.map((item) => [item.id, item]));
472
+ const individualById = new Map(individuals.map((item) => [item.id, item]));
473
+
474
+ const branchesByHeadquarterId = new Map<number, number[]>();
475
+ for (const branch of companyBranches) {
476
+ if (!branch.headquarter_id) continue;
477
+ const current = branchesByHeadquarterId.get(branch.headquarter_id) || [];
478
+ current.push(branch.id);
479
+ branchesByHeadquarterId.set(branch.headquarter_id, current);
480
+ }
481
+
482
+ const employerCompanyIdByPersonId = new Map<number, number>();
483
+ for (const meta of employerMetadata) {
484
+ const employerId = this.coerceNumber(meta.value);
485
+ if (employerId > 0) {
486
+ employerCompanyIdByPersonId.set(meta.person_id, employerId);
487
+ }
488
+ }
489
+
490
+ const employerCompanyIds = Array.from(
491
+ new Set(Array.from(employerCompanyIdByPersonId.values())),
492
+ );
493
+ const employerCompanies =
494
+ employerCompanyIds.length > 0
495
+ ? await this.prismaService.person.findMany({
496
+ where: {
497
+ id: { in: employerCompanyIds },
498
+ type: 'company',
499
+ },
500
+ })
501
+ : [];
502
+ const employerCompanyRows =
503
+ employerCompanyIds.length > 0
504
+ ? await this.prismaService.person_company.findMany({
505
+ where: {
506
+ id: { in: employerCompanyIds },
507
+ },
508
+ })
509
+ : [];
510
+ const employerCompanyRowById = new Map(
511
+ employerCompanyRows.map((item) => [item.id, item]),
512
+ );
513
+ const employerCompanyById = new Map(
514
+ employerCompanies.map((item: any) => [
515
+ item.id,
516
+ {
517
+ ...item,
518
+ person_company: employerCompanyRowById.get(item.id) ?? null,
519
+ },
520
+ ]),
521
+ );
522
+
523
+ return people.map((person) => {
524
+ const metadata = this.metadataArrayToMap(person.person_metadata);
525
+ const companyData = companyById.get(person.id);
526
+ const individualData = individualById.get(person.id);
527
+ const employerCompanyId = allowCompanyRegistration
528
+ ? employerCompanyIdByPersonId.get(person.id) ?? null
529
+ : null;
530
+ const employerCompany =
531
+ allowCompanyRegistration && employerCompanyId != null
532
+ ? this.normalizeRelationPersonSummary(
533
+ employerCompanyById.get(employerCompanyId),
534
+ )
535
+ : null;
536
+
537
+ return {
538
+ id: person.id,
539
+ name: person.name,
540
+ type: person.type,
541
+ status: person.status,
542
+ avatar_id: person.avatar_id ?? null,
543
+ created_at: person.created_at,
544
+ updated_at: person.updated_at,
545
+ birth_date: individualData?.birth_date ?? null,
546
+ gender: individualData?.gender ?? null,
547
+ job_title: individualData?.job_title ?? null,
548
+ trade_name: companyData?.trade_name ?? null,
549
+ foundation_date: companyData?.foundation_date ?? null,
550
+ legal_nature: companyData?.legal_nature ?? null,
551
+ headquarter_id: companyData?.headquarter_id ?? null,
552
+ branch_ids: branchesByHeadquarterId.get(person.id) || [],
553
+ notes: this.metadataToString(metadata.get(NOTES_METADATA_KEY)),
554
+ employer_company_id: allowCompanyRegistration ? employerCompanyId : null,
555
+ employer_company: allowCompanyRegistration ? employerCompany : null,
556
+ contact: person.contact || [],
557
+ document: person.document || [],
558
+ address: Array.isArray(person.person_address)
559
+ ? person.person_address
560
+ .map((item: any) => item.address)
561
+ .filter((address: any) => address != null)
562
+ : [],
563
+ };
564
+ });
565
+ }
566
+
567
+ private metadataArrayToMap(metadata: any[]) {
568
+ const map = new Map<string, unknown>();
569
+
570
+ for (const item of Array.isArray(metadata) ? metadata : []) {
571
+ map.set(item.key, item.value);
572
+ }
573
+
574
+ return map;
575
+ }
576
+
577
+ private metadataToString(value: unknown): string | null {
578
+ if (value == null) return null;
579
+ if (typeof value === 'string') return value;
580
+ if (typeof value === 'number' || typeof value === 'boolean') {
581
+ return String(value);
582
+ }
583
+
584
+ return null;
585
+ }
586
+
587
+ private async syncPersonSubtypeData(
588
+ tx: any,
589
+ personId: number,
590
+ currentType: string | null,
591
+ data: any,
592
+ locale: string,
593
+ ) {
594
+ const targetType = data.type ?? currentType;
595
+ if (!targetType) return;
596
+
597
+ if (targetType === 'individual') {
598
+ await tx.person_company.deleteMany({ where: { id: personId } });
599
+
600
+ await tx.person_individual.upsert({
601
+ where: { id: personId },
602
+ create: {
603
+ id: personId,
604
+ birth_date: this.parseDateOrNull(data.birth_date),
605
+ gender: data.gender ?? null,
606
+ job_title: this.normalizeTextOrNull(data.job_title),
607
+ },
608
+ update: {
609
+ birth_date:
610
+ data.birth_date === undefined
611
+ ? undefined
612
+ : this.parseDateOrNull(data.birth_date),
613
+ gender: data.gender === undefined ? undefined : data.gender ?? null,
614
+ job_title:
615
+ data.job_title === undefined
616
+ ? undefined
617
+ : this.normalizeTextOrNull(data.job_title),
618
+ },
619
+ });
620
+ return;
621
+ }
622
+
623
+ await tx.person_individual_relation.deleteMany({
624
+ where: {
625
+ OR: [
626
+ { person_individual_id: personId },
627
+ { related_person_individual_id: personId },
628
+ ],
629
+ },
630
+ });
631
+ await tx.person_individual.deleteMany({ where: { id: personId } });
632
+
633
+ const normalizedHeadquarterId = this.coerceNumber(data.headquarter_id);
634
+
635
+ if (normalizedHeadquarterId > 0) {
636
+ if (normalizedHeadquarterId === personId) {
637
+ throw new BadRequestException(
638
+ getLocaleText(
639
+ 'companyRelationSelfReference',
640
+ locale,
641
+ 'A company cannot be its own headquarter.',
642
+ ),
643
+ );
644
+ }
645
+
646
+ const headquarter = await tx.person.findFirst({
647
+ where: { id: normalizedHeadquarterId, type: 'company' },
648
+ select: { id: true },
649
+ });
650
+
651
+ if (!headquarter) {
652
+ throw new BadRequestException(
653
+ getLocaleText(
654
+ 'companyRelationInvalidTarget',
655
+ locale,
656
+ 'Only company records can be linked as headquarters or branches.',
657
+ ),
658
+ );
659
+ }
660
+ }
661
+
662
+ await tx.person_company.upsert({
663
+ where: { id: personId },
664
+ create: {
665
+ id: personId,
666
+ trade_name: this.normalizeTextOrNull(data.trade_name),
667
+ foundation_date: this.parseDateOrNull(data.foundation_date),
668
+ legal_nature: this.normalizeTextOrNull(data.legal_nature),
669
+ headquarter_id: normalizedHeadquarterId > 0 ? normalizedHeadquarterId : null,
670
+ },
671
+ update: {
672
+ trade_name:
673
+ data.trade_name === undefined
674
+ ? undefined
675
+ : this.normalizeTextOrNull(data.trade_name),
676
+ foundation_date:
677
+ data.foundation_date === undefined
678
+ ? undefined
679
+ : this.parseDateOrNull(data.foundation_date),
680
+ legal_nature:
681
+ data.legal_nature === undefined
682
+ ? undefined
683
+ : this.normalizeTextOrNull(data.legal_nature),
684
+ headquarter_id:
685
+ data.headquarter_id === undefined
686
+ ? undefined
687
+ : normalizedHeadquarterId > 0
688
+ ? normalizedHeadquarterId
689
+ : null,
690
+ },
691
+ });
692
+
693
+ if (Object.prototype.hasOwnProperty.call(data, 'branch_ids')) {
694
+ await this.syncCompanyBranches(tx, personId, data.branch_ids, locale);
695
+ }
696
+ }
697
+
698
+ private async syncCompanyBranches(
699
+ tx: any,
700
+ personId: number,
701
+ branchIds: number[] | undefined,
702
+ locale: string,
703
+ ) {
704
+ const normalizedBranchIds = Array.from(
705
+ new Set(
706
+ (Array.isArray(branchIds) ? branchIds : [])
707
+ .map((value) => Number(value))
708
+ .filter((value) => Number.isInteger(value) && value > 0 && value !== personId),
709
+ ),
710
+ );
711
+
712
+ if (normalizedBranchIds.length > 0) {
713
+ const companies = await tx.person.findMany({
714
+ where: {
715
+ id: { in: normalizedBranchIds },
716
+ type: 'company',
717
+ },
718
+ select: { id: true },
719
+ });
720
+
721
+ if (companies.length !== normalizedBranchIds.length) {
722
+ throw new BadRequestException(
723
+ getLocaleText(
724
+ 'companyRelationInvalidTarget',
725
+ locale,
726
+ 'Only company records can be linked as headquarters or branches.',
727
+ ),
728
+ );
729
+ }
730
+
731
+ await tx.person_company.updateMany({
732
+ where: {
733
+ id: { in: normalizedBranchIds },
734
+ },
735
+ data: {
736
+ headquarter_id: personId,
737
+ },
738
+ });
739
+ }
740
+
741
+ await tx.person_company.updateMany({
742
+ where: {
743
+ headquarter_id: personId,
744
+ id: { notIn: normalizedBranchIds.length > 0 ? normalizedBranchIds : [-1] },
745
+ },
746
+ data: {
747
+ headquarter_id: null,
748
+ },
749
+ });
750
+ }
751
+
752
+ private async syncPersonMetadata(
753
+ tx: any,
754
+ personId: number,
755
+ data: { notes?: unknown; employer_company_id?: unknown },
756
+ ) {
757
+ if (data.notes !== undefined) {
758
+ await this.upsertMetadataValue(tx, personId, NOTES_METADATA_KEY, data.notes);
759
+ }
760
+
761
+ if (data.employer_company_id !== undefined) {
762
+ await this.upsertMetadataValue(
763
+ tx,
764
+ personId,
765
+ EMPLOYER_COMPANY_METADATA_KEY,
766
+ data.employer_company_id,
767
+ );
768
+ }
769
+ }
770
+
771
+ private async upsertMetadataValue(
772
+ tx: any,
773
+ personId: number,
774
+ key: string,
775
+ value: unknown,
776
+ ) {
777
+ const existing = await tx.person_metadata.findFirst({
778
+ where: {
779
+ person_id: personId,
780
+ key,
781
+ },
782
+ select: { id: true },
783
+ });
784
+
785
+ const normalizedValue = this.normalizeMetadataValue(value);
786
+
787
+ if (normalizedValue == null) {
788
+ if (existing) {
789
+ await tx.person_metadata.delete({ where: { id: existing.id } });
790
+ }
791
+ return;
792
+ }
793
+
794
+ if (existing) {
795
+ await tx.person_metadata.update({
796
+ where: { id: existing.id },
797
+ data: { value: normalizedValue },
798
+ });
799
+ return;
800
+ }
801
+
802
+ await tx.person_metadata.create({
803
+ data: {
804
+ person_id: personId,
805
+ key,
806
+ value: normalizedValue,
807
+ },
808
+ });
809
+ }
810
+
811
+ private normalizeMetadataValue(value: unknown): string | number | boolean | null {
812
+ if (value == null) return null;
813
+
814
+ if (typeof value === 'string') {
815
+ const trimmed = value.trim();
816
+ return trimmed.length > 0 ? trimmed : null;
817
+ }
818
+
819
+ if (typeof value === 'number') {
820
+ return Number.isFinite(value) ? value : null;
821
+ }
822
+
823
+ if (typeof value === 'boolean') {
824
+ return value;
825
+ }
826
+
827
+ return null;
828
+ }
829
+
830
+ private async syncContacts(tx: any, personId: number, incomingContacts: any[]) {
831
+ const existingContacts = await tx.contact.findMany({ where: { person_id: personId } });
832
+
833
+ for (const contact of incomingContacts) {
834
+ if (contact.id) {
835
+ await tx.contact.update({ where: { id: contact.id }, data: contact });
836
+ } else {
837
+ await tx.contact.create({ data: { ...contact, person_id: personId } });
838
+ }
839
+ }
840
+
841
+ for (const old of existingContacts) {
842
+ if (!incomingContacts.find((item: any) => item.id === old.id)) {
843
+ await tx.contact.delete({ where: { id: old.id } });
844
+ }
845
+ }
846
+ }
847
+
848
+ private async syncAddresses(
849
+ tx: any,
850
+ personId: number,
851
+ incomingAddresses: any[],
852
+ locale: string,
853
+ ) {
854
+ const existingAddresses = await tx.person_address.findMany({
855
+ where: { person_id: personId },
856
+ include: { address: true },
857
+ });
858
+
859
+ for (const address of incomingAddresses) {
860
+ const addressData = {
861
+ line1: address.line1,
862
+ line2: address.line2 || '',
863
+ city: address.city,
864
+ state: address.state,
865
+ country_code: address.country_code || 'BRA',
866
+ postal_code: address.postal_code || '',
867
+ is_primary: address.is_primary,
868
+ address_type: address.address_type,
869
+ };
870
+
871
+ if (address.id) {
872
+ const existingLink = existingAddresses.find(
873
+ (item: any) => item.address_id === address.id,
874
+ );
875
+
876
+ if (!existingLink) {
877
+ throw new BadRequestException(
878
+ getLocaleText(
879
+ 'personNotFound',
880
+ locale,
881
+ `Address with ID ${address.id} not found for person ${personId}.`,
882
+ ),
883
+ );
884
+ }
885
+
886
+ await tx.address.update({
887
+ where: { id: address.id },
888
+ data: addressData,
889
+ });
890
+ } else {
891
+ const createdAddress = await tx.address.create({
892
+ data: addressData,
893
+ });
894
+
895
+ await tx.person_address.create({
896
+ data: {
897
+ person_id: personId,
898
+ address_id: createdAddress.id,
899
+ },
900
+ });
901
+ }
902
+ }
903
+
904
+ for (const old of existingAddresses) {
905
+ if (!incomingAddresses.find((item: any) => item.id === old.address_id)) {
906
+ await tx.person_address.delete({ where: { id: old.id } });
907
+ await tx.address.delete({ where: { id: old.address_id } });
908
+ }
909
+ }
910
+ }
911
+
912
+ private async syncDocuments(tx: any, personId: number, incomingDocuments: any[]) {
913
+ const existingDocuments = await tx.document.findMany({ where: { person_id: personId } });
914
+
915
+ for (const document of incomingDocuments) {
916
+ if (document.id) {
917
+ await tx.document.update({ where: { id: document.id }, data: document });
918
+ } else {
919
+ await tx.document.create({ data: { ...document, person_id: personId } });
920
+ }
921
+ }
922
+
923
+ for (const old of existingDocuments) {
924
+ if (!incomingDocuments.find((item: any) => item.id === old.id)) {
925
+ await tx.document.delete({ where: { id: old.id } });
926
+ }
927
+ }
928
+ }
929
+
930
+ private validateSinglePrimaryPerType(
931
+ items: any[],
932
+ groupKey: string,
933
+ locale: string,
934
+ localeKey: string,
935
+ fallbackMessage: string,
936
+ ) {
937
+ const map = new Map<string, number>();
938
+
939
+ for (const item of items) {
940
+ if (!item.is_primary) continue;
941
+
942
+ const key = String(item[groupKey]);
943
+ map.set(key, (map.get(key) || 0) + 1);
944
+ }
945
+
946
+ for (const count of map.values()) {
947
+ if (count > 1) {
948
+ throw new BadRequestException(getLocaleText(localeKey, locale, fallbackMessage));
949
+ }
950
+ }
951
+ }
952
+
953
+ private parseDateOrNull(value: unknown): Date | null {
954
+ if (!value) return null;
955
+ const date = value instanceof Date ? value : new Date(String(value));
956
+ return Number.isNaN(date.getTime()) ? null : date;
957
+ }
958
+
959
+ private normalizeTextOrNull(value: unknown): string | null {
960
+ if (typeof value !== 'string') {
961
+ return value == null ? null : String(value);
962
+ }
963
+
964
+ const trimmed = value.trim();
965
+ return trimmed.length > 0 ? trimmed : null;
966
+ }
967
+
968
+ private coerceNumber(value: unknown): number {
969
+ const parsed = Number(value);
970
+ return Number.isFinite(parsed) ? parsed : 0;
971
+ }
972
+
973
+ private async buildSearchFilters(search: string) {
974
+ const normalizedDigits = this.normalizeDigits(search);
975
+ const filters: any[] = [];
976
+
977
+ if (!Number.isNaN(+search)) {
978
+ filters.push({ id: { equals: +search } });
979
+ }
980
+
981
+ filters.push(
982
+ { name: { contains: search, mode: 'insensitive' } },
983
+ {
984
+ person_metadata: {
985
+ some: {
986
+ value: { path: [], equals: search } as any,
987
+ },
988
+ },
989
+ },
990
+ {
991
+ contact: {
992
+ some: {
993
+ value: { contains: search, mode: 'insensitive' },
994
+ },
995
+ },
996
+ },
997
+ {
998
+ document: {
999
+ some: {
1000
+ value: { contains: search, mode: 'insensitive' },
1001
+ },
1002
+ },
1003
+ },
1004
+ {
1005
+ person_address: {
1006
+ some: {
1007
+ address: {
1008
+ OR: [
1009
+ { line1: { contains: search, mode: 'insensitive' } },
1010
+ { line2: { contains: search, mode: 'insensitive' } },
1011
+ { city: { contains: search, mode: 'insensitive' } },
1012
+ { state: { contains: search, mode: 'insensitive' } },
1013
+ { postal_code: { contains: search, mode: 'insensitive' } },
1014
+ { country_code: { contains: search, mode: 'insensitive' } },
1015
+ ],
1016
+ },
1017
+ },
1018
+ },
1019
+ },
1020
+ );
1021
+
1022
+ if (normalizedDigits.length > 0) {
1023
+ const normalizedMatches =
1024
+ await this.findPersonIdsByNormalizedDigits(normalizedDigits);
1025
+
1026
+ if (normalizedMatches.length > 0) {
1027
+ filters.push({
1028
+ id: {
1029
+ in: normalizedMatches,
1030
+ },
1031
+ });
1032
+ }
1033
+ }
1034
+
1035
+ return filters;
1036
+ }
1037
+
1038
+ private normalizeDigits(value: string) {
1039
+ return value.replace(/\D/g, '');
1040
+ }
1041
+
1042
+ private async findPersonIdsByNormalizedDigits(normalizedDigits: string) {
1043
+ const likeValue = `%${normalizedDigits}%`;
1044
+
1045
+ const rows = await this.prismaService.$queryRaw<Array<{ id: number }>>(
1046
+ Prisma.sql`
1047
+ SELECT DISTINCT p.id
1048
+ FROM person p
1049
+ LEFT JOIN contact c ON c.person_id = p.id
1050
+ LEFT JOIN document d ON d.person_id = p.id
1051
+ LEFT JOIN person_address pa ON pa.person_id = p.id
1052
+ LEFT JOIN address a ON a.id = pa.address_id
1053
+ WHERE regexp_replace(COALESCE(c.value, ''), '[^0-9]+', '', 'g') ILIKE ${likeValue}
1054
+ OR regexp_replace(COALESCE(d.value, ''), '[^0-9]+', '', 'g') ILIKE ${likeValue}
1055
+ OR regexp_replace(COALESCE(a.postal_code, ''), '[^0-9]+', '', 'g') ILIKE ${likeValue}
1056
+ `,
1057
+ );
1058
+
1059
+ return rows.map((row) => row.id);
1060
+ }
1061
+
1062
+ private async ensureCompanyRegistrationAllowed({
1063
+ currentType,
1064
+ nextType,
1065
+ locale,
1066
+ }: {
1067
+ currentType?: string;
1068
+ nextType?: string;
1069
+ locale: string;
1070
+ }) {
1071
+ const targetType = nextType ?? currentType;
1072
+ const isNewCompanyRegistration =
1073
+ targetType === 'company' && currentType !== 'company';
1074
+
1075
+ if (!isNewCompanyRegistration) {
1076
+ return;
1077
+ }
1078
+
1079
+ const settings = await this.settingService.getSettingValues(
1080
+ CONTACT_ALLOW_COMPANY_REGISTRATION_SETTING,
1081
+ );
1082
+
1083
+ if (settings[CONTACT_ALLOW_COMPANY_REGISTRATION_SETTING] !== false) {
1084
+ return;
1085
+ }
1086
+
1087
+ throw new BadRequestException(
1088
+ getLocaleText(
1089
+ 'companyRegistrationDisabled',
1090
+ locale,
1091
+ 'Company registration is disabled. Only individual records can be created.',
1092
+ ),
1093
+ );
1094
+ }
1095
+
1096
+ private async isCompanyRegistrationAllowed() {
1097
+ const settings = await this.settingService.getSettingValues(
1098
+ CONTACT_ALLOW_COMPANY_REGISTRATION_SETTING,
1099
+ );
1100
+
1101
+ return settings[CONTACT_ALLOW_COMPANY_REGISTRATION_SETTING] !== false;
1102
+ }
1103
+
1104
+ private async cleanupReplacedAvatar(
1105
+ locale: string,
1106
+ previousAvatarId?: number | null,
1107
+ nextAvatarId?: number | null,
1108
+ ) {
1109
+ if (!previousAvatarId || previousAvatarId === nextAvatarId) {
1110
+ return;
1111
+ }
1112
+
1113
+ try {
1114
+ await this.fileService.delete(locale, { ids: [previousAvatarId] });
1115
+ } catch {
1116
+ // Keep the person update successful even if avatar cleanup fails.
1117
+ }
1118
+ }
1119
+
1120
+ private normalizeRelationPersonSummary(person: any) {
1121
+ if (!person) return null;
1122
+
1123
+ const tradeName =
1124
+ person.person_company?.trade_name != null
1125
+ ? String(person.person_company.trade_name)
1126
+ : null;
1127
+
1128
+ return {
1129
+ id: person.id,
1130
+ name: person.name,
1131
+ type: person.type,
1132
+ status: person.status,
1133
+ avatar_id: person.avatar_id ?? null,
1134
+ trade_name: tradeName,
1135
+ };
1136
+ }
1137
+ }