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