twenty-migrate-espocrm 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/src/load.ts ADDED
@@ -0,0 +1,280 @@
1
+ import axios, { AxiosInstance } from 'axios';
2
+ import { TransformedData, TwentyPerson, TwentyCompany, TwentyOpportunity, TwentyNote } from './transform';
3
+ import { SingleBar } from 'cli-progress';
4
+ import * as fs from 'fs';
5
+
6
+ export interface LoadResult {
7
+ success: number;
8
+ errors: number;
9
+ errorLog: string[];
10
+ objectResults: {
11
+ people: { success: number; errors: number };
12
+ companies: { success: number; errors: number };
13
+ opportunities: { success: number; errors: number };
14
+ notes: { success: number; errors: number };
15
+ };
16
+ }
17
+
18
+ export interface BatchResult {
19
+ success: boolean;
20
+ count: number;
21
+ error?: string;
22
+ }
23
+
24
+ export class TwentyAPIClient {
25
+ private client: AxiosInstance;
26
+ private baseUrl: string;
27
+ private apiKey: string;
28
+ private existingEmails: Set<string> = new Set();
29
+ private existingCompanies: Set<string> = new Set();
30
+
31
+ constructor(baseUrl: string, apiKey: string) {
32
+ this.baseUrl = baseUrl.replace(/\/$/, '');
33
+ this.apiKey = apiKey;
34
+
35
+ this.client = axios.create({
36
+ baseURL: `${this.baseUrl}`,
37
+ headers: {
38
+ 'Authorization': `Bearer ${this.apiKey}`,
39
+ 'Content-Type': 'application/json'
40
+ },
41
+ timeout: 30000
42
+ });
43
+ }
44
+
45
+ async testConnection(): Promise<boolean> {
46
+ try {
47
+ const response = await this.client.get('/api/ping');
48
+ return response.status === 200;
49
+ } catch (error: any) {
50
+ console.error('āŒ Connection test failed:', error.response?.data || error.message);
51
+ return false;
52
+ }
53
+ }
54
+
55
+ async loadExistingData(): Promise<void> {
56
+ console.log('šŸ” Loading existing data to prevent duplicates...');
57
+
58
+ try {
59
+ // Load existing emails
60
+ const peopleResponse = await this.client.get('/api/people');
61
+ if (peopleResponse.data && Array.isArray(peopleResponse.data)) {
62
+ peopleResponse.data.forEach((person: any) => {
63
+ if (person.email) {
64
+ this.existingEmails.add(person.email.toLowerCase());
65
+ }
66
+ });
67
+ }
68
+
69
+ // Load existing companies
70
+ const companiesResponse = await this.client.get('/api/companies');
71
+ if (companiesResponse.data && Array.isArray(companiesResponse.data)) {
72
+ companiesResponse.data.forEach((company: any) => {
73
+ if (company.name) {
74
+ this.existingCompanies.add(company.name.toLowerCase());
75
+ }
76
+ });
77
+ }
78
+
79
+ console.log(`āœ… Loaded ${this.existingEmails.size} existing emails and ${this.existingCompanies.size} existing companies`);
80
+ } catch (error: any) {
81
+ console.warn('āš ļø Could not load existing data, proceeding without duplicate checking:', error.message);
82
+ }
83
+ }
84
+
85
+ async createRecords(objectType: string, records: any[]): Promise<BatchResult> {
86
+ try {
87
+ // Filter out duplicates
88
+ const filteredRecords = await this.filterDuplicates(objectType, records);
89
+
90
+ if (filteredRecords.length === 0) {
91
+ return { success: true, count: 0, error: 'All records were duplicates' };
92
+ }
93
+
94
+ // Transform records for Twenty CRM API
95
+ const transformedRecords = records.map(record => this.transformRecordForAPI(objectType, record));
96
+
97
+ const response = await this.client.post(`/api/${objectType}`, transformedRecords);
98
+
99
+ return {
100
+ success: true,
101
+ count: filteredRecords.length
102
+ };
103
+ } catch (error: any) {
104
+ return {
105
+ success: false,
106
+ count: 0,
107
+ error: error.response?.data?.message || error.message
108
+ };
109
+ }
110
+ }
111
+
112
+ private async filterDuplicates(objectType: string, records: any[]): Promise<any[]> {
113
+ return records.filter(record => {
114
+ switch (objectType) {
115
+ case 'people':
116
+ return record.email && !this.existingEmails.has(record.email.toLowerCase());
117
+ case 'companies':
118
+ return record.name && !this.existingCompanies.has(record.name.toLowerCase());
119
+ default:
120
+ return true; // No duplicate checking for other objects
121
+ }
122
+ });
123
+ }
124
+
125
+ private transformRecordForAPI(objectType: string, record: any): any {
126
+ switch (objectType) {
127
+ case 'people':
128
+ return {
129
+ name: record.name,
130
+ email: record.email,
131
+ phone: record.phone,
132
+ jobTitle: record.jobTitle,
133
+ company: record.company,
134
+ city: record.city,
135
+ country: record.country,
136
+ source: record.source,
137
+ espocrmId: record.espocrmId
138
+ };
139
+ case 'companies':
140
+ return {
141
+ name: record.name,
142
+ domainName: record.domainName,
143
+ industry: record.industry,
144
+ description: record.description,
145
+ phone: record.phone,
146
+ website: record.website,
147
+ source: record.source,
148
+ espocrmId: record.espocrmId
149
+ };
150
+ case 'opportunities':
151
+ return {
152
+ name: record.name,
153
+ amount: record.amount,
154
+ closeDate: record.closeDate,
155
+ pipeline: record.pipeline,
156
+ stage: record.stage,
157
+ source: record.source,
158
+ espocrmId: record.espocrmId,
159
+ personId: record.personId,
160
+ companyId: record.companyId
161
+ };
162
+ case 'notes':
163
+ return {
164
+ body: record.body,
165
+ source: record.source,
166
+ espocrmId: record.espocrmId,
167
+ personId: record.personId,
168
+ companyId: record.companyId,
169
+ opportunityId: record.opportunityId
170
+ };
171
+ default:
172
+ return record;
173
+ }
174
+ }
175
+ }
176
+
177
+ export async function loadToTwenty(
178
+ data: TransformedData,
179
+ twentyUrl: string,
180
+ twentyKey: string,
181
+ batchSize: number,
182
+ progressBar: SingleBar
183
+ ): Promise<LoadResult> {
184
+ const client = new TwentyAPIClient(twentyUrl, twentyKey);
185
+ const errorLog: string[] = [];
186
+ const objectResults = {
187
+ people: { success: 0, errors: 0 },
188
+ companies: { success: 0, errors: 0 },
189
+ opportunities: { success: 0, errors: 0 },
190
+ notes: { success: 0, errors: 0 }
191
+ };
192
+
193
+ // Test connection first
194
+ console.log('šŸ”— Testing Twenty CRM connection...');
195
+ const isConnected = await client.testConnection();
196
+ if (!isConnected) {
197
+ throw new Error('Failed to connect to Twenty CRM. Check URL and API key.');
198
+ }
199
+ console.log('āœ… Connection successful');
200
+
201
+ // Load existing data for duplicate checking
202
+ await client.loadExistingData();
203
+
204
+ console.log(`šŸ“¤ Loading data to Twenty CRM...`);
205
+
206
+ // Process each object type
207
+ const objectTypes = ['people', 'companies', 'opportunities', 'notes'] as const;
208
+
209
+ for (const objectType of objectTypes) {
210
+ const records = data[objectType];
211
+ if (!records || records.length === 0) {
212
+ console.log(`ā­ļø Skipping ${objectType} - no records`);
213
+ continue;
214
+ }
215
+
216
+ console.log(`\nšŸ“¤ Processing ${objectType} (${records.length} records)...`);
217
+
218
+ // Process in batches
219
+ for (let i = 0; i < records.length; i += batchSize) {
220
+ const batch = records.slice(i, i + batchSize);
221
+ const batchNumber = Math.floor(i / batchSize) + 1;
222
+ const totalBatches = Math.ceil(records.length / batchSize);
223
+
224
+ try {
225
+ progressBar.update((i / records.length) * 100, {
226
+ batch: `${batchNumber}/${totalBatches}`,
227
+ records: `${i + 1}-${Math.min(i + batchSize, records.length)}`,
228
+ type: objectType
229
+ });
230
+
231
+ const batchResult = await client.createRecords(objectType, batch);
232
+
233
+ if (batchResult.success) {
234
+ objectResults[objectType].success += batchResult.count;
235
+ console.log(`āœ… Batch ${batchNumber} (${objectType}): ${batchResult.count} records imported`);
236
+ } else {
237
+ objectResults[objectType].errors += batch.length;
238
+ const errorMsg = `Batch ${batchNumber} (${objectType}): ${batchResult.error}`;
239
+ errorLog.push(errorMsg);
240
+ console.error(`āŒ ${errorMsg}`);
241
+ }
242
+ } catch (error: any) {
243
+ objectResults[objectType].errors += batch.length;
244
+ const errorMsg = `Batch ${batchNumber} (${objectType}): ${error.message}`;
245
+ errorLog.push(errorMsg);
246
+ console.error(`āŒ ${errorMsg}`);
247
+ }
248
+
249
+ // Rate limiting - 100 requests per minute = 600ms between requests
250
+ await new Promise(resolve => setTimeout(resolve, 600));
251
+ }
252
+ }
253
+
254
+ const totalSuccess = Object.values(objectResults).reduce((sum, result) => sum + result.success, 0);
255
+ const totalErrors = Object.values(objectResults).reduce((sum, result) => sum + result.errors, 0);
256
+
257
+ return {
258
+ success: totalSuccess,
259
+ errors: totalErrors,
260
+ errorLog,
261
+ objectResults
262
+ };
263
+ }
264
+
265
+ export function saveMigrationReport(result: LoadResult, filename: string): void {
266
+ const report = {
267
+ timestamp: new Date().toISOString(),
268
+ summary: {
269
+ total: result.success + result.errors,
270
+ success: result.success,
271
+ errors: result.errors,
272
+ successRate: result.success + result.errors > 0 ? ((result.success / (result.success + result.errors)) * 100).toFixed(2) + '%' : '0%'
273
+ },
274
+ objectResults: result.objectResults,
275
+ errorLog: result.errorLog
276
+ };
277
+
278
+ fs.writeFileSync(filename, JSON.stringify(report, null, 2), 'utf8');
279
+ console.log(`šŸ“„ Migration report saved to ${filename}`);
280
+ }
@@ -0,0 +1,196 @@
1
+ import { SingleBar, Presets } from 'cli-progress';
2
+ import { LoadResult } from './load';
3
+ import * as fs from 'fs';
4
+
5
+ export function showProgress(total: number): SingleBar {
6
+ const progressBar = new SingleBar({
7
+ format: 'šŸ“¤ Migration |{bar}| {percentage}% | {value}/{total} | {type} | Batch: {batch}',
8
+ barCompleteChar: '\u2588',
9
+ barIncompleteChar: '\u2591',
10
+ hideCursor: true
11
+ }, Presets.shades_classic);
12
+
13
+ progressBar.start(total, 0, {
14
+ batch: '0/0',
15
+ records: '0-0',
16
+ type: 'initializing'
17
+ });
18
+
19
+ return progressBar;
20
+ }
21
+
22
+ export async function generateMigrationReport(result: LoadResult, objects: string): Promise<void> {
23
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
24
+ const reportFilename = `migration-report-${timestamp}.json`;
25
+ const errorLogFilename = `migration-errors-${timestamp}.log`;
26
+
27
+ // Generate detailed report
28
+ const report = {
29
+ timestamp: new Date().toISOString(),
30
+ migration: {
31
+ tool: 'twenty-migrate-espocrm',
32
+ version: '1.0.0',
33
+ objects: objects.split(',').map(obj => obj.trim()),
34
+ dryRun: false
35
+ },
36
+ summary: {
37
+ total: result.success + result.errors,
38
+ success: result.success,
39
+ errors: result.errors,
40
+ successRate: result.success + result.errors > 0 ? ((result.success / (result.success + result.errors)) * 100).toFixed(2) + '%' : '0%'
41
+ },
42
+ objectResults: {
43
+ people: {
44
+ attempted: result.objectResults.people.success + result.objectResults.people.errors,
45
+ success: result.objectResults.people.success,
46
+ errors: result.objectResults.people.errors,
47
+ successRate: result.objectResults.people.success + result.objectResults.people.errors > 0 ?
48
+ ((result.objectResults.people.success / (result.objectResults.people.success + result.objectResults.people.errors)) * 100).toFixed(2) + '%' : '0%'
49
+ },
50
+ companies: {
51
+ attempted: result.objectResults.companies.success + result.objectResults.companies.errors,
52
+ success: result.objectResults.companies.success,
53
+ errors: result.objectResults.companies.errors,
54
+ successRate: result.objectResults.companies.success + result.objectResults.companies.errors > 0 ?
55
+ ((result.objectResults.companies.success / (result.objectResults.companies.success + result.objectResults.companies.errors)) * 100).toFixed(2) + '%' : '0%'
56
+ },
57
+ opportunities: {
58
+ attempted: result.objectResults.opportunities.success + result.objectResults.opportunities.errors,
59
+ success: result.objectResults.opportunities.success,
60
+ errors: result.objectResults.opportunities.errors,
61
+ successRate: result.objectResults.opportunities.success + result.objectResults.opportunities.errors > 0 ?
62
+ ((result.objectResults.opportunities.success / (result.objectResults.opportunities.success + result.objectResults.opportunities.errors)) * 100).toFixed(2) + '%' : '0%'
63
+ },
64
+ notes: {
65
+ attempted: result.objectResults.notes.success + result.objectResults.notes.errors,
66
+ success: result.objectResults.notes.success,
67
+ errors: result.objectResults.notes.errors,
68
+ successRate: result.objectResults.notes.success + result.objectResults.notes.errors > 0 ?
69
+ ((result.objectResults.notes.success / (result.objectResults.notes.success + result.objectResults.notes.errors)) * 100).toFixed(2) + '%' : '0%'
70
+ }
71
+ },
72
+ errors: result.errorLog,
73
+ recommendations: generateRecommendations(result)
74
+ };
75
+
76
+ // Save JSON report
77
+ fs.writeFileSync(reportFilename, JSON.stringify(report, null, 2), 'utf8');
78
+ console.log(`šŸ“„ Migration report saved to ${reportFilename}`);
79
+
80
+ // Save error log if there are errors
81
+ if (result.errorLog.length > 0) {
82
+ fs.writeFileSync(errorLogFilename, result.errorLog.join('\n'), 'utf8');
83
+ console.log(`šŸ“„ Error log saved to ${errorLogFilename}`);
84
+ }
85
+
86
+ // Display summary
87
+ console.log('\nšŸ“Š Migration Summary:');
88
+ console.log(`šŸ“ˆ Total Records: ${report.summary.total}`);
89
+ console.log(`āœ… Successful: ${report.summary.success}`);
90
+ console.log(`āŒ Errors: ${report.summary.errors}`);
91
+ console.log(`šŸ“Š Success Rate: ${report.summary.successRate}`);
92
+
93
+ console.log('\nšŸ“‹ Object Breakdown:');
94
+ Object.entries(report.objectResults).forEach(([objectType, results]) => {
95
+ if (results.attempted > 0) {
96
+ console.log(`šŸ‘¤ ${objectType}: ${results.success}/${results.attempted} (${results.successRate})`);
97
+ }
98
+ });
99
+
100
+ if (result.errorLog.length > 0) {
101
+ console.log(`\nāŒ Errors encountered: ${result.errorLog.length}`);
102
+ console.log(`šŸ“„ See ${errorLogFilename} for details`);
103
+ }
104
+
105
+ // Display recommendations
106
+ if (report.recommendations.length > 0) {
107
+ console.log('\nšŸ’” Recommendations:');
108
+ report.recommendations.forEach((rec, index) => {
109
+ console.log(`${index + 1}. ${rec}`);
110
+ });
111
+ }
112
+ }
113
+
114
+ function generateRecommendations(result: LoadResult): string[] {
115
+ const recommendations: string[] = [];
116
+
117
+ // Overall success rate recommendations
118
+ const totalSuccessRate = result.success + result.errors > 0 ?
119
+ (result.success / (result.success + result.errors)) * 100 : 0;
120
+
121
+ if (totalSuccessRate < 90) {
122
+ recommendations.push('Consider reviewing data quality and formatting before migration');
123
+ }
124
+
125
+ if (totalSuccessRate < 75) {
126
+ recommendations.push('Run migration in dry-run mode first to identify potential issues');
127
+ }
128
+
129
+ // Object-specific recommendations
130
+ Object.entries(result.objectResults).forEach(([objectType, results]) => {
131
+ const successRate = results.success + results.errors > 0 ?
132
+ (results.success / (results.success + results.errors)) * 100 : 0;
133
+
134
+ if (successRate < 80) {
135
+ recommendations.push(`Review ${objectType} data mapping - ${results.errors} records failed`);
136
+ }
137
+
138
+ if (results.errors > 0 && successRate < 50) {
139
+ recommendations.push(`Consider manual review of ${objectType} data before retrying`);
140
+ }
141
+ });
142
+
143
+ // Error pattern recommendations
144
+ const errorPatterns = analyzeErrorPatterns(result.errorLog);
145
+ if (errorPatterns.duplicateErrors > 0) {
146
+ recommendations.push('Consider running with duplicate detection disabled if duplicates are expected');
147
+ }
148
+
149
+ if (errorPatterns.rateLimitErrors > 0) {
150
+ recommendations.push('Increase rate limit delay in configuration');
151
+ }
152
+
153
+ if (errorPatterns.validationErrors > 0) {
154
+ recommendations.push('Review data validation rules and field requirements');
155
+ }
156
+
157
+ return recommendations;
158
+ }
159
+
160
+ function analyzeErrorPatterns(errorLog: string[]): {
161
+ duplicateErrors: number;
162
+ rateLimitErrors: number;
163
+ validationErrors: number;
164
+ otherErrors: number;
165
+ } {
166
+ const patterns = {
167
+ duplicateErrors: 0,
168
+ rateLimitErrors: 0,
169
+ validationErrors: 0,
170
+ otherErrors: 0
171
+ };
172
+
173
+ errorLog.forEach(error => {
174
+ const errorLower = error.toLowerCase();
175
+
176
+ if (errorLower.includes('duplicate') || errorLower.includes('already exists')) {
177
+ patterns.duplicateErrors++;
178
+ } else if (errorLower.includes('rate limit') || errorLower.includes('too many requests')) {
179
+ patterns.rateLimitErrors++;
180
+ } else if (errorLower.includes('validation') || errorLower.includes('required') || errorLower.includes('invalid')) {
181
+ patterns.validationErrors++;
182
+ } else {
183
+ patterns.otherErrors++;
184
+ }
185
+ });
186
+
187
+ return patterns;
188
+ }
189
+
190
+ export function saveErrorLog(errorLog: string[], filename: string): void {
191
+ if (errorLog.length === 0) return;
192
+
193
+ const logContent = errorLog.join('\n');
194
+ fs.writeFileSync(filename, logContent, 'utf8');
195
+ console.log(`šŸ“„ Error log saved to ${filename}`);
196
+ }