@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.
- package/README.md +225 -17
- package/dist/person/dto/account.dto.d.ts +5 -0
- package/dist/person/dto/account.dto.d.ts.map +1 -1
- package/dist/person/dto/account.dto.js +29 -0
- package/dist/person/dto/account.dto.js.map +1 -1
- package/dist/person/dto/import-preview.dto.d.ts +7 -0
- package/dist/person/dto/import-preview.dto.d.ts.map +1 -0
- package/dist/person/dto/import-preview.dto.js +7 -0
- package/dist/person/dto/import-preview.dto.js.map +1 -0
- package/dist/person/dto/import.dto.d.ts +15 -0
- package/dist/person/dto/import.dto.d.ts.map +1 -0
- package/dist/person/dto/import.dto.js +51 -0
- package/dist/person/dto/import.dto.js.map +1 -0
- package/dist/person/person.controller.d.ts +14 -0
- package/dist/person/person.controller.d.ts.map +1 -1
- package/dist/person/person.controller.js +53 -0
- package/dist/person/person.controller.js.map +1 -1
- package/dist/person/person.service.d.ts +19 -0
- package/dist/person/person.service.d.ts.map +1 -1
- package/dist/person/person.service.js +481 -67
- package/dist/person/person.service.js.map +1 -1
- package/dist/person-relation-type/person-relation-type.controller.d.ts +2 -2
- package/dist/person-relation-type/person-relation-type.service.d.ts +2 -2
- package/hedhog/data/route.yaml +6 -0
- package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +2242 -484
- package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +51 -0
- package/hedhog/frontend/app/accounts/page.tsx.ejs +181 -16
- package/hedhog/frontend/app/contact-type/page.tsx.ejs +223 -29
- package/hedhog/frontend/app/document-type/page.tsx.ejs +248 -37
- package/hedhog/frontend/app/follow-ups/page.tsx.ejs +129 -19
- package/hedhog/frontend/app/person/_components/person-field-with-create.tsx.ejs +78 -212
- package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +760 -178
- package/hedhog/frontend/app/person/_components/person-import-sheet.tsx.ejs +1120 -0
- package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +171 -4
- package/hedhog/frontend/app/person/page.tsx.ejs +17 -0
- package/hedhog/frontend/app/pipeline/_components/lead-proposals-tab.tsx.ejs +160 -35
- package/hedhog/frontend/messages/en.json +104 -2
- package/hedhog/frontend/messages/pt.json +111 -9
- package/package.json +5 -5
- package/src/person/dto/account.dto.ts +31 -0
- package/src/person/dto/import-preview.dto.ts +6 -0
- package/src/person/dto/import.dto.ts +61 -0
- package/src/person/person.controller.ts +74 -12
- 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:
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
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
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
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
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
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
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
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(
|
|
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
|
}
|