@hed-hog/contact 0.0.266 → 0.0.274

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 (83) hide show
  1. package/README.md +470 -0
  2. package/dist/address-type.enum.d.ts +10 -0
  3. package/dist/address-type.enum.d.ts.map +1 -0
  4. package/dist/address-type.enum.js +14 -0
  5. package/dist/address-type.enum.js.map +1 -0
  6. package/dist/contact.module.d.ts.map +1 -1
  7. package/dist/contact.module.js +0 -2
  8. package/dist/contact.module.js.map +1 -1
  9. package/dist/contact.service.d.ts +19 -22
  10. package/dist/contact.service.d.ts.map +1 -1
  11. package/dist/contact.service.js +22 -2
  12. package/dist/contact.service.js.map +1 -1
  13. package/dist/index.d.ts +5 -8
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -4
  16. package/dist/index.js.map +1 -1
  17. package/dist/person/dto/create.dto.d.ts +14 -0
  18. package/dist/person/dto/create.dto.d.ts.map +1 -1
  19. package/dist/person/dto/create.dto.js +52 -1
  20. package/dist/person/dto/create.dto.js.map +1 -1
  21. package/dist/person/dto/interaction-create.dto.d.ts +16 -0
  22. package/dist/person/dto/interaction-create.dto.d.ts.map +1 -0
  23. package/dist/person/dto/interaction-create.dto.js +57 -0
  24. package/dist/person/dto/interaction-create.dto.js.map +1 -0
  25. package/dist/person/dto/update.dto.d.ts +17 -1
  26. package/dist/person/dto/update.dto.d.ts.map +1 -1
  27. package/dist/person/dto/update.dto.js +79 -3
  28. package/dist/person/dto/update.dto.js.map +1 -1
  29. package/dist/person/person.controller.d.ts +37 -8
  30. package/dist/person/person.controller.d.ts.map +1 -1
  31. package/dist/person/person.controller.js +29 -3
  32. package/dist/person/person.controller.js.map +1 -1
  33. package/dist/person/person.service.d.ts +71 -13
  34. package/dist/person/person.service.d.ts.map +1 -1
  35. package/dist/person/person.service.js +762 -108
  36. package/dist/person/person.service.js.map +1 -1
  37. package/dist/person-relation-type/person-relation-type.controller.d.ts +13 -9
  38. package/dist/person-relation-type/person-relation-type.controller.d.ts.map +1 -1
  39. package/dist/person-relation-type/person-relation-type.service.d.ts +16 -20
  40. package/dist/person-relation-type/person-relation-type.service.d.ts.map +1 -1
  41. package/dist/person-relation-type/person-relation-type.service.js +48 -41
  42. package/dist/person-relation-type/person-relation-type.service.js.map +1 -1
  43. package/hedhog/data/menu.yaml +2 -16
  44. package/hedhog/data/role.yaml +9 -1
  45. package/hedhog/data/route.yaml +10 -21
  46. package/hedhog/data/setting_group.yaml +21 -0
  47. package/hedhog/frontend/app/person/_components/delete-person-dialog.tsx.ejs +59 -0
  48. package/hedhog/frontend/app/person/_components/person-field-with-create.tsx.ejs +831 -0
  49. package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +2277 -0
  50. package/hedhog/frontend/app/person/_components/person-types.ts.ejs +157 -0
  51. package/hedhog/frontend/app/person/page.tsx.ejs +1158 -1335
  52. package/hedhog/frontend/messages/en.json +114 -4
  53. package/hedhog/frontend/messages/pt.json +155 -4
  54. package/hedhog/table/person.yaml +7 -0
  55. package/hedhog/table/person_address.yaml +18 -0
  56. package/hedhog/table/person_company.yaml +26 -11
  57. package/hedhog/table/person_individual.yaml +4 -0
  58. package/hedhog/table/person_individual_relation.yaml +39 -0
  59. package/package.json +6 -5
  60. package/src/address-type.enum.ts +9 -0
  61. package/src/contact.module.ts +46 -48
  62. package/src/contact.service.ts +28 -13
  63. package/src/index.ts +6 -13
  64. package/src/language/en.json +8 -1
  65. package/src/language/pt.json +9 -1
  66. package/src/person/dto/create.dto.ts +49 -1
  67. package/src/person/dto/update.dto.ts +75 -3
  68. package/src/person/person.controller.ts +31 -14
  69. package/src/person/person.service.ts +1019 -121
  70. package/src/person-relation-type/person-relation-type.service.ts +84 -76
  71. package/hedhog/data/address_type.yaml +0 -28
  72. package/hedhog/frontend/app/address-type/page.tsx.ejs +0 -480
  73. package/hedhog/query/add-unique-address-type-locale.sql +0 -3
  74. package/hedhog/table/address.yaml +0 -28
  75. package/hedhog/table/address_type.yaml +0 -11
  76. package/hedhog/table/person_relation.yaml +0 -20
  77. package/hedhog/table/person_relation_type.yaml +0 -6
  78. package/src/address-type/address-type.controller.ts +0 -55
  79. package/src/address-type/address-type.enum.ts +0 -9
  80. package/src/address-type/address-type.module.ts +0 -18
  81. package/src/address-type/address-type.service.ts +0 -121
  82. package/src/address-type/dto/create.dto.ts +0 -19
  83. package/src/address-type/dto/update.dto.ts +0 -9
