@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
|
@@ -21,6 +21,7 @@ const core_1 = require("@hed-hog/core");
|
|
|
21
21
|
const common_1 = require("@nestjs/common");
|
|
22
22
|
const account_dto_1 = require("./dto/account.dto");
|
|
23
23
|
const create_interaction_dto_1 = require("./dto/create-interaction.dto");
|
|
24
|
+
const import_dto_1 = require("./dto/import.dto");
|
|
24
25
|
const CONTACT_ALLOW_COMPANY_REGISTRATION_SETTING = 'contact-allow-company-registration';
|
|
25
26
|
const CONTACT_OWNER_ROLE_SLUG = 'owner-contact';
|
|
26
27
|
const CONTACT_OWNER_ALLOWED_ROLE_SLUGS = [
|
|
@@ -655,7 +656,22 @@ let PersonService = PersonService_1 = class PersonService {
|
|
|
655
656
|
person_metadata: {
|
|
656
657
|
some: {
|
|
657
658
|
key: OWNER_USER_METADATA_KEY,
|
|
658
|
-
value:
|
|
659
|
+
value: {
|
|
660
|
+
equals: ownerUserId,
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
const employerCompanyId = this.coerceNumber(paginationParams.employer_company_id);
|
|
667
|
+
if (employerCompanyId > 0) {
|
|
668
|
+
metadataFilters.push({
|
|
669
|
+
person_metadata: {
|
|
670
|
+
some: {
|
|
671
|
+
key: EMPLOYER_COMPANY_METADATA_KEY,
|
|
672
|
+
value: {
|
|
673
|
+
equals: employerCompanyId,
|
|
674
|
+
},
|
|
659
675
|
},
|
|
660
676
|
},
|
|
661
677
|
});
|
|
@@ -665,7 +681,9 @@ let PersonService = PersonService_1 = class PersonService {
|
|
|
665
681
|
person_metadata: {
|
|
666
682
|
some: {
|
|
667
683
|
key: SOURCE_METADATA_KEY,
|
|
668
|
-
value:
|
|
684
|
+
value: {
|
|
685
|
+
equals: paginationParams.source,
|
|
686
|
+
},
|
|
669
687
|
},
|
|
670
688
|
},
|
|
671
689
|
});
|
|
@@ -676,7 +694,9 @@ let PersonService = PersonService_1 = class PersonService {
|
|
|
676
694
|
person_metadata: {
|
|
677
695
|
some: {
|
|
678
696
|
key: LIFECYCLE_STAGE_METADATA_KEY,
|
|
679
|
-
value:
|
|
697
|
+
value: {
|
|
698
|
+
equals: paginationParams.lifecycle_stage,
|
|
699
|
+
},
|
|
680
700
|
},
|
|
681
701
|
},
|
|
682
702
|
});
|
|
@@ -824,8 +844,7 @@ let PersonService = PersonService_1 = class PersonService {
|
|
|
824
844
|
await this.syncPersonMetadata(tx, person.id, {
|
|
825
845
|
owner_user_id: data.owner_user_id,
|
|
826
846
|
});
|
|
827
|
-
await this.
|
|
828
|
-
await this.upsertPrimaryAccountContact(tx, person.id, 'PHONE', data.phone);
|
|
847
|
+
await this.syncAccountArrays(tx, person.id, data, locale);
|
|
829
848
|
return {
|
|
830
849
|
success: true,
|
|
831
850
|
id: person.id,
|
|
@@ -855,8 +874,7 @@ let PersonService = PersonService_1 = class PersonService {
|
|
|
855
874
|
await this.syncPersonMetadata(tx, id, {
|
|
856
875
|
owner_user_id: data.owner_user_id,
|
|
857
876
|
});
|
|
858
|
-
await this.
|
|
859
|
-
await this.upsertPrimaryAccountContact(tx, id, 'PHONE', data.phone);
|
|
877
|
+
await this.syncAccountArrays(tx, id, data, locale);
|
|
860
878
|
return {
|
|
861
879
|
success: true,
|
|
862
880
|
id,
|
|
@@ -2071,15 +2089,29 @@ let PersonService = PersonService_1 = class PersonService {
|
|
|
2071
2089
|
if (fromStage === toStage) {
|
|
2072
2090
|
return;
|
|
2073
2091
|
}
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2092
|
+
const fromStageSql = fromStage
|
|
2093
|
+
? api_prisma_1.Prisma.sql `CAST(${fromStage} AS crm_stage_history_from_stage_f4181e9874_enum)`
|
|
2094
|
+
: api_prisma_1.Prisma.sql `NULL`;
|
|
2095
|
+
await client.$executeRaw(api_prisma_1.Prisma.sql `
|
|
2096
|
+
INSERT INTO crm_stage_history (
|
|
2097
|
+
person_id,
|
|
2098
|
+
from_stage,
|
|
2099
|
+
to_stage,
|
|
2100
|
+
changed_by_user_id,
|
|
2101
|
+
changed_at,
|
|
2102
|
+
created_at,
|
|
2103
|
+
updated_at
|
|
2104
|
+
)
|
|
2105
|
+
VALUES (
|
|
2106
|
+
${personId},
|
|
2107
|
+
${fromStageSql},
|
|
2108
|
+
CAST(${toStage} AS crm_stage_history_to_stage_ca5bd55f9f_enum),
|
|
2109
|
+
${changedByUserId},
|
|
2110
|
+
NOW(),
|
|
2111
|
+
NOW(),
|
|
2112
|
+
NOW()
|
|
2113
|
+
)
|
|
2114
|
+
`);
|
|
2083
2115
|
}
|
|
2084
2116
|
async syncPersonSubtypeData(tx, personId, currentType, data, locale) {
|
|
2085
2117
|
var _a, _b, _c;
|
|
@@ -2315,6 +2347,52 @@ let PersonService = PersonService_1 = class PersonService {
|
|
|
2315
2347
|
}
|
|
2316
2348
|
return null;
|
|
2317
2349
|
}
|
|
2350
|
+
async syncAccountArrays(tx, personId, data, locale) {
|
|
2351
|
+
if (Object.prototype.hasOwnProperty.call(data, 'contacts')) {
|
|
2352
|
+
const incomingContacts = Array.isArray(data.contacts) ? data.contacts : [];
|
|
2353
|
+
this.validateSinglePrimaryPerType(incomingContacts, 'contact_type_id', locale, 'moreThanOnePrimaryContact', 'More than one contact of the same type cannot be marked as primary.');
|
|
2354
|
+
await this.syncContacts(tx, personId, incomingContacts);
|
|
2355
|
+
}
|
|
2356
|
+
if (Object.prototype.hasOwnProperty.call(data, 'addresses')) {
|
|
2357
|
+
const incomingAddresses = Array.isArray(data.addresses) ? data.addresses : [];
|
|
2358
|
+
this.validateSinglePrimaryPerType(incomingAddresses, 'address_type', locale, 'moreThanOnePrimaryAddress', 'More than one address of the same type cannot be marked as primary.');
|
|
2359
|
+
await this.syncAddresses(tx, personId, incomingAddresses, locale);
|
|
2360
|
+
}
|
|
2361
|
+
if (Object.prototype.hasOwnProperty.call(data, 'documents')) {
|
|
2362
|
+
const incomingDocuments = Array.isArray(data.documents) ? data.documents : [];
|
|
2363
|
+
this.validateSinglePrimaryPerType(incomingDocuments, 'document_type_id', locale, 'moreThanOnePrimaryDocument', 'More than one document of the same type cannot be marked as primary.');
|
|
2364
|
+
await this.syncDocuments(tx, personId, incomingDocuments);
|
|
2365
|
+
}
|
|
2366
|
+
if (Object.prototype.hasOwnProperty.call(data, 'email')) {
|
|
2367
|
+
await this.upsertPrimaryAccountContact(tx, personId, 'EMAIL', data.email);
|
|
2368
|
+
}
|
|
2369
|
+
if (Object.prototype.hasOwnProperty.call(data, 'phone')) {
|
|
2370
|
+
await this.upsertPrimaryAccountContact(tx, personId, 'PHONE', data.phone);
|
|
2371
|
+
}
|
|
2372
|
+
if (Object.prototype.hasOwnProperty.call(data, 'collaborator_person_ids')) {
|
|
2373
|
+
await this.attachCollaboratorsToCompany(tx, personId, data.collaborator_person_ids);
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
async attachCollaboratorsToCompany(tx, companyId, collaboratorPersonIds) {
|
|
2377
|
+
const normalizedCollaboratorIds = Array.from(new Set((Array.isArray(collaboratorPersonIds) ? collaboratorPersonIds : [])
|
|
2378
|
+
.map((value) => Number(value))
|
|
2379
|
+
.filter((value) => Number.isInteger(value) && value > 0 && value !== companyId)));
|
|
2380
|
+
if (normalizedCollaboratorIds.length === 0) {
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
const collaborators = await tx.person.findMany({
|
|
2384
|
+
where: {
|
|
2385
|
+
id: { in: normalizedCollaboratorIds },
|
|
2386
|
+
type: 'individual',
|
|
2387
|
+
},
|
|
2388
|
+
select: {
|
|
2389
|
+
id: true,
|
|
2390
|
+
},
|
|
2391
|
+
});
|
|
2392
|
+
for (const collaborator of collaborators) {
|
|
2393
|
+
await this.upsertMetadataValue(tx, collaborator.id, EMPLOYER_COMPANY_METADATA_KEY, companyId);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2318
2396
|
async syncContacts(tx, personId, incomingContacts) {
|
|
2319
2397
|
const existingContacts = await tx.contact.findMany({ where: { person_id: personId } });
|
|
2320
2398
|
for (const contact of incomingContacts) {
|
|
@@ -2740,62 +2818,86 @@ let PersonService = PersonService_1 = class PersonService {
|
|
|
2740
2818
|
const priority = 'medium';
|
|
2741
2819
|
const type = 'task';
|
|
2742
2820
|
if (existing) {
|
|
2743
|
-
await tx.
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
},
|
|
2756
|
-
});
|
|
2821
|
+
await tx.$executeRaw(api_prisma_1.Prisma.sql `
|
|
2822
|
+
UPDATE crm_activity
|
|
2823
|
+
SET
|
|
2824
|
+
owner_user_id = ${ownerUserId},
|
|
2825
|
+
type = CAST(${'task'} AS crm_activity_type_77c8508dad_enum),
|
|
2826
|
+
subject = ${this.getFollowupActivitySubject()},
|
|
2827
|
+
notes = ${normalizedNotes},
|
|
2828
|
+
due_at = CAST(${dueAt} AS TIMESTAMPTZ),
|
|
2829
|
+
priority = CAST(${'medium'} AS crm_activity_priority_b4e2dbbb4d_enum),
|
|
2830
|
+
updated_at = NOW()
|
|
2831
|
+
WHERE id = ${existing.id}
|
|
2832
|
+
`);
|
|
2757
2833
|
return;
|
|
2758
2834
|
}
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2835
|
+
await tx.$executeRaw(api_prisma_1.Prisma.sql `
|
|
2836
|
+
INSERT INTO crm_activity (
|
|
2837
|
+
person_id,
|
|
2838
|
+
owner_user_id,
|
|
2839
|
+
created_by_user_id,
|
|
2840
|
+
type,
|
|
2841
|
+
subject,
|
|
2842
|
+
notes,
|
|
2843
|
+
due_at,
|
|
2844
|
+
priority,
|
|
2845
|
+
source_kind,
|
|
2846
|
+
created_at,
|
|
2847
|
+
updated_at
|
|
2848
|
+
)
|
|
2849
|
+
VALUES (
|
|
2850
|
+
${personId},
|
|
2851
|
+
${ownerUserId},
|
|
2852
|
+
${actorUserId},
|
|
2853
|
+
CAST(${'task'} AS crm_activity_type_77c8508dad_enum),
|
|
2854
|
+
${this.getFollowupActivitySubject()},
|
|
2855
|
+
${normalizedNotes},
|
|
2856
|
+
CAST(${dueAt} AS TIMESTAMPTZ),
|
|
2857
|
+
CAST(${'medium'} AS crm_activity_priority_b4e2dbbb4d_enum),
|
|
2858
|
+
CAST(${'followup'} AS crm_activity_source_kind_a09c5de478_enum),
|
|
2859
|
+
NOW(),
|
|
2860
|
+
NOW()
|
|
2861
|
+
)
|
|
2862
|
+
`);
|
|
2776
2863
|
}
|
|
2777
2864
|
async createCompletedInteractionActivity(tx, { personId, ownerUserId, interaction, actorUserId, }) {
|
|
2778
2865
|
const completedAt = new Date(interaction.created_at);
|
|
2779
2866
|
const type = interaction.type;
|
|
2780
2867
|
const priority = 'medium';
|
|
2781
2868
|
const sourceKind = 'interaction';
|
|
2782
|
-
await tx.
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2869
|
+
await tx.$executeRaw(api_prisma_1.Prisma.sql `
|
|
2870
|
+
INSERT INTO crm_activity (
|
|
2871
|
+
person_id,
|
|
2872
|
+
owner_user_id,
|
|
2873
|
+
created_by_user_id,
|
|
2874
|
+
completed_by_user_id,
|
|
2875
|
+
type,
|
|
2876
|
+
subject,
|
|
2877
|
+
notes,
|
|
2878
|
+
due_at,
|
|
2879
|
+
completed_at,
|
|
2880
|
+
priority,
|
|
2881
|
+
source_kind,
|
|
2882
|
+
created_at,
|
|
2883
|
+
updated_at
|
|
2884
|
+
)
|
|
2885
|
+
VALUES (
|
|
2886
|
+
${personId},
|
|
2887
|
+
${ownerUserId},
|
|
2888
|
+
${actorUserId},
|
|
2889
|
+
${actorUserId},
|
|
2890
|
+
CAST(${interaction.type} AS crm_activity_type_77c8508dad_enum),
|
|
2891
|
+
${this.getInteractionActivitySubject(interaction.type)},
|
|
2892
|
+
${this.normalizeTextOrNull(interaction.notes)},
|
|
2893
|
+
CAST(${interaction.created_at} AS TIMESTAMPTZ),
|
|
2894
|
+
CAST(${interaction.created_at} AS TIMESTAMPTZ),
|
|
2895
|
+
CAST(${'medium'} AS crm_activity_priority_b4e2dbbb4d_enum),
|
|
2896
|
+
CAST(${'interaction'} AS crm_activity_source_kind_a09c5de478_enum),
|
|
2897
|
+
CAST(${interaction.created_at} AS TIMESTAMPTZ),
|
|
2898
|
+
NOW()
|
|
2899
|
+
)
|
|
2900
|
+
`);
|
|
2799
2901
|
}
|
|
2800
2902
|
getFollowupActivitySubject() {
|
|
2801
2903
|
return 'Follow-up';
|
|
@@ -3058,7 +3160,7 @@ let PersonService = PersonService_1 = class PersonService {
|
|
|
3058
3160
|
filters.push(api_prisma_1.Prisma.sql `AND p.status = ${status}`);
|
|
3059
3161
|
}
|
|
3060
3162
|
if (lifecycleStage && lifecycleStage !== 'all') {
|
|
3061
|
-
filters.push(api_prisma_1.Prisma.sql `AND pc.account_lifecycle_stage = ${lifecycleStage}`);
|
|
3163
|
+
filters.push(api_prisma_1.Prisma.sql `AND pc.account_lifecycle_stage = CAST(${lifecycleStage} AS person_company_account_lifecycle_stage_680de09d00_enum)`);
|
|
3062
3164
|
}
|
|
3063
3165
|
if (search) {
|
|
3064
3166
|
const searchLike = `%${search}%`;
|
|
@@ -3133,10 +3235,10 @@ let PersonService = PersonService_1 = class PersonService {
|
|
|
3133
3235
|
filters.push(api_prisma_1.Prisma.sql `AND a.completed_at IS NOT NULL`);
|
|
3134
3236
|
}
|
|
3135
3237
|
if (type && type !== 'all') {
|
|
3136
|
-
filters.push(api_prisma_1.Prisma.sql `AND a.type = ${type}`);
|
|
3238
|
+
filters.push(api_prisma_1.Prisma.sql `AND a.type = CAST(${type} AS crm_activity_type_77c8508dad_enum)`);
|
|
3137
3239
|
}
|
|
3138
3240
|
if (priority && priority !== 'all') {
|
|
3139
|
-
filters.push(api_prisma_1.Prisma.sql `AND a.priority = ${priority}`);
|
|
3241
|
+
filters.push(api_prisma_1.Prisma.sql `AND a.priority = CAST(${priority} AS crm_activity_priority_b4e2dbbb4d_enum)`);
|
|
3140
3242
|
}
|
|
3141
3243
|
if (search) {
|
|
3142
3244
|
const searchLike = `%${search}%`;
|
|
@@ -3283,6 +3385,318 @@ let PersonService = PersonService_1 = class PersonService {
|
|
|
3283
3385
|
trade_name: tradeName,
|
|
3284
3386
|
};
|
|
3285
3387
|
}
|
|
3388
|
+
// ─── CSV Import ──────────────────────────────────────────────────────────────
|
|
3389
|
+
getUploadedFileText(file) {
|
|
3390
|
+
let buffer = file.buffer;
|
|
3391
|
+
if (!buffer || buffer.length === 0) {
|
|
3392
|
+
throw new common_1.BadRequestException('Uploaded file is empty');
|
|
3393
|
+
}
|
|
3394
|
+
const utf8 = buffer.toString('utf8');
|
|
3395
|
+
// Replace UTF-8 BOM if present
|
|
3396
|
+
const withoutBom = utf8.replace(/^\uFEFF/, '');
|
|
3397
|
+
if (withoutBom.includes('\uFFFD')) {
|
|
3398
|
+
return buffer.toString('latin1').replace(/^\uFEFF/, '');
|
|
3399
|
+
}
|
|
3400
|
+
return withoutBom;
|
|
3401
|
+
}
|
|
3402
|
+
parseCsvRaw(content) {
|
|
3403
|
+
const text = content.trimEnd();
|
|
3404
|
+
if (!text)
|
|
3405
|
+
return { columns: [], rows: [] };
|
|
3406
|
+
// Detect delimiter from the first line before parsing the whole content
|
|
3407
|
+
const firstNewlineIdx = text.indexOf('\n');
|
|
3408
|
+
const firstLine = firstNewlineIdx === -1 ? text : text.slice(0, firstNewlineIdx);
|
|
3409
|
+
const delimiter = firstLine.includes(';') ? ';' : ',';
|
|
3410
|
+
// RFC 4180-compliant parser: character-by-character so quoted fields
|
|
3411
|
+
// can span multiple lines without being split into separate records.
|
|
3412
|
+
const records = [];
|
|
3413
|
+
let row = [];
|
|
3414
|
+
let field = '';
|
|
3415
|
+
let inQuotes = false;
|
|
3416
|
+
let i = 0;
|
|
3417
|
+
const pushField = () => {
|
|
3418
|
+
// For unquoted fields trim surrounding whitespace;
|
|
3419
|
+
// for quoted fields the value is already the inner content.
|
|
3420
|
+
row.push(inQuotes ? field : field.trim());
|
|
3421
|
+
field = '';
|
|
3422
|
+
};
|
|
3423
|
+
const pushRow = () => {
|
|
3424
|
+
pushField();
|
|
3425
|
+
// Skip rows where every cell is empty (blank lines between records)
|
|
3426
|
+
if (row.some((c) => c !== '')) {
|
|
3427
|
+
records.push(row);
|
|
3428
|
+
}
|
|
3429
|
+
row = [];
|
|
3430
|
+
inQuotes = false;
|
|
3431
|
+
};
|
|
3432
|
+
while (i < text.length) {
|
|
3433
|
+
const ch = text[i];
|
|
3434
|
+
if (inQuotes) {
|
|
3435
|
+
if (ch === '"') {
|
|
3436
|
+
if (i + 1 < text.length && text[i + 1] === '"') {
|
|
3437
|
+
// Escaped double-quote ("") → literal "
|
|
3438
|
+
field += '"';
|
|
3439
|
+
i += 2;
|
|
3440
|
+
}
|
|
3441
|
+
else {
|
|
3442
|
+
// Closing quote — exit quoted mode; consume any trailing
|
|
3443
|
+
// whitespace before the next delimiter so malformed exports
|
|
3444
|
+
// like `"value" ;` don't include a trailing space.
|
|
3445
|
+
inQuotes = false;
|
|
3446
|
+
i++;
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
else {
|
|
3450
|
+
// Newlines inside quoted fields are literal content, not record separators
|
|
3451
|
+
field += ch;
|
|
3452
|
+
i++;
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
else {
|
|
3456
|
+
if (ch === '"' && field.trim() === '') {
|
|
3457
|
+
// Opening quote — only valid at the start of a field
|
|
3458
|
+
inQuotes = true;
|
|
3459
|
+
field = '';
|
|
3460
|
+
i++;
|
|
3461
|
+
}
|
|
3462
|
+
else if (ch === delimiter) {
|
|
3463
|
+
pushField();
|
|
3464
|
+
field = '';
|
|
3465
|
+
i++;
|
|
3466
|
+
}
|
|
3467
|
+
else if (ch === '\r' && i + 1 < text.length && text[i + 1] === '\n') {
|
|
3468
|
+
pushRow();
|
|
3469
|
+
i += 2;
|
|
3470
|
+
}
|
|
3471
|
+
else if (ch === '\n') {
|
|
3472
|
+
pushRow();
|
|
3473
|
+
i++;
|
|
3474
|
+
}
|
|
3475
|
+
else {
|
|
3476
|
+
field += ch;
|
|
3477
|
+
i++;
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
}
|
|
3481
|
+
// Flush the last record (file may not end with a newline)
|
|
3482
|
+
if (field || row.length > 0) {
|
|
3483
|
+
row.push(inQuotes ? field : field.trim());
|
|
3484
|
+
if (row.some((c) => c !== '')) {
|
|
3485
|
+
records.push(row);
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
if (records.length === 0)
|
|
3489
|
+
return { columns: [], rows: [] };
|
|
3490
|
+
const columns = records[0].map((c) => c.trim());
|
|
3491
|
+
const rows = records
|
|
3492
|
+
.slice(1)
|
|
3493
|
+
.filter((r) => r.some((c) => c !== ''))
|
|
3494
|
+
.map((cells) => {
|
|
3495
|
+
const row = {};
|
|
3496
|
+
columns.forEach((col, idx) => {
|
|
3497
|
+
var _a;
|
|
3498
|
+
row[col] = ((_a = cells[idx]) !== null && _a !== void 0 ? _a : '').trim();
|
|
3499
|
+
});
|
|
3500
|
+
return row;
|
|
3501
|
+
});
|
|
3502
|
+
return { columns, rows };
|
|
3503
|
+
}
|
|
3504
|
+
async previewCsvImport(file) {
|
|
3505
|
+
const MAX_ROWS = 5000;
|
|
3506
|
+
const PREVIEW_ROWS = 20;
|
|
3507
|
+
const content = this.getUploadedFileText(file);
|
|
3508
|
+
const { columns, rows } = this.parseCsvRaw(content);
|
|
3509
|
+
if (rows.length > MAX_ROWS) {
|
|
3510
|
+
throw new common_1.BadRequestException(`File exceeds the maximum of ${MAX_ROWS} rows. Split the file and try again.`);
|
|
3511
|
+
}
|
|
3512
|
+
return {
|
|
3513
|
+
fileName: file.originalname,
|
|
3514
|
+
totalEstimated: rows.length,
|
|
3515
|
+
columns,
|
|
3516
|
+
preview: rows.slice(0, PREVIEW_ROWS),
|
|
3517
|
+
};
|
|
3518
|
+
}
|
|
3519
|
+
async importFromCsv(file, mapping, companyId, locale, userId) {
|
|
3520
|
+
var _a, _b, _c;
|
|
3521
|
+
// 1. Validate the linked company, if provided
|
|
3522
|
+
if (companyId) {
|
|
3523
|
+
const company = await this.prismaService.person.findFirst({
|
|
3524
|
+
where: { id: companyId, type: 'company' },
|
|
3525
|
+
select: { id: true },
|
|
3526
|
+
});
|
|
3527
|
+
if (!company) {
|
|
3528
|
+
throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('personNotFound', locale, `Company with ID ${companyId} not found.`));
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
// 1b. Validate mapping field names against allowed CRM fields
|
|
3532
|
+
const validCrmFields = new Set(import_dto_1.CRM_IMPORT_FIELDS);
|
|
3533
|
+
for (const val of Object.values(mapping)) {
|
|
3534
|
+
if (!validCrmFields.has(val)) {
|
|
3535
|
+
throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('validation.nameMustBeString', locale, `Invalid mapping field: "${val}"`));
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
// 2. Pre-fetch contact type IDs once (avoid per-row roundtrips)
|
|
3539
|
+
const contactTypes = await this.prismaService.contact_type.findMany({
|
|
3540
|
+
where: { code: { in: ['EMAIL', 'PHONE', 'MOBILE'] } },
|
|
3541
|
+
select: { id: true, code: true },
|
|
3542
|
+
});
|
|
3543
|
+
const contactTypeMap = new Map(contactTypes.map((t) => [t.code.toUpperCase(), t.id]));
|
|
3544
|
+
// 3. Pre-fetch document type IDs once
|
|
3545
|
+
const documentTypes = await this.prismaService.document_type.findMany({
|
|
3546
|
+
where: { code: { in: ['CPF', 'CNPJ'] } },
|
|
3547
|
+
select: { id: true, code: true },
|
|
3548
|
+
});
|
|
3549
|
+
const documentTypeMap = new Map(documentTypes.map((t) => [t.code.toUpperCase(), t.id]));
|
|
3550
|
+
// 4. Parse CSV content
|
|
3551
|
+
const content = this.getUploadedFileText(file);
|
|
3552
|
+
const { rows } = this.parseCsvRaw(content);
|
|
3553
|
+
let imported = 0;
|
|
3554
|
+
let skipped = 0;
|
|
3555
|
+
const errors = [];
|
|
3556
|
+
const BATCH_SIZE = 50;
|
|
3557
|
+
for (let batchStart = 0; batchStart < rows.length; batchStart += BATCH_SIZE) {
|
|
3558
|
+
const batch = rows.slice(batchStart, batchStart + BATCH_SIZE);
|
|
3559
|
+
for (let i = 0; i < batch.length; i++) {
|
|
3560
|
+
const rowIndex = batchStart + i + 2; // +2: 1-indexed and skip header row
|
|
3561
|
+
const row = batch[i];
|
|
3562
|
+
try {
|
|
3563
|
+
// Build a flat object: CRM field → CSV cell value
|
|
3564
|
+
const mapped = {};
|
|
3565
|
+
for (const [csvCol, crmField] of Object.entries(mapping)) {
|
|
3566
|
+
if (crmField !== '_ignore' && row[csvCol] !== undefined && row[csvCol] !== '') {
|
|
3567
|
+
mapped[crmField] = row[csvCol];
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
// 'name' is mandatory — skip silently when absent
|
|
3571
|
+
const name = this.normalizeTextOrNull(mapped['name']);
|
|
3572
|
+
if (!name) {
|
|
3573
|
+
skipped++;
|
|
3574
|
+
continue;
|
|
3575
|
+
}
|
|
3576
|
+
// Coerce type (default: individual)
|
|
3577
|
+
const rawType = ((_a = mapped['type']) !== null && _a !== void 0 ? _a : '').toLowerCase();
|
|
3578
|
+
const personType = rawType === 'company' ||
|
|
3579
|
+
rawType === 'empresa' ||
|
|
3580
|
+
rawType === 'jurídica' ||
|
|
3581
|
+
rawType === 'juridica'
|
|
3582
|
+
? 'company'
|
|
3583
|
+
: 'individual';
|
|
3584
|
+
// Coerce status (default: active)
|
|
3585
|
+
const rawStatus = ((_b = mapped['status']) !== null && _b !== void 0 ? _b : '').toLowerCase();
|
|
3586
|
+
const personStatus = rawStatus === 'inactive' || rawStatus === 'inativo' ? 'inactive' : 'active';
|
|
3587
|
+
// Run each row inside its own transaction so a bad row never rolls
|
|
3588
|
+
// back successfully imported rows
|
|
3589
|
+
await this.prismaService.$transaction(async (tx) => {
|
|
3590
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
3591
|
+
// (a) Base person record
|
|
3592
|
+
const person = await tx.person.create({
|
|
3593
|
+
data: { name, type: personType, status: personStatus },
|
|
3594
|
+
});
|
|
3595
|
+
// (b) Subtype-specific table (person_individual / person_company)
|
|
3596
|
+
await this.syncPersonSubtypeData(tx, person.id, null, {
|
|
3597
|
+
type: personType,
|
|
3598
|
+
job_title: personType === 'individual' ? ((_a = mapped['job_title']) !== null && _a !== void 0 ? _a : null) : null,
|
|
3599
|
+
trade_name: personType === 'company' ? ((_b = mapped['trade_name']) !== null && _b !== void 0 ? _b : null) : null,
|
|
3600
|
+
website: personType === 'company' ? ((_c = mapped['website']) !== null && _c !== void 0 ? _c : null) : null,
|
|
3601
|
+
}, locale);
|
|
3602
|
+
// (c) Metadata (notes, source, employer company, owner)
|
|
3603
|
+
await this.syncPersonMetadata(tx, person.id, {
|
|
3604
|
+
notes: (_d = mapped['notes']) !== null && _d !== void 0 ? _d : undefined,
|
|
3605
|
+
source: (_e = mapped['source']) !== null && _e !== void 0 ? _e : undefined,
|
|
3606
|
+
employer_company_id: personType === 'individual' && companyId ? companyId : undefined,
|
|
3607
|
+
owner_user_id: userId > 0 ? userId : undefined,
|
|
3608
|
+
});
|
|
3609
|
+
// (d) Contacts — email takes priority as primary
|
|
3610
|
+
let needsPrimary = true;
|
|
3611
|
+
const emailTypeId = contactTypeMap.get('EMAIL');
|
|
3612
|
+
if (mapped['email'] && emailTypeId) {
|
|
3613
|
+
await tx.contact.create({
|
|
3614
|
+
data: {
|
|
3615
|
+
person_id: person.id,
|
|
3616
|
+
contact_type_id: emailTypeId,
|
|
3617
|
+
value: mapped['email'],
|
|
3618
|
+
is_primary: true,
|
|
3619
|
+
},
|
|
3620
|
+
});
|
|
3621
|
+
needsPrimary = false;
|
|
3622
|
+
}
|
|
3623
|
+
const phoneTypeId = contactTypeMap.get('PHONE');
|
|
3624
|
+
if (mapped['phone'] && phoneTypeId) {
|
|
3625
|
+
await tx.contact.create({
|
|
3626
|
+
data: {
|
|
3627
|
+
person_id: person.id,
|
|
3628
|
+
contact_type_id: phoneTypeId,
|
|
3629
|
+
value: mapped['phone'],
|
|
3630
|
+
is_primary: needsPrimary,
|
|
3631
|
+
},
|
|
3632
|
+
});
|
|
3633
|
+
needsPrimary = false;
|
|
3634
|
+
}
|
|
3635
|
+
const mobileTypeId = contactTypeMap.get('MOBILE');
|
|
3636
|
+
if (mapped['mobile'] && mobileTypeId) {
|
|
3637
|
+
await tx.contact.create({
|
|
3638
|
+
data: {
|
|
3639
|
+
person_id: person.id,
|
|
3640
|
+
contact_type_id: mobileTypeId,
|
|
3641
|
+
value: mapped['mobile'],
|
|
3642
|
+
is_primary: needsPrimary,
|
|
3643
|
+
},
|
|
3644
|
+
});
|
|
3645
|
+
}
|
|
3646
|
+
// (e) Address — only created when at least one address field is present
|
|
3647
|
+
const hasAddress = mapped['address_street'] ||
|
|
3648
|
+
mapped['address_city'] ||
|
|
3649
|
+
mapped['address_state'] ||
|
|
3650
|
+
mapped['address_zip'] ||
|
|
3651
|
+
mapped['address_country'];
|
|
3652
|
+
if (hasAddress) {
|
|
3653
|
+
const address = await tx.address.create({
|
|
3654
|
+
data: {
|
|
3655
|
+
line1: (_f = mapped['address_street']) !== null && _f !== void 0 ? _f : '',
|
|
3656
|
+
line2: '',
|
|
3657
|
+
city: (_g = mapped['address_city']) !== null && _g !== void 0 ? _g : '',
|
|
3658
|
+
state: (_h = mapped['address_state']) !== null && _h !== void 0 ? _h : '',
|
|
3659
|
+
country_code: mapped['address_country'] || 'BRA',
|
|
3660
|
+
postal_code: (_j = mapped['address_zip']) !== null && _j !== void 0 ? _j : '',
|
|
3661
|
+
is_primary: true,
|
|
3662
|
+
address_type: 'residential',
|
|
3663
|
+
},
|
|
3664
|
+
});
|
|
3665
|
+
await tx.person_address.create({
|
|
3666
|
+
data: { person_id: person.id, address_id: address.id },
|
|
3667
|
+
});
|
|
3668
|
+
}
|
|
3669
|
+
// (f) Documents — the document table has no is_primary column
|
|
3670
|
+
const cpfTypeId = documentTypeMap.get('CPF');
|
|
3671
|
+
if (mapped['cpf'] && cpfTypeId) {
|
|
3672
|
+
await tx.document.create({
|
|
3673
|
+
data: {
|
|
3674
|
+
person_id: person.id,
|
|
3675
|
+
document_type_id: cpfTypeId,
|
|
3676
|
+
value: mapped['cpf'],
|
|
3677
|
+
},
|
|
3678
|
+
});
|
|
3679
|
+
}
|
|
3680
|
+
const cnpjTypeId = documentTypeMap.get('CNPJ');
|
|
3681
|
+
if (mapped['cnpj'] && cnpjTypeId) {
|
|
3682
|
+
await tx.document.create({
|
|
3683
|
+
data: {
|
|
3684
|
+
person_id: person.id,
|
|
3685
|
+
document_type_id: cnpjTypeId,
|
|
3686
|
+
value: mapped['cnpj'],
|
|
3687
|
+
},
|
|
3688
|
+
});
|
|
3689
|
+
}
|
|
3690
|
+
});
|
|
3691
|
+
imported++;
|
|
3692
|
+
}
|
|
3693
|
+
catch (err) {
|
|
3694
|
+
errors.push({ row: rowIndex, message: (_c = err === null || err === void 0 ? void 0 : err.message) !== null && _c !== void 0 ? _c : 'Unknown error' });
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
return { imported, skipped, errors };
|
|
3699
|
+
}
|
|
3286
3700
|
};
|
|
3287
3701
|
exports.PersonService = PersonService;
|
|
3288
3702
|
exports.PersonService = PersonService = PersonService_1 = __decorate([
|