twenty-import-csv 1.0.0
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/.env.example +16 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/dist/browser-automation.d.ts +24 -0
- package/dist/browser-automation.d.ts.map +1 -0
- package/dist/browser-automation.js +295 -0
- package/dist/browser-automation.js.map +1 -0
- package/dist/cli-browser.d.ts +3 -0
- package/dist/cli-browser.d.ts.map +1 -0
- package/dist/cli-browser.js +134 -0
- package/dist/cli-browser.js.map +1 -0
- package/dist/cli-fixed.d.ts +3 -0
- package/dist/cli-fixed.d.ts.map +1 -0
- package/dist/cli-fixed.js +112 -0
- package/dist/cli-fixed.js.map +1 -0
- package/dist/cli-simple.d.ts +3 -0
- package/dist/cli-simple.d.ts.map +1 -0
- package/dist/cli-simple.js +167 -0
- package/dist/cli-simple.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +167 -0
- package/dist/cli.js.map +1 -0
- package/dist/load-graphql.d.ts +23 -0
- package/dist/load-graphql.d.ts.map +1 -0
- package/dist/load-graphql.js +239 -0
- package/dist/load-graphql.js.map +1 -0
- package/dist/load-old.d.ts +24 -0
- package/dist/load-old.d.ts.map +1 -0
- package/dist/load-old.js +183 -0
- package/dist/load-old.js.map +1 -0
- package/dist/load-real.d.ts +23 -0
- package/dist/load-real.d.ts.map +1 -0
- package/dist/load-real.js +202 -0
- package/dist/load-real.js.map +1 -0
- package/dist/load.d.ts +24 -0
- package/dist/load.d.ts.map +1 -0
- package/dist/load.js +195 -0
- package/dist/load.js.map +1 -0
- package/dist/mapper.d.ts +16 -0
- package/dist/mapper.d.ts.map +1 -0
- package/dist/mapper.js +181 -0
- package/dist/mapper.js.map +1 -0
- package/dist/parser-broken.d.ts +11 -0
- package/dist/parser-broken.d.ts.map +1 -0
- package/dist/parser-broken.js +88 -0
- package/dist/parser-broken.js.map +1 -0
- package/dist/parser-old.d.ts +11 -0
- package/dist/parser-old.d.ts.map +1 -0
- package/dist/parser-old.js +90 -0
- package/dist/parser-old.js.map +1 -0
- package/dist/parser.d.ts +11 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +83 -0
- package/dist/parser.js.map +1 -0
- package/dist/reporter.d.ts +15 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +144 -0
- package/dist/reporter.js.map +1 -0
- package/examples/contacts-mapping.txt +8 -0
- package/examples/contacts.csv +6 -0
- package/package.json +50 -0
- package/src/browser-automation.ts +350 -0
- package/src/cli-browser.ts +134 -0
- package/src/cli-simple.ts +158 -0
- package/src/cli.ts +159 -0
- package/src/load-graphql.ts +238 -0
- package/src/load-old.ts +177 -0
- package/src/load-real.ts +199 -0
- package/src/load.ts +197 -0
- package/src/mapper.ts +183 -0
- package/src/parser.ts +55 -0
- package/src/reporter.ts +131 -0
- package/tsconfig.json +24 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { loadToTwenty } from './load-real';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as Papa from 'papaparse';
|
|
8
|
+
import axios from 'axios';
|
|
9
|
+
|
|
10
|
+
interface Options {
|
|
11
|
+
file: string;
|
|
12
|
+
object: string;
|
|
13
|
+
twentyUrl: string;
|
|
14
|
+
twentyKey: string;
|
|
15
|
+
dryRun: boolean;
|
|
16
|
+
batch?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const program = new Command();
|
|
20
|
+
|
|
21
|
+
program
|
|
22
|
+
.name('twenty-import-csv')
|
|
23
|
+
.description('Universal CSV import tool for Twenty CRM')
|
|
24
|
+
.version('1.0.0');
|
|
25
|
+
|
|
26
|
+
program
|
|
27
|
+
.requiredOption('-f, --file <path>', 'CSV file to import')
|
|
28
|
+
.requiredOption('-o, --object <type>', 'Twenty object type (people, companies, opportunities, etc.)')
|
|
29
|
+
.requiredOption('-u, --twenty-url <url>', 'Twenty CRM URL')
|
|
30
|
+
.requiredOption('-k, --twenty-key <key>', 'Twenty CRM API key')
|
|
31
|
+
.option('-d, --dry-run', 'Preview import without writing data', false)
|
|
32
|
+
.option('-b, --batch <number>', 'Batch size for API calls', '60')
|
|
33
|
+
.parse();
|
|
34
|
+
|
|
35
|
+
const options = program.opts() as Options;
|
|
36
|
+
|
|
37
|
+
async function parseCSV(filePath: string): Promise<any[]> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
40
|
+
|
|
41
|
+
Papa.parse(fileContent, {
|
|
42
|
+
header: true,
|
|
43
|
+
skipEmptyLines: true,
|
|
44
|
+
complete: (results) => {
|
|
45
|
+
if (results.errors.length > 0) {
|
|
46
|
+
console.warn(`⚠️ CSV parsing warnings: ${results.errors.length}`);
|
|
47
|
+
}
|
|
48
|
+
resolve(results.data);
|
|
49
|
+
},
|
|
50
|
+
error: (error: any) => {
|
|
51
|
+
reject(new Error(`CSV parsing failed: ${error.message}`));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function main() {
|
|
58
|
+
try {
|
|
59
|
+
console.log('🚀 Twenty CSV Import Tool');
|
|
60
|
+
console.log(`📁 File: ${options.file}`);
|
|
61
|
+
console.log(`🎯 Object: ${options.object}`);
|
|
62
|
+
console.log(`🔗 Twenty URL: ${options.twentyUrl}`);
|
|
63
|
+
console.log(`🧪 Dry Run: ${options.dryRun ? 'YES' : 'NO'}`);
|
|
64
|
+
|
|
65
|
+
// Check if file exists
|
|
66
|
+
if (!fs.existsSync(options.file)) {
|
|
67
|
+
console.error(`❌ Error: File ${options.file} not found`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Parse CSV
|
|
72
|
+
console.log('\n📖 Parsing CSV file...');
|
|
73
|
+
const data = await parseCSV(options.file);
|
|
74
|
+
console.log(`✅ Parsed ${data.length} records`);
|
|
75
|
+
|
|
76
|
+
// Simple field mapping for people
|
|
77
|
+
const mappedData = data.map((record: any) => {
|
|
78
|
+
const mapped: any = {};
|
|
79
|
+
|
|
80
|
+
// Auto-map common fields for people
|
|
81
|
+
if (options.object === 'people') {
|
|
82
|
+
mapped['name.firstName'] = record.first_name || record.firstName || '';
|
|
83
|
+
mapped['name.lastName'] = record.last_name || record.lastName || '';
|
|
84
|
+
mapped['email'] = record.email || '';
|
|
85
|
+
mapped['phone'] = record.phone || '';
|
|
86
|
+
mapped['jobTitle'] = record.job_title || record.jobTitle || '';
|
|
87
|
+
mapped['city'] = record.city || '';
|
|
88
|
+
mapped['country'] = record.country || '';
|
|
89
|
+
} else {
|
|
90
|
+
// For other objects, just pass through
|
|
91
|
+
Object.assign(mapped, record);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return mapped;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Show preview for dry run
|
|
98
|
+
if (options.dryRun) {
|
|
99
|
+
console.log('\n👀 DRY RUN - Preview (first 5 records):');
|
|
100
|
+
console.log(JSON.stringify(mappedData.slice(0, 5), null, 2));
|
|
101
|
+
console.log(`\n📊 Summary: ${mappedData.length} records ready for import`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Load data to Twenty
|
|
106
|
+
console.log('\n📤 Loading data to Twenty CRM...');
|
|
107
|
+
const batchSize = parseInt(String(options.batch || '60'));
|
|
108
|
+
const client = axios.create({
|
|
109
|
+
baseURL: `${options.twentyUrl.replace(/\/$/, '')}/rest`,
|
|
110
|
+
headers: {
|
|
111
|
+
'Authorization': `Bearer ${options.twentyKey}`,
|
|
112
|
+
'Content-Type': 'application/json'
|
|
113
|
+
},
|
|
114
|
+
timeout: 30000
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
let successCount = 0;
|
|
118
|
+
let errorCount = 0;
|
|
119
|
+
|
|
120
|
+
// Process in batches
|
|
121
|
+
for (let i = 0; i < mappedData.length; i += batchSize) {
|
|
122
|
+
const batch = mappedData.slice(i, i + batchSize);
|
|
123
|
+
const batchNumber = Math.floor(i / batchSize) + 1;
|
|
124
|
+
const totalBatches = Math.ceil(mappedData.length / batchSize);
|
|
125
|
+
|
|
126
|
+
console.log(`📤 Batch ${batchNumber}/${totalBatches}: ${batch.length} records`);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const response = await client.post(`/${options.object}`, {
|
|
130
|
+
data: batch
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (response.status === 200) {
|
|
134
|
+
successCount += batch.length;
|
|
135
|
+
console.log(`✅ Batch ${batchNumber} imported successfully`);
|
|
136
|
+
} else {
|
|
137
|
+
throw new Error(`HTTP ${response.status}`);
|
|
138
|
+
}
|
|
139
|
+
} catch (error: any) {
|
|
140
|
+
errorCount += batch.length;
|
|
141
|
+
console.error(`❌ Batch ${batchNumber} failed:`, error.response?.data || error.message);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Small delay to avoid rate limiting
|
|
145
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.log('\n✅ Import completed!');
|
|
149
|
+
console.log(`📊 Success: ${successCount}`);
|
|
150
|
+
console.log(`❌ Errors: ${errorCount}`);
|
|
151
|
+
console.log(`📈 Success rate: ${((successCount / (successCount + errorCount)) * 100).toFixed(2)}%`);
|
|
152
|
+
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.error('❌ Import failed:', (error as Error).message);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main();
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import axios, { AxiosInstance } from 'axios';
|
|
2
|
+
import { ParsedRecord } from './parser';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
|
|
5
|
+
export interface LoadResult {
|
|
6
|
+
success: number;
|
|
7
|
+
errors: number;
|
|
8
|
+
errorLog: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BatchResult {
|
|
12
|
+
success: boolean;
|
|
13
|
+
count: number;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class TwentyGraphQLClient {
|
|
18
|
+
private client: AxiosInstance;
|
|
19
|
+
private baseUrl: string;
|
|
20
|
+
private apiKey: string;
|
|
21
|
+
|
|
22
|
+
constructor(baseUrl: string, apiKey: string) {
|
|
23
|
+
this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
24
|
+
this.apiKey = apiKey;
|
|
25
|
+
|
|
26
|
+
this.client = axios.create({
|
|
27
|
+
baseURL: `${this.baseUrl}`,
|
|
28
|
+
headers: {
|
|
29
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
30
|
+
'Content-Type': 'application/json'
|
|
31
|
+
},
|
|
32
|
+
timeout: 30000
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async testConnection(): Promise<boolean> {
|
|
37
|
+
try {
|
|
38
|
+
const query = `
|
|
39
|
+
query {
|
|
40
|
+
people {
|
|
41
|
+
edges {
|
|
42
|
+
node {
|
|
43
|
+
id
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
const response = await this.client.post('/graphql', { query });
|
|
51
|
+
return response.status === 200 && !response.data.errors;
|
|
52
|
+
} catch (error: any) {
|
|
53
|
+
console.error('❌ Connection test failed:', error.response?.data || error.message);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async createRecords(objectType: string, records: ParsedRecord[]): Promise<BatchResult> {
|
|
59
|
+
try {
|
|
60
|
+
// Prepare GraphQL mutation based on object type
|
|
61
|
+
let mutation: string;
|
|
62
|
+
let inputType: string;
|
|
63
|
+
|
|
64
|
+
switch (objectType.toLowerCase()) {
|
|
65
|
+
case 'people':
|
|
66
|
+
mutation = `
|
|
67
|
+
mutation CreatePeople($input: [PersonCreateInput!]!) {
|
|
68
|
+
createPeople(input: $input) {
|
|
69
|
+
id
|
|
70
|
+
name {
|
|
71
|
+
firstName
|
|
72
|
+
lastName
|
|
73
|
+
}
|
|
74
|
+
email
|
|
75
|
+
phone
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
inputType = 'PersonCreateInput';
|
|
80
|
+
break;
|
|
81
|
+
|
|
82
|
+
case 'companies':
|
|
83
|
+
mutation = `
|
|
84
|
+
mutation CreateCompanies($input: [CompanyCreateInput!]!) {
|
|
85
|
+
createCompanies(input: $input) {
|
|
86
|
+
id
|
|
87
|
+
name
|
|
88
|
+
domainName
|
|
89
|
+
phone
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
`;
|
|
93
|
+
inputType = 'CompanyCreateInput';
|
|
94
|
+
break;
|
|
95
|
+
|
|
96
|
+
default:
|
|
97
|
+
throw new Error(`Unsupported object type: ${objectType}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Transform records to GraphQL format
|
|
101
|
+
const graphqlRecords = records.map(record => {
|
|
102
|
+
const transformed: any = {};
|
|
103
|
+
|
|
104
|
+
if (objectType === 'people') {
|
|
105
|
+
// Handle name field
|
|
106
|
+
if (record['name.firstName'] || record['name.lastName']) {
|
|
107
|
+
transformed.name = {
|
|
108
|
+
firstName: record['name.firstName'] || '',
|
|
109
|
+
lastName: record['name.lastName'] || ''
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Copy other fields
|
|
114
|
+
if (record.email) transformed.email = record.email;
|
|
115
|
+
if (record.phone) transformed.phone = record.phone;
|
|
116
|
+
if (record.jobTitle) transformed.jobTitle = record.jobTitle;
|
|
117
|
+
if (record.city) transformed.city = record.city;
|
|
118
|
+
if (record.country) transformed.country = record.country;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return transformed;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const response = await this.client.post('/graphql', {
|
|
125
|
+
query: mutation,
|
|
126
|
+
variables: {
|
|
127
|
+
input: graphqlRecords
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (response.data.errors) {
|
|
132
|
+
return {
|
|
133
|
+
success: false,
|
|
134
|
+
count: 0,
|
|
135
|
+
error: response.data.errors.map((e: any) => e.message).join('; ')
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
success: true,
|
|
141
|
+
count: records.length
|
|
142
|
+
};
|
|
143
|
+
} catch (error: any) {
|
|
144
|
+
return {
|
|
145
|
+
success: false,
|
|
146
|
+
count: 0,
|
|
147
|
+
error: error.response?.data?.message || error.message
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getObjectEndpoint(objectType: string): string {
|
|
153
|
+
const endpoints: { [key: string]: string } = {
|
|
154
|
+
'people': 'people',
|
|
155
|
+
'persons': 'people',
|
|
156
|
+
'contacts': 'people',
|
|
157
|
+
'companies': 'companies',
|
|
158
|
+
'organization': 'companies',
|
|
159
|
+
'opportunities': 'opportunities',
|
|
160
|
+
'deals': 'opportunities',
|
|
161
|
+
'tasks': 'tasks',
|
|
162
|
+
'notes': 'notes'
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return endpoints[objectType.toLowerCase()] || objectType;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function loadToTwenty(
|
|
170
|
+
data: ParsedRecord[],
|
|
171
|
+
objectType: string,
|
|
172
|
+
twentyUrl: string,
|
|
173
|
+
twentyKey: string,
|
|
174
|
+
batchSize: number,
|
|
175
|
+
progressBar: any
|
|
176
|
+
): Promise<LoadResult> {
|
|
177
|
+
const client = new TwentyGraphQLClient(twentyUrl, twentyKey);
|
|
178
|
+
const errorLog: string[] = [];
|
|
179
|
+
let successCount = 0;
|
|
180
|
+
let errorCount = 0;
|
|
181
|
+
|
|
182
|
+
// Test connection first
|
|
183
|
+
console.log('🔗 Testing Twenty CRM GraphQL connection...');
|
|
184
|
+
const isConnected = await client.testConnection();
|
|
185
|
+
if (!isConnected) {
|
|
186
|
+
throw new Error('Failed to connect to Twenty CRM. Check URL and API key.');
|
|
187
|
+
}
|
|
188
|
+
console.log('✅ GraphQL connection successful');
|
|
189
|
+
|
|
190
|
+
console.log(`📤 Loading ${data.length} records via GraphQL...`);
|
|
191
|
+
|
|
192
|
+
// Process in batches
|
|
193
|
+
for (let i = 0; i < data.length; i += batchSize) {
|
|
194
|
+
const batch = data.slice(i, i + batchSize);
|
|
195
|
+
const batchNumber = Math.floor(i / batchSize) + 1;
|
|
196
|
+
const totalBatches = Math.ceil(data.length / batchSize);
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
progressBar.update((i / data.length) * 100, {
|
|
200
|
+
batch: `${batchNumber}/${totalBatches}`,
|
|
201
|
+
records: `${i + 1}-${Math.min(i + batchSize, data.length)}`
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const batchResult = await client.createRecords(objectType, batch);
|
|
205
|
+
|
|
206
|
+
if (batchResult.success) {
|
|
207
|
+
successCount += batchResult.count;
|
|
208
|
+
console.log(`✅ Batch ${batchNumber} imported successfully`);
|
|
209
|
+
} else {
|
|
210
|
+
errorCount += batch.length;
|
|
211
|
+
const errorMsg = `Batch ${batchNumber}: ${batchResult.error}`;
|
|
212
|
+
errorLog.push(errorMsg);
|
|
213
|
+
console.error(`❌ ${errorMsg}`);
|
|
214
|
+
}
|
|
215
|
+
} catch (error: any) {
|
|
216
|
+
const errorMsg = `Batch ${batchNumber}: ${error.message}`;
|
|
217
|
+
errorLog.push(errorMsg);
|
|
218
|
+
errorCount += batch.length;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Small delay to avoid rate limiting
|
|
222
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
success: successCount,
|
|
227
|
+
errors: errorCount,
|
|
228
|
+
errorLog
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function saveErrorLog(errorLog: string[], filename: string): void {
|
|
233
|
+
if (errorLog.length === 0) return;
|
|
234
|
+
|
|
235
|
+
const logContent = errorLog.join('\n');
|
|
236
|
+
fs.writeFileSync(filename, logContent, 'utf8');
|
|
237
|
+
console.log(`📄 Error log saved to ${filename}`);
|
|
238
|
+
}
|
package/src/load-old.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import axios, { AxiosInstance } from 'axios';
|
|
2
|
+
import { ParsedRecord } from './parser';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
|
|
5
|
+
export interface LoadResult {
|
|
6
|
+
success: number;
|
|
7
|
+
errors: number;
|
|
8
|
+
errorLog: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BatchResult {
|
|
12
|
+
success: boolean;
|
|
13
|
+
count: number;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class TwentyAPIClient {
|
|
18
|
+
private client: AxiosInstance;
|
|
19
|
+
private baseUrl: string;
|
|
20
|
+
private apiKey: string;
|
|
21
|
+
|
|
22
|
+
constructor(baseUrl: string, apiKey: string) {
|
|
23
|
+
this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
24
|
+
this.apiKey = apiKey;
|
|
25
|
+
|
|
26
|
+
this.client = axios.create({
|
|
27
|
+
baseURL: `${this.baseUrl}/rest`,
|
|
28
|
+
headers: {
|
|
29
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
30
|
+
'Content-Type': 'application/json'
|
|
31
|
+
},
|
|
32
|
+
timeout: 30000
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async testConnection(): Promise<boolean> {
|
|
37
|
+
try {
|
|
38
|
+
const response = await this.client.get('/people', { params: { limit: 1 } });
|
|
39
|
+
return response.status === 200;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error('❌ Connection test failed:', (error as any).response?.data || (error as any).message);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async createRecords(objectType: string, records: ParsedRecord[]): Promise<BatchResult> {
|
|
47
|
+
try {
|
|
48
|
+
const response = await this.client.post(`/${objectType}`, {
|
|
49
|
+
data: records
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
success: true,
|
|
54
|
+
count: records.length
|
|
55
|
+
};
|
|
56
|
+
} catch (error: any) {
|
|
57
|
+
return {
|
|
58
|
+
success: false,
|
|
59
|
+
count: 0,
|
|
60
|
+
error: error.response?.data?.message || error.message
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async createRecord(objectType: string, record: ParsedRecord): Promise<BatchResult> {
|
|
66
|
+
try {
|
|
67
|
+
const response = await this.client.post(`/${objectType}`, record);
|
|
68
|
+
return {
|
|
69
|
+
success: true,
|
|
70
|
+
count: 1
|
|
71
|
+
};
|
|
72
|
+
} catch (error: any) {
|
|
73
|
+
return {
|
|
74
|
+
success: false,
|
|
75
|
+
count: 0,
|
|
76
|
+
error: error.response?.data?.message || error.message
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
getObjectEndpoint(objectType: string): string {
|
|
82
|
+
const endpoints: { [key: string]: string } = {
|
|
83
|
+
'people': 'people',
|
|
84
|
+
'persons': 'people',
|
|
85
|
+
'contacts': 'people',
|
|
86
|
+
'companies': 'companies',
|
|
87
|
+
'organization': 'companies',
|
|
88
|
+
'opportunities': 'opportunities',
|
|
89
|
+
'deals': 'opportunities',
|
|
90
|
+
'tasks': 'tasks',
|
|
91
|
+
'notes': 'notes'
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return endpoints[objectType.toLowerCase()] || objectType;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function loadToTwenty(
|
|
99
|
+
data: ParsedRecord[],
|
|
100
|
+
objectType: string,
|
|
101
|
+
twentyUrl: string,
|
|
102
|
+
twentyKey: string,
|
|
103
|
+
batchSize: number,
|
|
104
|
+
progressBar: any
|
|
105
|
+
): Promise<LoadResult> {
|
|
106
|
+
const client = new TwentyAPIClient(twentyUrl, twentyKey);
|
|
107
|
+
const errorLog: string[] = [];
|
|
108
|
+
let successCount = 0;
|
|
109
|
+
let errorCount = 0;
|
|
110
|
+
|
|
111
|
+
// Test connection first
|
|
112
|
+
console.log('🔗 Testing Twenty CRM connection...');
|
|
113
|
+
const isConnected = await client.testConnection();
|
|
114
|
+
if (!isConnected) {
|
|
115
|
+
throw new Error('Failed to connect to Twenty CRM. Check URL and API key.');
|
|
116
|
+
}
|
|
117
|
+
console.log('✅ Connection successful');
|
|
118
|
+
|
|
119
|
+
const endpoint = client.getObjectEndpoint(objectType);
|
|
120
|
+
console.log(`📤 Loading ${data.length} records to ${endpoint}...`);
|
|
121
|
+
|
|
122
|
+
// Process in batches
|
|
123
|
+
for (let i = 0; i < data.length; i += batchSize) {
|
|
124
|
+
const batch = data.slice(i, i + batchSize);
|
|
125
|
+
const batchNumber = Math.floor(i / batchSize) + 1;
|
|
126
|
+
const totalBatches = Math.ceil(data.length / batchSize);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
progressBar.update((i / data.length) * 100, {
|
|
130
|
+
batch: `${batchNumber}/${totalBatches}`,
|
|
131
|
+
records: `${i + 1}-${Math.min(i + batchSize, data.length)}`
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Try batch create first (if supported)
|
|
135
|
+
const batchResult = await client.createRecords(endpoint, batch);
|
|
136
|
+
|
|
137
|
+
if (batchResult.success) {
|
|
138
|
+
successCount += batchResult.count;
|
|
139
|
+
} else {
|
|
140
|
+
// Fallback to individual records
|
|
141
|
+
console.warn(`⚠️ Batch create failed, trying individual records...`);
|
|
142
|
+
|
|
143
|
+
for (const record of batch) {
|
|
144
|
+
const result = await client.createRecord(endpoint, record);
|
|
145
|
+
if (result.success) {
|
|
146
|
+
successCount++;
|
|
147
|
+
} else {
|
|
148
|
+
errorCount++;
|
|
149
|
+
const errorMsg = `Record ${JSON.stringify(record)}: ${result.error}`;
|
|
150
|
+
errorLog.push(errorMsg);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch (error: any) {
|
|
155
|
+
const errorMsg = `Batch ${batchNumber}: ${error.message}`;
|
|
156
|
+
errorLog.push(errorMsg);
|
|
157
|
+
errorCount += batch.length;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Small delay to avoid rate limiting
|
|
161
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
success: successCount,
|
|
166
|
+
errors: errorCount,
|
|
167
|
+
errorLog
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function saveErrorLog(errorLog: string[], filename: string): void {
|
|
172
|
+
if (errorLog.length === 0) return;
|
|
173
|
+
|
|
174
|
+
const logContent = errorLog.join('\n');
|
|
175
|
+
fs.writeFileSync(filename, logContent, 'utf8');
|
|
176
|
+
console.log(`📄 Error log saved to ${filename}`);
|
|
177
|
+
}
|