@@ -1,15 +1,29 @@
1
1
  import { DeleteDTO } from '@hed-hog/api';
2
2
  import { getLocaleText } from '@hed-hog/api-locale';
3
3
  import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
4
- import { PrismaService } from '@hed-hog/api-prisma';
4
+ import { Prisma, PrismaService } from '@hed-hog/api-prisma';
5
+ import { FileService, SettingService } from '@hed-hog/core';
5
6
  import {
6
7
  BadRequestException,
7
8
  Inject,
8
9
  Injectable,
10
+ NotFoundException,
9
11
  forwardRef,
10
12
  } from '@nestjs/common';
11
13
  import { CreateDTO } from './dto/create.dto';
12
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
+
13
27
  @Injectable()
14
28
  export class PersonService {
15
29
  constructor(
@@ -17,9 +31,35 @@ export class PersonService {
17
31
  private readonly prismaService: PrismaService,
18
32
  @Inject(forwardRef(() => PaginationService))
19
33
  private readonly paginationService: PaginationService,
34
+ @Inject(forwardRef(() => FileService))
35
+ private readonly fileService: FileService,
36
+ @Inject(forwardRef(() => SettingService))
37
+ private readonly settingService: SettingService,
20
38
  ) {}
21
39
 
22
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
+
23
63
  const [total, individual, company, active, inactive] = await Promise.all([
24
64
  this.prismaService.person.count(),
25
65
  this.prismaService.person.count({ where: { type: 'individual' } }),
@@ -37,173 +77,266 @@ export class PersonService {
37
77
  };
38
78
  }
39
79
 
40
- async list(paginationParams: PaginationDTO & { type: string; status: string }) {
41
- const OR: any[] = [];
42
- const search = paginationParams.search?.trim();
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
+ };
43
103
 
44
- if (search) {
45
- if (!isNaN(+search)) {
46
- OR.push({ id: { equals: +search } });
47
- }
48
- OR.push({ name: { contains: search, mode: 'insensitive' } });
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
+ });
49
123
  }
50
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();
51
141
  const where: any = {};
52
- if (OR.length > 0) {
53
- where.OR = OR;
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
+ );
54
155
  }
55
156
 
