@hed-hog/contact 0.0.304 → 0.0.305

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 (44) hide show
  1. package/README.md +225 -17
  2. package/dist/person/dto/account.dto.d.ts +5 -0
  3. package/dist/person/dto/account.dto.d.ts.map +1 -1
  4. package/dist/person/dto/account.dto.js +29 -0
  5. package/dist/person/dto/account.dto.js.map +1 -1
  6. package/dist/person/dto/import-preview.dto.d.ts +7 -0
  7. package/dist/person/dto/import-preview.dto.d.ts.map +1 -0
  8. package/dist/person/dto/import-preview.dto.js +7 -0
  9. package/dist/person/dto/import-preview.dto.js.map +1 -0
  10. package/dist/person/dto/import.dto.d.ts +15 -0
  11. package/dist/person/dto/import.dto.d.ts.map +1 -0
  12. package/dist/person/dto/import.dto.js +51 -0
  13. package/dist/person/dto/import.dto.js.map +1 -0
  14. package/dist/person/person.controller.d.ts +14 -0
  15. package/dist/person/person.controller.d.ts.map +1 -1
  16. package/dist/person/person.controller.js +53 -0
  17. package/dist/person/person.controller.js.map +1 -1
  18. package/dist/person/person.service.d.ts +19 -0
  19. package/dist/person/person.service.d.ts.map +1 -1
  20. package/dist/person/person.service.js +481 -67
  21. package/dist/person/person.service.js.map +1 -1
  22. package/dist/person-relation-type/person-relation-type.controller.d.ts +2 -2
  23. package/dist/person-relation-type/person-relation-type.service.d.ts +2 -2
  24. package/hedhog/data/route.yaml +6 -0
  25. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +2242 -484
  26. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +51 -0
  27. package/hedhog/frontend/app/accounts/page.tsx.ejs +181 -16
  28. package/hedhog/frontend/app/contact-type/page.tsx.ejs +223 -29
  29. package/hedhog/frontend/app/document-type/page.tsx.ejs +248 -37
  30. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +129 -19
  31. package/hedhog/frontend/app/person/_components/person-field-with-create.tsx.ejs +78 -212
  32. package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +760 -178
  33. package/hedhog/frontend/app/person/_components/person-import-sheet.tsx.ejs +1120 -0
  34. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +171 -4
  35. package/hedhog/frontend/app/person/page.tsx.ejs +17 -0
  36. package/hedhog/frontend/app/pipeline/_components/lead-proposals-tab.tsx.ejs +160 -35
  37. package/hedhog/frontend/messages/en.json +104 -2
  38. package/hedhog/frontend/messages/pt.json +111 -9
  39. package/package.json +5 -5
  40. package/src/person/dto/account.dto.ts +31 -0
  41. package/src/person/dto/import-preview.dto.ts +6 -0
  42. package/src/person/dto/import.dto.ts +61 -0
  43. package/src/person/person.controller.ts +74 -12
  44. package/src/person/person.service.ts +615 -68
@@ -39,6 +39,7 @@ import {
39
39
  FollowupListQueryDTO,
40
40
  FollowupStatsQueryDTO,
41
41
  } from './dto/followup-query.dto';
42
+ import { CRM_IMPORT_FIELDS } from './dto/import.dto';
42
43
  import { MergePersonDTO } from './dto/merge.dto';
43
44
  import { ReportsQueryDTO, type CrmReportGroupBy } from './dto/reports-query.dto';
44
45
  import { UpdateLifecycleStageDTO } from './dto/update-lifecycle-stage.dto';