56
- if (paginationParams.type && paginationParams.type !== 'all') {
57
- where.type = paginationParams.type;
157
+ if (!allowCompanyRegistration) {
158
+ where.type = 'individual';
159
+ } else if (requestedType && requestedType !== 'all') {
160
+ where.type = requestedType;
58
161
  }
162
+
59
163
  if (paginationParams.status && paginationParams.status !== 'all') {
60
164
  where.status = paginationParams.status;
61
165
  }
62
166
 
63
- return this.paginationService.paginate(
167
+ if (search) {
168
+ where.OR = await this.buildSearchFilters(search);
169
+ }
170
+
171
+ const result = await this.paginationService.paginate(
64
172
  this.prismaService.person,
65
173
  paginationParams,
66
174
  {
67
175
  where,
68
176
  include: {
69
- address: true,
177
+ person_address: {
178
+ include: {
179
+ address: true,
180
+ },
181
+ },
70
182
  contact: true,
71
183
  document: true,
184
+ person_metadata: true,
72
185
  },
73
186
  },
74
187
  );
75
- }
76
188
 
77
- async get(locale: string, id: number) {
78
- const person = await this.prismaService.person.findUnique({ where: { id } });
79
- if (!person) {
80
- throw new BadRequestException(getLocaleText('personNotFound', locale, `Person with ID ${id} not found`));
81
- }
189
+ const enriched = await this.enrichPeople(
190
+ result.data as any[],
191
+ allowCompanyRegistration,
192
+ );
82
193
 
83
- return person
194
+ return {
195
+ ...result,
196
+ data: enriched,
197
+ };
84
198
  }
85
199
 
86
- async create(data: CreateDTO) {
87
- return this.prismaService.person.create({
88
- data,
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
+ },
89
215
  });
90
- }
91
216
 
92
- async update(id: number, data: any, locale: string) {
93
- const person = await this.prismaService.person.findUnique({ where: { id } });
94
217
  if (!person) {
95
- throw new BadRequestException(getLocaleText('personNotFound', locale, `Person with ID ${id} not found`));
218
+ throw new BadRequestException(
219
+ getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
220
+ );
96
221
  }
97
222
 
98
- const incomingContacts = data.contacts || [];
99
- const incomingAddresses = data.addresses || [];
100
- const incomingDocuments = data.documents || [];
101
- const contactPrimaryMap = new Map();
102
- for (const c of incomingContacts) {
103
- if (c.is_primary) {
104
- const key = String(c.contact_type_id);
105
- contactPrimaryMap.set(key, (contactPrimaryMap.get(key) || 0) + 1);
106
- }
107
- }
108
- for (const [_, count] of contactPrimaryMap.entries()) {
109
- if (count > 1) {
110
- throw new BadRequestException(
111
- getLocaleText('moreThanOnePrimaryContact', locale, 'More than one contact of the same type cannot be marked as primary.')
112
- );
113
- }
223
+ if (!allowCompanyRegistration && person.type === 'company') {
224
+ throw new NotFoundException(
225
+ getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
226
+ );
114
227
  }
115
228
 
116
- const addressPrimaryMap = new Map();
117
- for (const a of incomingAddresses) {
118
- if (a.is_primary) {
119
- const key = String(a.address_type_id);
120
- addressPrimaryMap.set(key, (addressPrimaryMap.get(key) || 0) + 1);
121
- }
122
- }
123
- for (const [_, count] of addressPrimaryMap.entries()) {
124
- if (count > 1) {
125
- throw new BadRequestException(
126
- getLocaleText('moreThanOnePrimaryAddress', locale, 'More than one address of the same type cannot be marked as primary.')
127
- );
128
- }
129
- }
229
+ const [normalized] = await this.enrichPeople(
230
+ [person as any],
231
+ allowCompanyRegistration,
232
+ );
233
+ return normalized;
234
+ }
130
235
 
131
- const documentPrimaryMap = new Map();
132
- for (const d of incomingDocuments) {
133
- if (d.is_primary) {
134
- const key = String(d.document_type_id);
135
- documentPrimaryMap.set(key, (documentPrimaryMap.get(key) || 0) + 1);
136
- }
137
- }
138
- for (const [_, count] of documentPrimaryMap.entries()) {
139
- if (count > 1) {
140
- throw new BadRequestException(
141
- getLocaleText('moreThanOnePrimaryDocument', locale, 'More than one document of the same type cannot be marked as primary.')
142
- );
143
- }
144
- }
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
+ });
145
243
 