@@ -1058,6 +1059,7 @@ export class PersonService {
1058
1059
  type?: string;
1059
1060
  status?: string;
1060
1061
  owner_user_id?: string | number;
1062
+ employer_company_id?: string | number;
1061
1063
  source?: string;
1062
1064
  lifecycle_stage?: string;
1063
1065
  follow_up_status?: string;
@@ -1105,7 +1107,26 @@ export class PersonService {
1105
1107
  person_metadata: {
1106
1108
  some: {
1107
1109
  key: OWNER_USER_METADATA_KEY,
1108
- value: ownerUserId as any,
1110
+ value: {
1111
+ equals: ownerUserId,
1112
+ },
1113
+ },
1114
+ },
1115
+ });
1116
+ }
1117
+
1118
+ const employerCompanyId = this.coerceNumber(
1119
+ paginationParams.employer_company_id,
1120
+ );
1121
+
1122
+ if (employerCompanyId > 0) {
1123
+ metadataFilters.push({
1124
+ person_metadata: {
1125
+ some: {
1126
+ key: EMPLOYER_COMPANY_METADATA_KEY,
1127
+ value: {
1128
+ equals: employerCompanyId,
1129
+ },
1109
1130
  },
1110
1131
  },
1111
1132
  });
@@ -1116,7 +1137,9 @@ export class PersonService {
1116
1137
  person_metadata: {
1117
1138
  some: {
1118
1139
  key: SOURCE_METADATA_KEY,
1119
- value: paginationParams.source as any,
1140
+ value: {
1141
+ equals: paginationParams.source,
1142
+ },
1120
1143
  },
1121
1144
  },
1122
1145
  });
@@ -1130,7 +1153,9 @@ export class PersonService {
1130
1153
  person_metadata: {
1131
1154
  some: {
1132
1155
  key: LIFECYCLE_STAGE_METADATA_KEY,
1133
- value: paginationParams.lifecycle_stage as any,
1156
+ value: {
1157
+ equals: paginationParams.lifecycle_stage,
1158
+ },
1134
1159
  },
1135
1160
  },
1136
1161
  });
@@ -1340,8 +1365,7 @@ export class PersonService {
1340
1365
  await this.syncPersonMetadata(tx, person.id, {
1341
1366
  owner_user_id: data.owner_user_id,
1342
1367
  });
1343
- await this.upsertPrimaryAccountContact(tx, person.id, 'EMAIL', data.email);
1344
- await this.upsertPrimaryAccountContact(tx, person.id, 'PHONE', data.phone);
1368
+ await this.syncAccountArrays(tx, person.id, data, locale);
1345
1369
 
1346
1370
  return {
1347
1371
  success: true,
@@ -1384,8 +1408,7 @@ export class PersonService {
1384
1408
  await this.syncPersonMetadata(tx, id, {
1385
1409
  owner_user_id: data.owner_user_id,
1386
1410
  });
1387
- await this.upsertPrimaryAccountContact(tx, id, 'EMAIL', data.email);
1388
- await this.upsertPrimaryAccountContact(tx, id, 'PHONE', data.phone);
1411
+ await this.syncAccountArrays(tx, id, data, locale);
1389
1412
 
1390
1413
  return {
1391
1414
  success: true,
@@ -3116,15 +3139,32 @@ export class PersonService {
3116
3139
  return;
3117
3140
  }
3118
3141
 
3119
- await client.crm_stage_history.create({
3120
- data: {
3121
- person_id: personId,
3122
- from_stage: fromStage ?? null,
3123
- to_stage: toStage,
3124
- changed_by_user_id: changedByUserId,
3125
- changed_at: new Date(),
3126
- },
3127
- });
3142
+ const fromStageSql = fromStage
3143
+ ? Prisma.sql`CAST(${fromStage} AS crm_stage_history_from_stage_f4181e9874_enum)`
3144
+ : Prisma.sql`NULL`;
3145
+
3146
+ await client.$executeRaw(
3147
+ Prisma.sql`
3148
+ INSERT INTO crm_stage_history (
3149
+ person_id,
3150
+ from_stage,
3151
+ to_stage,
3152
+ changed_by_user_id,
3153
+ changed_at,
3154
+ created_at,
3155
+ updated_at
3156
+ )
3157
+ VALUES (
3158
+ ${personId},
3159
+ ${fromStageSql},
3160
+ CAST(${toStage} AS crm_stage_history_to_stage_ca5bd55f9f_enum),
3161
+ ${changedByUserId},
3162
+ NOW(),
3163
+ NOW(),
3164
+ NOW()
3165
+ )
3166
+ `,
3167
+ );
3128
3168
  }
3129
3169
 
3130
3170
  private async syncPersonSubtypeData(
@@ -3498,6 +3538,113 @@ export class PersonService {
3498
3538
  return null;
3499
3539
  }
3500
3540
 
3541
+ private async syncAccountArrays(
3542
+ tx: any,
3543
+ personId: number,
3544
+ data: Pick<
3545
+ CreateAccountDTO,
3546
+ | 'contacts'
3547
+ | 'addresses'
3548
+ | 'documents'
3549
+ | 'email'
3550
+ | 'phone'
3551
+ | 'collaborator_person_ids'
3552
+ >,
3553
+ locale: string,
3554
+ ) {
3555
+ if (Object.prototype.hasOwnProperty.call(data, 'contacts')) {
3556
+ const incomingContacts = Array.isArray(data.contacts) ? data.contacts : [];
3557
+ this.validateSinglePrimaryPerType(
3558
+ incomingContacts,
3559
+ 'contact_type_id',
3560
+ locale,
3561
+ 'moreThanOnePrimaryContact',
3562
+ 'More than one contact of the same type cannot be marked as primary.',
3563
+ );
3564
+ await this.syncContacts(tx, personId, incomingContacts);
3565
+ }
3566
+
3567
+ if (Object.prototype.hasOwnProperty.call(data, 'addresses')) {
3568
+ const incomingAddresses = Array.isArray(data.addresses) ? data.addresses : [];
3569
+ this.validateSinglePrimaryPerType(
3570
+ incomingAddresses,
3571
+ 'address_type',
3572
+ locale,
3573
+ 'moreThanOnePrimaryAddress',
3574
+ 'More than one address of the same type cannot be marked as primary.',
3575
+ );
3576
+ await this.syncAddresses(tx, personId, incomingAddresses, locale);
3577
+ }
3578
+
3579
+ if (Object.prototype.hasOwnProperty.call(data, 'documents')) {
3580
+ const incomingDocuments = Array.isArray(data.documents) ? data.documents : [];
3581
+ this.validateSinglePrimaryPerType(
3582
+ incomingDocuments,
3583
+ 'document_type_id',
3584
+ locale,
3585
+ 'moreThanOnePrimaryDocument',
3586
+ 'More than one document of the same type cannot be marked as primary.',
3587
+ );
3588
+ await this.syncDocuments(tx, personId, incomingDocuments);
3589
+ }
3590
+
3591
+ if (Object.prototype.hasOwnProperty.call(data, 'email')) {
3592
+ await this.upsertPrimaryAccountContact(tx, personId, 'EMAIL', data.email);
3593
+ }
3594
+
3595
+ if (Object.prototype.hasOwnProperty.call(data, 'phone')) {
3596
+ await this.upsertPrimaryAccountContact(tx, personId, 'PHONE', data.phone);
3597
+ }
3598
+
3599
+ if (Object.prototype.hasOwnProperty.call(data, 'collaborator_person_ids')) {
3600
+ await this.attachCollaboratorsToCompany(
3601
+ tx,
3602
+ personId,
3603
+ data.collaborator_person_ids,
3604
+ );
3605
+ }
3606
+ }
3607
+
3608
+ private async attachCollaboratorsToCompany(
3609
+ tx: any,
3610
+ companyId: number,
3611
+ collaboratorPersonIds: number[] | undefined,
3612
+ ) {
3613
+ const normalizedCollaboratorIds = Array.from(
3614
+ new Set(
3615
+ (Array.isArray(collaboratorPersonIds) ? collaboratorPersonIds : [])
3616
+ .map((value) => Number(value))
3617
+ .filter(
3618
+ (value) =>
3619
+ Number.isInteger(value) && value > 0 && value !== companyId,
3620
+ ),
3621
+ ),
3622
+ );
3623
+
3624
+ if (normalizedCollaboratorIds.length === 0) {
3625
+ return;
3626
+ }
3627
+
3628
+ const collaborators = await tx.person.findMany({
3629
+ where: {
3630
+ id: { in: normalizedCollaboratorIds },
3631
+ type: 'individual',
3632
+ },
3633
+ select: {
3634
+ id: true,
3635
+ },
3636
+ });
3637
+
3638
+ for (const collaborator of collaborators) {
3639
+ await this.upsertMetadataValue(
3640
+ tx,
3641
+ collaborator.id,
3642
+ EMPLOYER_COMPANY_METADATA_KEY,
3643
+ companyId,
3644
+ );
3645
+ }
3646
+ }
3647
+
3501
3648
  private async syncContacts(tx: any, personId: number, incomingContacts: any[]) {
3502
3649
  const existingContacts = await tx.contact.findMany({ where: { person_id: personId } });
3503
3650
 
@@ -4078,41 +4225,53 @@ export class PersonService {
4078
4225
  const type: CrmActivityType = 'task';
4079
4226
 
4080
4227
  if (existing) {
4081
- await tx.crm_activity.update({
4082
- where: {
4083
- id: existing.id,
4084
- },
4085
- data: {
4086
- owner_user_id: ownerUserId,
4087
- type,
4088
- subject,
4089
- notes: normalizedNotes,
4090
- due_at: dueAtDate,
4091
- priority,
4092
- updated_at: new Date(),
4093
- },
4094
- });
4228
+ await tx.$executeRaw(
4229
+ Prisma.sql`
4230
+ UPDATE crm_activity
4231
+ SET
4232
+ owner_user_id = ${ownerUserId},
4233
+ type = CAST(${'task'} AS crm_activity_type_77c8508dad_enum),
4234
+ subject = ${this.getFollowupActivitySubject()},
4235
+ notes = ${normalizedNotes},
4236
+ due_at = CAST(${dueAt} AS TIMESTAMPTZ),
4237
+ priority = CAST(${'medium'} AS crm_activity_priority_b4e2dbbb4d_enum),
4238
+ updated_at = NOW()
4239
+ WHERE id = ${existing.id}
4240
+ `,
4241
+ );
4095
4242
  return;
4096
4243
  }
4097
4244
 
4098
- const sourceKind: CrmActivitySourceKind = 'followup';
4099
- const now = new Date();
4100
-
4101
- await tx.crm_activity.create({
4102
- data: {
4103
- person_id: personId,
4104
- owner_user_id: ownerUserId,
4105
- created_by_user_id: actorUserId,
4106
- type,
4107
- subject,
4108
- notes: normalizedNotes,
4109
- due_at: dueAtDate,
4110
- priority,
4111
- source_kind: sourceKind,
4112
- created_at: now,
4113
- updated_at: now,
4114
- },
4115
- });
4245
+ await tx.$executeRaw(
4246
+ Prisma.sql`
4247
+ INSERT INTO crm_activity (
4248
+ person_id,
4249
+ owner_user_id,
4250
+ created_by_user_id,
4251
+ type,
4252
+ subject,
4253
+ notes,
4254
+ due_at,
4255
+ priority,
4256
+ source_kind,
4257
+ created_at,
4258
+ updated_at
4259
+ )
4260
+ VALUES (
4261
+ ${personId},
4262
+ ${ownerUserId},
4263
+ ${actorUserId},
4264
+ CAST(${'task'} AS crm_activity_type_77c8508dad_enum),
4265
+ ${this.getFollowupActivitySubject()},
4266
+ ${normalizedNotes},
4267
+ CAST(${dueAt} AS TIMESTAMPTZ),
4268
+ CAST(${'medium'} AS crm_activity_priority_b4e2dbbb4d_enum),
4269
+ CAST(${'followup'} AS crm_activity_source_kind_a09c5de478_enum),
4270
+ NOW(),
4271
+ NOW()
4272
+ )
4273
+ `,
4274
+ );
4116
4275
  }
4117
4276
 
4118
4277
  private async createCompletedInteractionActivity(
@@ -4134,23 +4293,40 @@ export class PersonService {
4134
4293
  const priority: CrmActivityPriority = 'medium';
4135
4294
  const sourceKind: CrmActivitySourceKind = 'interaction';
4136
4295
 
4137
- await tx.crm_activity.create({
4138
- data: {
4139
- person_id: personId,
4140
- owner_user_id: ownerUserId,
4141
- created_by_user_id: actorUserId,
4142
- completed_by_user_id: actorUserId,
4143
- type,
4144
- subject: this.getInteractionActivitySubject(interaction.type),
4145
- notes: this.normalizeTextOrNull(interaction.notes),
4146
- due_at: completedAt,
4147
- completed_at: completedAt,
4148
- priority,
4149
- source_kind: sourceKind,
4150
- created_at: completedAt,
4151
- updated_at: new Date(),
4152
- },
4153
- });
4296
+ await tx.$executeRaw(
4297
+ Prisma.sql`
4298
+ INSERT INTO crm_activity (
4299
+ person_id,
4300
+ owner_user_id,
4301
+ created_by_user_id,
4302
+ completed_by_user_id,
4303
+ type,
4304
+ subject,
4305
+ notes,
4306
+ due_at,
4307
+ completed_at,
4308
+ priority,
4309
+ source_kind,
4310
+ created_at,
4311
+ updated_at
4312
+ )
4313
+ VALUES (
4314
+ ${personId},
4315
+ ${ownerUserId},
4316
+ ${actorUserId},
4317
+ ${actorUserId},
4318
+ CAST(${interaction.type} AS crm_activity_type_77c8508dad_enum),
4319
+ ${this.getInteractionActivitySubject(interaction.type)},
4320
+ ${this.normalizeTextOrNull(interaction.notes)},
4321
+ CAST(${interaction.created_at} AS TIMESTAMPTZ),
4322
+ CAST(${interaction.created_at} AS TIMESTAMPTZ),
4323
+ CAST(${'medium'} AS crm_activity_priority_b4e2dbbb4d_enum),
4324
+ CAST(${'interaction'} AS crm_activity_source_kind_a09c5de478_enum),
4325
+ CAST(${interaction.created_at} AS TIMESTAMPTZ),
4326
+ NOW()
4327
+ )
4328
+ `,
4329
+ );
4154
4330
  }
4155
4331
 
4156
4332
  private getFollowupActivitySubject() {
@@ -4483,7 +4659,7 @@ export class PersonService {
4483
4659
 
4484
4660
  if (lifecycleStage && lifecycleStage !== 'all') {
4485
4661
  filters.push(
4486
- Prisma.sql`AND pc.account_lifecycle_stage = ${lifecycleStage}`,
4662
+ Prisma.sql`AND pc.account_lifecycle_stage = CAST(${lifecycleStage} AS person_company_account_lifecycle_stage_680de09d00_enum)`,
4487
4663
  );
4488
4664
  }
4489
4665
 
@@ -4591,12 +4767,14 @@ export class PersonService {
4591
4767
  }
4592
4768
 
4593
4769
  if (type && type !== 'all') {
4594
- filters.push(Prisma.sql`AND a.type = ${type}`);
4770
+ filters.push(
4771
+ Prisma.sql`AND a.type = CAST(${type} AS crm_activity_type_77c8508dad_enum)`,
4772
+ );
4595
4773
  }
4596
4774
 
4597
4775
  if (priority && priority !== 'all') {
4598
4776
  filters.push(
4599
- Prisma.sql`AND a.priority = ${priority}`,
4777
+ Prisma.sql`AND a.priority = CAST(${priority} AS crm_activity_priority_b4e2dbbb4d_enum)`,
4600
4778
  );
4601
4779
  }
4602
4780
 
@@ -4811,4 +4989,373 @@ export class PersonService {
4811
4989
  trade_name: tradeName,
4812
4990
  };
4813
4991
  }
4992
+
4993
+ // ─── CSV Import ──────────────────────────────────────────────────────────────
4994
+
4995
+ private getUploadedFileText(file: MulterFile): string {
4996
+ let buffer = file.buffer;
4997
+
4998
+ if (!buffer || buffer.length === 0) {
4999
+ throw new BadRequestException('Uploaded file is empty');
5000
+ }
5001
+
5002
+ const utf8 = buffer.toString('utf8');
5003
+ // Replace UTF-8 BOM if present
5004
+ const withoutBom = utf8.replace(/^\uFEFF/, '');
5005
+ if (withoutBom.includes('\uFFFD')) {
5006
+ return buffer.toString('latin1').replace(/^\uFEFF/, '');
5007
+ }
5008
+
5009
+ return withoutBom;
5010
+ }
5011
+
5012
+ private parseCsvRaw(content: string): { columns: string[]; rows: Record<string, string>[] } {
5013
+ const text = content.trimEnd();
5014
+ if (!text) return { columns: [], rows: [] };
5015
+
5016
+ // Detect delimiter from the first line before parsing the whole content
5017
+ const firstNewlineIdx = text.indexOf('\n');
5018
+ const firstLine = firstNewlineIdx === -1 ? text : text.slice(0, firstNewlineIdx);
5019
+ const delimiter = firstLine.includes(';') ? ';' : ',';
5020
+
5021
+ // RFC 4180-compliant parser: character-by-character so quoted fields
5022
+ // can span multiple lines without being split into separate records.
5023
+ const records: string[][] = [];
5024
+ let row: string[] = [];
5025
+ let field = '';
5026
+ let inQuotes = false;
5027
+ let i = 0;
5028
+
5029
+ const pushField = () => {
5030
+ // For unquoted fields trim surrounding whitespace;
5031
+ // for quoted fields the value is already the inner content.
5032
+ row.push(inQuotes ? field : field.trim());
5033
+ field = '';
5034
+ };
5035
+
5036
+ const pushRow = () => {
5037
+ pushField();
5038
+ // Skip rows where every cell is empty (blank lines between records)
5039
+ if (row.some((c) => c !== '')) {
5040
+ records.push(row);
5041
+ }
5042
+ row = [];
5043
+ inQuotes = false;
5044
+ };
5045
+
5046
+ while (i < text.length) {
5047
+ const ch = text[i];
5048
+
5049
+ if (inQuotes) {
5050
+ if (ch === '"') {
5051
+ if (i + 1 < text.length && text[i + 1] === '"') {
5052
+ // Escaped double-quote ("") → literal "
5053
+ field += '"';
5054
+ i += 2;
5055
+ } else {
5056
+ // Closing quote — exit quoted mode; consume any trailing
5057
+ // whitespace before the next delimiter so malformed exports
5058
+ // like `"value" ;` don't include a trailing space.
5059
+ inQuotes = false;
5060
+ i++;
5061
+ }
5062
+ } else {
5063
+ // Newlines inside quoted fields are literal content, not record separators
5064
+ field += ch;
5065
+ i++;
5066
+ }
5067
+ } else {
5068
+ if (ch === '"' && field.trim() === '') {
5069
+ // Opening quote — only valid at the start of a field
5070
+ inQuotes = true;
5071
+ field = '';
5072
+ i++;
5073
+ } else if (ch === delimiter) {
5074
+ pushField();
5075
+ field = '';
5076
+ i++;
5077
+ } else if (ch === '\r' && i + 1 < text.length && text[i + 1] === '\n') {
5078
+ pushRow();
5079
+ i += 2;
5080
+ } else if (ch === '\n') {
5081
+ pushRow();
5082
+ i++;
5083
+ } else {
5084
+ field += ch;
5085
+ i++;
5086
+ }
5087
+ }
5088
+ }
5089
+
5090
+ // Flush the last record (file may not end with a newline)
5091
+ if (field || row.length > 0) {
5092
+ row.push(inQuotes ? field : field.trim());
5093
+ if (row.some((c) => c !== '')) {
5094
+ records.push(row);
5095
+ }
5096
+ }
5097
+
5098
+ if (records.length === 0) return { columns: [], rows: [] };
5099
+
5100
+ const columns = records[0].map((c) => c.trim());
5101
+ const rows = records
5102
+ .slice(1)
5103
+ .filter((r) => r.some((c) => c !== ''))
5104
+ .map((cells) => {
5105
+ const row: Record<string, string> = {};
5106
+ columns.forEach((col, idx) => {
5107
+ row[col] = (cells[idx] ?? '').trim();
5108
+ });
5109
+ return row;
5110
+ });
5111
+
5112
+ return { columns, rows };
5113
+ }
5114
+
5115
+ async previewCsvImport(file: MulterFile) {
5116
+ const MAX_ROWS = 5000;
5117
+ const PREVIEW_ROWS = 20;
5118
+
5119
+ const content = this.getUploadedFileText(file);
5120
+ const { columns, rows } = this.parseCsvRaw(content);
5121
+
5122
+ if (rows.length > MAX_ROWS) {
5123
+ throw new BadRequestException(
5124
+ `File exceeds the maximum of ${MAX_ROWS} rows. Split the file and try again.`,
5125
+ );
5126
+ }
5127
+
5128
+ return {
5129
+ fileName: file.originalname,
5130
+ totalEstimated: rows.length,
5131
+ columns,
5132
+ preview: rows.slice(0, PREVIEW_ROWS),
5133
+ };
5134
+ }
5135
+
5136
+ async importFromCsv(
5137
+ file: MulterFile,
5138
+ mapping: Record<string, string>,
5139
+ companyId: number | undefined,
5140
+ locale: string,
5141
+ userId: number,
5142
+ ) {
5143
+ // 1. Validate the linked company, if provided
5144
+ if (companyId) {
5145
+ const company = await this.prismaService.person.findFirst({
5146
+ where: { id: companyId, type: 'company' },
5147
+ select: { id: true },
5148
+ });
5149
+ if (!company) {
5150
+ throw new BadRequestException(
5151
+ getLocaleText('personNotFound', locale, `Company with ID ${companyId} not found.`),
5152
+ );
5153
+ }
5154
+ }
5155
+
5156
+ // 1b. Validate mapping field names against allowed CRM fields
5157
+ const validCrmFields = new Set<string>(CRM_IMPORT_FIELDS);
5158
+ for (const val of Object.values(mapping)) {
5159
+ if (!validCrmFields.has(val)) {
5160
+ throw new BadRequestException(
5161
+ getLocaleText('validation.nameMustBeString', locale, `Invalid mapping field: "${val}"`),
5162
+ );
5163
+ }
5164
+ }
5165
+
5166
+ // 2. Pre-fetch contact type IDs once (avoid per-row roundtrips)
5167
+ const contactTypes = await this.prismaService.contact_type.findMany({
5168
+ where: { code: { in: ['EMAIL', 'PHONE', 'MOBILE'] } },
5169
+ select: { id: true, code: true },
5170
+ });
5171
+ const contactTypeMap = new Map(contactTypes.map((t) => [t.code.toUpperCase(), t.id]));
5172
+
5173
+ // 3. Pre-fetch document type IDs once
5174
+ const documentTypes = await this.prismaService.document_type.findMany({
5175
+ where: { code: { in: ['CPF', 'CNPJ'] } },
5176
+ select: { id: true, code: true },
5177
+ });
5178
+ const documentTypeMap = new Map(documentTypes.map((t) => [t.code.toUpperCase(), t.id]));
5179
+
5180
+ // 4. Parse CSV content
5181
+ const content = this.getUploadedFileText(file);
5182
+ const { rows } = this.parseCsvRaw(content);
5183
+
5184
+ let imported = 0;
5185
+ let skipped = 0;
5186
+ const errors: Array<{ row: number; message: string }> = [];
5187
+
5188
+ const BATCH_SIZE = 50;
5189
+ for (let batchStart = 0; batchStart < rows.length; batchStart += BATCH_SIZE) {
5190
+ const batch = rows.slice(batchStart, batchStart + BATCH_SIZE);
5191
+
5192
+ for (let i = 0; i < batch.length; i++) {
5193
+ const rowIndex = batchStart + i + 2; // +2: 1-indexed and skip header row
5194
+ const row = batch[i];
5195
+
5196
+ try {
5197
+ // Build a flat object: CRM field → CSV cell value
5198
+ const mapped: Record<string, string> = {};
5199
+ for (const [csvCol, crmField] of Object.entries(mapping)) {
5200
+ if (crmField !== '_ignore' && row[csvCol] !== undefined && row[csvCol] !== '') {
5201
+ mapped[crmField] = row[csvCol];
5202
+ }
5203
+ }
5204
+
5205
+ // 'name' is mandatory — skip silently when absent
5206
+ const name = this.normalizeTextOrNull(mapped['name']);
5207
+ if (!name) {
5208
+ skipped++;
5209
+ continue;
5210
+ }
5211
+
5212
+ // Coerce type (default: individual)
5213
+ const rawType = (mapped['type'] ?? '').toLowerCase();
5214
+ const personType: 'individual' | 'company' =
5215
+ rawType === 'company' ||
5216
+ rawType === 'empresa' ||
5217
+ rawType === 'jurídica' ||
5218
+ rawType === 'juridica'
5219
+ ? 'company'
5220
+ : 'individual';
5221
+
5222
+ // Coerce status (default: active)
5223
+ const rawStatus = (mapped['status'] ?? '').toLowerCase();
5224
+ const personStatus: 'active' | 'inactive' =
5225
+ rawStatus === 'inactive' || rawStatus === 'inativo' ? 'inactive' : 'active';
5226
+
5227
+ // Run each row inside its own transaction so a bad row never rolls
5228
+ // back successfully imported rows
5229
+ await this.prismaService.$transaction(async (tx) => {
5230
+ // (a) Base person record
5231
+ const person = await tx.person.create({
5232
+ data: { name, type: personType, status: personStatus },
5233
+ });
5234
+
5235
+ // (b) Subtype-specific table (person_individual / person_company)
5236
+ await this.syncPersonSubtypeData(
5237
+ tx,
5238
+ person.id,
5239
+ null,
5240
+ {
5241
+ type: personType,
5242
+ job_title:
5243
+ personType === 'individual' ? (mapped['job_title'] ?? null) : null,
5244
+ trade_name:
5245
+ personType === 'company' ? (mapped['trade_name'] ?? null) : null,
5246
+ website:
5247
+ personType === 'company' ? (mapped['website'] ?? null) : null,
5248
+ },
5249
+ locale,
5250
+ );
5251
+
5252
+ // (c) Metadata (notes, source, employer company, owner)
5253
+ await this.syncPersonMetadata(tx, person.id, {
5254
+ notes: mapped['notes'] ?? undefined,
5255
+ source: mapped['source'] ?? undefined,
5256
+ employer_company_id:
5257
+ personType === 'individual' && companyId ? companyId : undefined,
5258
+ owner_user_id: userId > 0 ? userId : undefined,
5259
+ });
5260
+
5261
+ // (d) Contacts — email takes priority as primary
5262
+ let needsPrimary = true;
5263
+
5264
+ const emailTypeId = contactTypeMap.get('EMAIL');
5265
+ if (mapped['email'] && emailTypeId) {
5266
+ await tx.contact.create({
5267
+ data: {
5268
+ person_id: person.id,
5269
+ contact_type_id: emailTypeId,
5270
+ value: mapped['email'],
5271
+ is_primary: true,
5272
+ },
5273
+ });
5274
+ needsPrimary = false;
5275
+ }
5276
+
5277
+ const phoneTypeId = contactTypeMap.get('PHONE');
5278
+ if (mapped['phone'] && phoneTypeId) {
5279
+ await tx.contact.create({
5280
+ data: {
5281
+ person_id: person.id,
5282
+ contact_type_id: phoneTypeId,
5283
+ value: mapped['phone'],
5284
+ is_primary: needsPrimary,
5285
+ },
5286
+ });
5287
+ needsPrimary = false;
5288
+ }
5289
+
5290
+ const mobileTypeId = contactTypeMap.get('MOBILE');
5291
+ if (mapped['mobile'] && mobileTypeId) {
5292
+ await tx.contact.create({
5293
+ data: {
5294
+ person_id: person.id,
5295
+ contact_type_id: mobileTypeId,
5296
+ value: mapped['mobile'],
5297
+ is_primary: needsPrimary,
5298
+ },
5299
+ });
5300
+ }
5301
+
5302
+ // (e) Address — only created when at least one address field is present
5303
+ const hasAddress =
5304
+ mapped['address_street'] ||
5305
+ mapped['address_city'] ||
5306
+ mapped['address_state'] ||
5307
+ mapped['address_zip'] ||
5308
+ mapped['address_country'];
5309
+
5310
+ if (hasAddress) {
5311
+ const address = await tx.address.create({
5312
+ data: {
5313
+ line1: mapped['address_street'] ?? '',
5314
+ line2: '',
5315
+ city: mapped['address_city'] ?? '',
5316
+ state: mapped['address_state'] ?? '',
5317
+ country_code: mapped['address_country'] || 'BRA',
5318
+ postal_code: mapped['address_zip'] ?? '',
5319
+ is_primary: true,
5320
+ address_type: 'residential',
5321
+ },
5322
+ });
5323
+ await tx.person_address.create({
5324
+ data: { person_id: person.id, address_id: address.id },
5325
+ });
5326
+ }
5327
+
5328
+ // (f) Documents — the document table has no is_primary column
5329
+ const cpfTypeId = documentTypeMap.get('CPF');
5330
+ if (mapped['cpf'] && cpfTypeId) {
5331
+ await tx.document.create({
5332
+ data: {
5333
+ person_id: person.id,
5334
+ document_type_id: cpfTypeId,
5335
+ value: mapped['cpf'],
5336
+ },
5337
+ });
5338
+ }
5339
+
5340
+ const cnpjTypeId = documentTypeMap.get('CNPJ');
5341
+ if (mapped['cnpj'] && cnpjTypeId) {
5342
+ await tx.document.create({
5343
+ data: {
5344
+ person_id: person.id,
5345
+ document_type_id: cnpjTypeId,
5346
+ value: mapped['cnpj'],
5347
+ },
5348
+ });
5349
+ }
5350
+ });
5351
+
5352
+ imported++;
5353
+ } catch (err: any) {
5354
+ errors.push({ row: rowIndex, message: err?.message ?? 'Unknown error' });
5355
+ }
5356
+ }
5357
+ }
5358
+
5359
+ return { imported, skipped, errors };
5360
+ }
4814
5361
  }