146
244
  return this.prismaService.$transaction(async (tx) => {
147
- await tx.person.update({
148
- where: { id },
245
+ const person = await tx.person.create({
149
246
  data: {
150
247
  name: data.name,
151
248
  type: data.type,
152
249
  status: data.status,
250
+ avatar_id: data.avatar_id ?? null,
153
251
  },
154
252
  });
155
253
 
156
- const existingContacts = await tx.contact.findMany({ where: { person_id: id } });
157
- for (const c of incomingContacts) {
158
- if (c.id) {
159
- await tx.contact.update({ where: { id: c.id }, data: c });
160
- } else {
161
- await tx.contact.create({ data: { ...c, person_id: id } });
162
- }
163
- }
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
+ });
164
261
 
165
- for (const old of existingContacts) {
166
- if (!incomingContacts.find((c: any) => c.id === old.id)) {
167
- await tx.contact.delete({ where: { id: old.id } });
168
- }
169
- }
262
+ return person;
263
+ });
264
+ }
170
265
 
171
- const existingAddresses = await tx.address.findMany({ where: { person_id: id } });
172
- for (const a of incomingAddresses) {
173
- if (a.id) {
174
- await tx.address.update({ where: { id: a.id }, data: a });
175
- } else {
176
- await tx.address.create({ data: { ...a, person_id: id } });
177
- }
178
- }
179
- for (const old of existingAddresses) {
180
- if (!incomingAddresses.find((a: any) => a.id === old.id)) {
181
- await tx.address.delete({ where: { id: old.id } });
182
- }
183
- }
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
+ }
184
274
 
185
- const existingDocuments = await tx.document.findMany({ where: { person_id: id } });
186
- for (const d of incomingDocuments) {
187
- if (d.id) {
188
- await tx.document.update({ where: { id: d.id }, data: d });
189
- } else {
190
- await tx.document.create({ data: { ...d, person_id: id } });
191
- }
192
- }
193
- for (const old of existingDocuments) {
194
- if (!incomingDocuments.find((d: any) => d.id === old.id)) {
195
- await tx.document.delete({ where: { id: old.id } });
196
- }
197
- }
275
+ if (!allowCompanyRegistration && person.type === 'company') {
276
+ throw new NotFoundException(
277
+ getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
278
+ );
279
+ }
198
280
 
199
- return { success: true };
281
+ await this.ensureCompanyRegistrationAllowed({
282
+ currentType: person.type,
283
+ nextType: data.type,
284
+ locale,
200
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
+ });
201
330
  }
202
331
 
203
332
  async delete({ ids }: DeleteDTO, locale: string) {
204
333
  if (ids == undefined || ids == null) {
205
334
  throw new BadRequestException(
206
- getLocaleText('deleteItemsRequired', locale, 'You must select at least one item to delete.'),
335
+ getLocaleText(
336
+ 'deleteItemsRequired',
337
+ locale,
338
+ 'You must select at least one item to delete.',
339
+ ),
207
340
  );
208
341
  }
209
342
 
@@ -211,22 +344,62 @@ export class PersonService {
211
344
  where: { id: { in: ids } },
212
345
  select: { id: true },
213
346
  });
214
- const existingIds = existing.map(p => p.id);
215
- const missingIds = ids.filter(id => !existingIds.includes(id));
347
+ const existingIds = existing.map((p) => p.id);
348
+ const missingIds = ids.filter((itemId) => !existingIds.includes(itemId));
216
349
  if (missingIds.length > 0) {
217
350
  throw new BadRequestException(
218
351
  getLocaleText(
219
352
  'personNotFound',
220
353
  locale,
221
- `Person(s) with ID(s) ${missingIds.join(', ')} not found.`
222
- )
354
+ `Person(s) with ID(s) ${missingIds.join(', ')} not found.`,
355
+ ),
223
356
  );
224
357
  }
225
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
+
226
371
  return this.prismaService.$transaction([
227
372
  this.prismaService.contact.deleteMany({ where: { person_id: { in: ids } } }),
228
- this.prismaService.address.deleteMany({ where: { person_id: { in: ids } } }),
229
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
+ }),
230
403
  this.prismaService.person.deleteMany({
231
404
  where: {
232
405
  id: {
@@ -236,4 +409,729 @@ export class PersonService {
236
409
  }),
237
410
  ]);
238
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
+ }
239
1137
  }