@ixiam/n8n-nodes-civicrm 0.4.1 → 0.4.4
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/dist/nodes/CiviCrm/CiviCrm.node.js +303 -38
- package/nodes/CiviCrm/CiviCrm.node.ts +526 -164
- package/package.json +1 -1
|
@@ -16,6 +16,41 @@ const ENTITY_MAP = {
|
|
|
16
16
|
email: 'Email',
|
|
17
17
|
activity: 'Activity',
|
|
18
18
|
};
|
|
19
|
+
/**
|
|
20
|
+
* Caché global de location types
|
|
21
|
+
* Se rellena la primera vez que se usa y dura hasta que se reinicia n8n.
|
|
22
|
+
* key: normalizado (lowercase, sin espacios/ símbolos)
|
|
23
|
+
* value: option_value.name (para usar en location_type_id:name)
|
|
24
|
+
*/
|
|
25
|
+
let locationTypeCache = null;
|
|
26
|
+
function normalizeLocationKey(s) {
|
|
27
|
+
return String(s || '')
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9]+/g, '');
|
|
30
|
+
}
|
|
31
|
+
async function getLocationTypeMap() {
|
|
32
|
+
if (locationTypeCache)
|
|
33
|
+
return locationTypeCache;
|
|
34
|
+
const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/OptionValue/get', {
|
|
35
|
+
where: [['option_group_id:name', '=', 'location_type']],
|
|
36
|
+
select: ['name', 'label'],
|
|
37
|
+
limit: 0,
|
|
38
|
+
});
|
|
39
|
+
const values = (res?.values || []);
|
|
40
|
+
const map = {};
|
|
41
|
+
for (const v of values) {
|
|
42
|
+
const name = v.name || '';
|
|
43
|
+
const label = v.label || '';
|
|
44
|
+
const normName = normalizeLocationKey(name);
|
|
45
|
+
const normLabel = normalizeLocationKey(label);
|
|
46
|
+
if (normName)
|
|
47
|
+
map[normName] = name;
|
|
48
|
+
if (normLabel)
|
|
49
|
+
map[normLabel] = name;
|
|
50
|
+
}
|
|
51
|
+
locationTypeCache = map;
|
|
52
|
+
return map;
|
|
53
|
+
}
|
|
19
54
|
/**
|
|
20
55
|
* Nodo principal CiviCRM para n8n
|
|
21
56
|
*/
|
|
@@ -27,7 +62,36 @@ class CiviCrm {
|
|
|
27
62
|
icon: 'file:civicrm.svg',
|
|
28
63
|
group: ['transform'],
|
|
29
64
|
version: 1,
|
|
30
|
-
description: 'Interact with CiviCRM API v4 (Civi-Go compatible)'
|
|
65
|
+
description: 'Interact with CiviCRM API v4 (Civi-Go compatible).\\n\\n' +
|
|
66
|
+
'Email / Phone / Address mapping:\\n' +
|
|
67
|
+
'- You can send simple fields: email, phone, address.city, address.country_id, etc.\\n' +
|
|
68
|
+
'- You can also use dynamic prefixes based on real CiviCRM Location Types.\\n' +
|
|
69
|
+
' Example prefixes (from Location Types): Home, Work, Billing, Mobile, Oficina, etc.\\n' +
|
|
70
|
+
'- Prefix format: <location-prefix>.<entity>[.<subfield>]\\n' +
|
|
71
|
+
' Examples:\\n' +
|
|
72
|
+
' email = test@ixiam.com\\n' +
|
|
73
|
+
' phone = 600123123\\n' +
|
|
74
|
+
' address.city = Barcelona\\n' +
|
|
75
|
+
' work.email = test@company.com (Location Type = Work)\\n' +
|
|
76
|
+
' billing.address.postal_code = 28010 (Location Type = Billing)\\n' +
|
|
77
|
+
' home.phone.phone_type_id = 2 (Location Type = Home)\\n\\n' +
|
|
78
|
+
'Subfields examples:\\n' +
|
|
79
|
+
'- email.is_primary, email.signature_html, email.signature_text\\n' +
|
|
80
|
+
'- phone.phone_type_id, phone.phone_ext\\n' +
|
|
81
|
+
'- address.country_id, address.state_province_id, address.postal_code, address.street_address\\n\\n' +
|
|
82
|
+
'Prefixes and Location Types:\\n' +
|
|
83
|
+
'- The prefix (e.g. work, home, billing, oficina) is matched against CiviCRM Location Types (name/label).\\n' +
|
|
84
|
+
'- If the prefix matches, the node sets location_type_id:name automatically.\\n' +
|
|
85
|
+
'- If no prefix is used (e.g. email, phone, address.city), the node uses the Location Type selectors.\\n\\n' +
|
|
86
|
+
'birth_date:\\n' +
|
|
87
|
+
'- Accepted formats: YYYY-MM-DD, DD/MM/YYYY, DD-MM-YYYY, YYYY/MM/DD, YYYY.MM.DD.\\n' +
|
|
88
|
+
'- The node normalizes and validates the date before sending it to CiviCRM.\\n\\n' +
|
|
89
|
+
'Filters in GET MANY:\\n' +
|
|
90
|
+
'- Use API4 "where" JSON array format, e.g.:\\n' +
|
|
91
|
+
' [ ["contact_type","=","Individual"] ]\\n' +
|
|
92
|
+
' [ ["first_name","LIKE","Jul%"] ]\\n' +
|
|
93
|
+
' [ ["birth_date",">","1990-01-01"] ]\\n' +
|
|
94
|
+
' [ ["gender_id","IN",[1,2]] ]',
|
|
31
95
|
defaults: { name: 'CiviCRM' },
|
|
32
96
|
inputs: ['main'],
|
|
33
97
|
outputs: ['main'],
|
|
@@ -46,7 +110,8 @@ class CiviCrm {
|
|
|
46
110
|
{ name: 'Household', value: 'Household' },
|
|
47
111
|
],
|
|
48
112
|
displayOptions: { show: { resource: ['contact'] } },
|
|
49
|
-
description: '
|
|
113
|
+
description: 'Contact type to filter (GET MANY) or assign (CREATE/UPDATE). ' +
|
|
114
|
+
'For GET MANY, if set, a condition [ "contact_type", "=", selectedType ] is added to the where.',
|
|
50
115
|
},
|
|
51
116
|
{
|
|
52
117
|
displayName: 'Email Location Type',
|
|
@@ -59,6 +124,10 @@ class CiviCrm {
|
|
|
59
124
|
{ name: 'Other', value: 'Other' },
|
|
60
125
|
],
|
|
61
126
|
displayOptions: { show: { resource: ['contact'], operation: ['create', 'update'] } },
|
|
127
|
+
description: 'Default Location Type used for email when no prefix is provided.\\n' +
|
|
128
|
+
'- If you use "email" or "email.xxx" as field name, this Location Type will be used.\\n' +
|
|
129
|
+
'- If you use a prefixed field like "work.email", "home.email", etc., ' +
|
|
130
|
+
'the prefix overrides this selector and maps to the matching CiviCRM Location Type.',
|
|
62
131
|
},
|
|
63
132
|
{
|
|
64
133
|
displayName: 'Phone Location Type',
|
|
@@ -72,6 +141,10 @@ class CiviCrm {
|
|
|
72
141
|
{ name: 'Other', value: 'Other' },
|
|
73
142
|
],
|
|
74
143
|
displayOptions: { show: { resource: ['contact'], operation: ['create', 'update'] } },
|
|
144
|
+
description: 'Default Location Type used for phone when no prefix is provided.\\n' +
|
|
145
|
+
'- If you use "phone" or "phone.xxx" as field name, this Location Type will be used.\\n' +
|
|
146
|
+
'- If you use a prefixed field like "mobile.phone", "work.phone", etc., ' +
|
|
147
|
+
'the prefix overrides this selector and maps to the matching CiviCRM Location Type.',
|
|
75
148
|
},
|
|
76
149
|
{
|
|
77
150
|
displayName: 'Address Location Type',
|
|
@@ -85,6 +158,10 @@ class CiviCrm {
|
|
|
85
158
|
{ name: 'Other', value: 'Other' },
|
|
86
159
|
],
|
|
87
160
|
displayOptions: { show: { resource: ['contact'], operation: ['create', 'update'] } },
|
|
161
|
+
description: 'Default Location Type used for address when no prefix is provided.\\n' +
|
|
162
|
+
'- If you use "address.city", "address.country_id", etc., this Location Type will be used.\\n' +
|
|
163
|
+
'- If you use a prefixed field like "billing.address.postal_code", "home.address.city", etc., ' +
|
|
164
|
+
'the prefix overrides this selector and maps to the matching CiviCRM Location Type.',
|
|
88
165
|
},
|
|
89
166
|
{
|
|
90
167
|
displayName: 'Mark as Primary',
|
|
@@ -92,7 +169,8 @@ class CiviCrm {
|
|
|
92
169
|
type: 'boolean',
|
|
93
170
|
default: true,
|
|
94
171
|
displayOptions: { show: { resource: ['contact'], operation: ['create', 'update'] } },
|
|
95
|
-
description: 'If enabled,
|
|
172
|
+
description: 'If enabled, previous primary email/phone/address for this contact are deleted before creating the new ones. ' +
|
|
173
|
+
'Useful when you want to replace existing primary contact details.',
|
|
96
174
|
},
|
|
97
175
|
{
|
|
98
176
|
displayName: 'ID',
|
|
@@ -101,6 +179,7 @@ class CiviCrm {
|
|
|
101
179
|
default: 0,
|
|
102
180
|
required: true,
|
|
103
181
|
displayOptions: { show: { operation: ['get', 'update', 'delete'] } },
|
|
182
|
+
description: 'Record ID of the selected entity (Contact, Event, Case, etc.).',
|
|
104
183
|
},
|
|
105
184
|
...generic_1.genericFields,
|
|
106
185
|
...generic_1.upsertFields,
|
|
@@ -137,31 +216,42 @@ class CiviCrm {
|
|
|
137
216
|
const operation = this.getNodeParameter('operation', 0);
|
|
138
217
|
const entity = ENTITY_MAP[resource];
|
|
139
218
|
for (let i = 0; i < items.length; i++) {
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
const
|
|
219
|
+
const emailLocationParam = this.getNodeParameter('emailLocation', i, 'Work');
|
|
220
|
+
const phoneLocationParam = this.getNodeParameter('phoneLocation', i, 'Work');
|
|
221
|
+
const addressLocationParam = this.getNodeParameter('addressLocation', i, 'Home');
|
|
143
222
|
const isPrimary = this.getNodeParameter('isPrimary', i, true);
|
|
144
|
-
//
|
|
223
|
+
// Valores de location type efectivos (pueden ser sobreescritos por prefijo)
|
|
224
|
+
let emailLocationName = emailLocationParam;
|
|
225
|
+
let phoneLocationName = phoneLocationParam;
|
|
226
|
+
let addressLocationName = addressLocationParam;
|
|
227
|
+
// =========== GET ===========
|
|
145
228
|
if (operation === 'get') {
|
|
146
229
|
const id = this.getNodeParameter('id', i);
|
|
147
230
|
const where = [['id', '=', id]];
|
|
148
|
-
// Solo para Contact → usar chain
|
|
149
231
|
if (resource === 'contact') {
|
|
150
232
|
const params = {
|
|
151
233
|
where,
|
|
152
234
|
limit: 1,
|
|
153
|
-
select: [
|
|
235
|
+
select: [
|
|
236
|
+
'id',
|
|
237
|
+
'display_name',
|
|
238
|
+
'first_name',
|
|
239
|
+
'last_name',
|
|
240
|
+
'contact_type',
|
|
241
|
+
'gender_id',
|
|
242
|
+
'gender_id:name',
|
|
243
|
+
'birth_date',
|
|
244
|
+
],
|
|
154
245
|
chain: {
|
|
155
246
|
emails: ['Email', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
156
247
|
phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
157
248
|
addresses: ['Address', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
158
249
|
},
|
|
159
250
|
};
|
|
160
|
-
const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST',
|
|
251
|
+
const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Contact/get', params);
|
|
161
252
|
out.push({ json: (res?.values?.[0] ?? {}) });
|
|
162
253
|
}
|
|
163
254
|
else {
|
|
164
|
-
// Otras entidades → select estándar
|
|
165
255
|
const params = {
|
|
166
256
|
where,
|
|
167
257
|
limit: 1,
|
|
@@ -171,7 +261,7 @@ class CiviCrm {
|
|
|
171
261
|
out.push({ json: (res?.values?.[0] ?? {}) });
|
|
172
262
|
}
|
|
173
263
|
}
|
|
174
|
-
//
|
|
264
|
+
// =========== GET MANY ===========
|
|
175
265
|
if (operation === 'getMany') {
|
|
176
266
|
const returnAll = this.getNodeParameter('returnAll', i, false);
|
|
177
267
|
const limit = this.getNodeParameter('limit', i, 100);
|
|
@@ -186,7 +276,16 @@ class CiviCrm {
|
|
|
186
276
|
if (resource === 'contact') {
|
|
187
277
|
baseParams = {
|
|
188
278
|
where,
|
|
189
|
-
select: [
|
|
279
|
+
select: [
|
|
280
|
+
'id',
|
|
281
|
+
'display_name',
|
|
282
|
+
'first_name',
|
|
283
|
+
'last_name',
|
|
284
|
+
'contact_type',
|
|
285
|
+
'gender_id',
|
|
286
|
+
'gender_id:name',
|
|
287
|
+
'birth_date',
|
|
288
|
+
],
|
|
190
289
|
chain: {
|
|
191
290
|
emails: ['Email', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
192
291
|
phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
@@ -220,7 +319,7 @@ class CiviCrm {
|
|
|
220
319
|
out.push({ json: v });
|
|
221
320
|
}
|
|
222
321
|
}
|
|
223
|
-
//
|
|
322
|
+
// =========== CREATE / UPDATE ===========
|
|
224
323
|
const isCreate = operation === 'create';
|
|
225
324
|
if (isCreate || operation === 'update') {
|
|
226
325
|
const id = !isCreate ? this.getNodeParameter('id', i) : undefined;
|
|
@@ -229,26 +328,150 @@ class CiviCrm {
|
|
|
229
328
|
const emailData = {};
|
|
230
329
|
const phoneData = {};
|
|
231
330
|
const addressData = {};
|
|
331
|
+
// Normaliza birth_date a YYYY-MM-DD con validación
|
|
332
|
+
function normalizeBirthDate(input) {
|
|
333
|
+
if (!input)
|
|
334
|
+
return input;
|
|
335
|
+
let val = input.trim();
|
|
336
|
+
// YYYY-MM-DD
|
|
337
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(val))
|
|
338
|
+
return val;
|
|
339
|
+
// DD/MM/YYYY
|
|
340
|
+
if (/^\d{2}\/\d{2}\/\d{4}$/.test(val)) {
|
|
341
|
+
const [d, m, y] = val.split('/');
|
|
342
|
+
val = `${y}-${m}-${d}`;
|
|
343
|
+
}
|
|
344
|
+
// DD-MM-YYYY
|
|
345
|
+
else if (/^\d{2}-\d{2}-\d{4}$/.test(val)) {
|
|
346
|
+
const [d, m, y] = val.split('-');
|
|
347
|
+
val = `${y}-${m}-${d}`;
|
|
348
|
+
}
|
|
349
|
+
// YYYY/MM/DD
|
|
350
|
+
else if (/^\d{4}\/\d{2}\/\d{2}$/.test(val)) {
|
|
351
|
+
val = val.replace(/\//g, '-');
|
|
352
|
+
}
|
|
353
|
+
// YYYY.MM.DD
|
|
354
|
+
else if (/^\d{4}\.\d{2}\.\d{2}$/.test(val)) {
|
|
355
|
+
val = val.replace(/\./g, '-');
|
|
356
|
+
}
|
|
357
|
+
const date = new Date(val);
|
|
358
|
+
if (isNaN(date.getTime())) {
|
|
359
|
+
throw new Error(`Invalid birth_date format: ${input}`);
|
|
360
|
+
}
|
|
361
|
+
return val;
|
|
362
|
+
}
|
|
363
|
+
// Cargar mapa de location types solo si estamos en contacto
|
|
364
|
+
let locationTypeMap = {};
|
|
365
|
+
if (resource === 'contact') {
|
|
366
|
+
locationTypeMap = await getLocationTypeMap.call(this);
|
|
367
|
+
}
|
|
368
|
+
// Field mapping con soporte para:
|
|
369
|
+
// - email, email.xxx
|
|
370
|
+
// - phone, phone.xxx
|
|
371
|
+
// - address.xxx
|
|
372
|
+
// - <prefix>.email[.field]
|
|
373
|
+
// - <prefix>.phone[.field]
|
|
374
|
+
// - <prefix>.address[.field]
|
|
232
375
|
for (const p of pairs) {
|
|
233
376
|
if (!p.fieldName)
|
|
234
377
|
continue;
|
|
235
378
|
const key = p.fieldName.trim();
|
|
236
|
-
const
|
|
237
|
-
|
|
379
|
+
const rawVal = convertValue(p.fieldValue);
|
|
380
|
+
const val = rawVal;
|
|
381
|
+
// 1) Casos simples sin prefijo: email / email.xxx / phone / phone.xxx / address.xxx
|
|
382
|
+
// Email simple
|
|
383
|
+
if (key === 'email') {
|
|
384
|
+
emailData.email = val;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (key.startsWith('email.')) {
|
|
238
388
|
emailData[key.replace(/^email\./, '')] = val;
|
|
239
|
-
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
// Phone simple
|
|
392
|
+
if (key === 'phone') {
|
|
393
|
+
phoneData.phone = val;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (key.startsWith('phone.')) {
|
|
240
397
|
phoneData[key.replace(/^phone\./, '')] = val;
|
|
241
|
-
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
// Address simple
|
|
401
|
+
if (key.startsWith('address.')) {
|
|
242
402
|
addressData[key.replace(/^address\./, '')] = val;
|
|
243
|
-
|
|
244
|
-
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
// 2) Prefijo dinámico: <prefix>.email[.field], <prefix>.phone[.field], <prefix>.address[.field]
|
|
406
|
+
const segments = key.split('.');
|
|
407
|
+
if (segments.length >= 2 && resource === 'contact') {
|
|
408
|
+
const prefixRaw = segments[0];
|
|
409
|
+
const root = segments[1]; // email / phone / address
|
|
410
|
+
const subfield = segments.slice(2).join('.') || '';
|
|
411
|
+
const normalizedPrefix = normalizeLocationKey(prefixRaw);
|
|
412
|
+
const mappedLocationName = locationTypeMap[normalizedPrefix];
|
|
413
|
+
if (root === 'email' || root === 'phone' || root === 'address') {
|
|
414
|
+
// Si el prefijo coincide con un location type, sobreescribir el locationName correspondiente
|
|
415
|
+
if (mappedLocationName) {
|
|
416
|
+
if (root === 'email')
|
|
417
|
+
emailLocationName = mappedLocationName;
|
|
418
|
+
if (root === 'phone')
|
|
419
|
+
phoneLocationName = mappedLocationName;
|
|
420
|
+
if (root === 'address')
|
|
421
|
+
addressLocationName = mappedLocationName;
|
|
422
|
+
}
|
|
423
|
+
// Asignar campo
|
|
424
|
+
if (root === 'email') {
|
|
425
|
+
if (!subfield) {
|
|
426
|
+
emailData.email = val;
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
emailData[subfield] = val;
|
|
430
|
+
}
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (root === 'phone') {
|
|
434
|
+
if (!subfield) {
|
|
435
|
+
phoneData.phone = val;
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
phoneData[subfield] = val;
|
|
439
|
+
}
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (root === 'address') {
|
|
443
|
+
if (!subfield) {
|
|
444
|
+
// No existe campo address "plano", así que ignoramos si no hay subfield
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
addressData[subfield] = val;
|
|
448
|
+
}
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// 3) gender
|
|
454
|
+
if (key === 'gender' || key === 'gender_id') {
|
|
455
|
+
values.gender_id = val;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
// 4) birth_date
|
|
459
|
+
if (key === 'birth_date' || key === 'birth') {
|
|
460
|
+
values.birth_date = normalizeBirthDate(String(val));
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
// 5) default → campo de Contact o de la entidad
|
|
464
|
+
values[key] = val;
|
|
245
465
|
}
|
|
466
|
+
// contact_type
|
|
246
467
|
const contactType = this.getNodeParameter('contactType', i, '');
|
|
247
|
-
if (resource === 'contact' && contactType)
|
|
468
|
+
if (resource === 'contact' && contactType) {
|
|
248
469
|
values.contact_type = contactType;
|
|
470
|
+
}
|
|
471
|
+
// --- CREATE / UPDATE CONTACT ---
|
|
249
472
|
let contactId = id;
|
|
250
473
|
if (isCreate) {
|
|
251
|
-
const contactRes = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST',
|
|
474
|
+
const contactRes = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Contact/create', { values });
|
|
252
475
|
contactId = contactRes?.values?.[0]?.id;
|
|
253
476
|
if (!contactId)
|
|
254
477
|
throw new Error('Failed to create contact.');
|
|
@@ -259,34 +482,76 @@ class CiviCrm {
|
|
|
259
482
|
where: [['id', '=', contactId]],
|
|
260
483
|
});
|
|
261
484
|
}
|
|
485
|
+
// --- SUBENTITIES (solo para contact) ---
|
|
262
486
|
if (resource === 'contact') {
|
|
487
|
+
// Si es primary, limpiar anteriores
|
|
263
488
|
if (isPrimary) {
|
|
264
|
-
await GenericFunctions_1.civicrmApiRequest.call(this, 'POST',
|
|
265
|
-
where: [
|
|
489
|
+
await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Email/delete', {
|
|
490
|
+
where: [
|
|
491
|
+
['contact_id', '=', contactId],
|
|
492
|
+
['is_primary', '=', true],
|
|
493
|
+
],
|
|
266
494
|
});
|
|
267
|
-
await GenericFunctions_1.civicrmApiRequest.call(this, 'POST',
|
|
268
|
-
where: [
|
|
495
|
+
await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Phone/delete', {
|
|
496
|
+
where: [
|
|
497
|
+
['contact_id', '=', contactId],
|
|
498
|
+
['is_primary', '=', true],
|
|
499
|
+
],
|
|
269
500
|
});
|
|
270
|
-
await GenericFunctions_1.civicrmApiRequest.call(this, 'POST',
|
|
271
|
-
where: [
|
|
501
|
+
await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Address/delete', {
|
|
502
|
+
where: [
|
|
503
|
+
['contact_id', '=', contactId],
|
|
504
|
+
['is_primary', '=', true],
|
|
505
|
+
],
|
|
272
506
|
});
|
|
273
507
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
508
|
+
// Email
|
|
509
|
+
if (Object.keys(emailData).length) {
|
|
510
|
+
await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Email/create', {
|
|
511
|
+
values: {
|
|
512
|
+
...emailData,
|
|
513
|
+
contact_id: contactId,
|
|
514
|
+
is_primary: isPrimary,
|
|
515
|
+
'location_type_id:name': emailLocationName,
|
|
516
|
+
},
|
|
277
517
|
});
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
518
|
+
}
|
|
519
|
+
// Phone
|
|
520
|
+
if (Object.keys(phoneData).length) {
|
|
521
|
+
await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Phone/create', {
|
|
522
|
+
values: {
|
|
523
|
+
...phoneData,
|
|
524
|
+
contact_id: contactId,
|
|
525
|
+
is_primary: isPrimary,
|
|
526
|
+
'location_type_id:name': phoneLocationName,
|
|
527
|
+
},
|
|
281
528
|
});
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
529
|
+
}
|
|
530
|
+
// Address
|
|
531
|
+
if (Object.keys(addressData).length) {
|
|
532
|
+
await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Address/create', {
|
|
533
|
+
values: {
|
|
534
|
+
...addressData,
|
|
535
|
+
contact_id: contactId,
|
|
536
|
+
is_primary: isPrimary,
|
|
537
|
+
'location_type_id:name': addressLocationName,
|
|
538
|
+
},
|
|
285
539
|
});
|
|
540
|
+
}
|
|
286
541
|
}
|
|
542
|
+
// --- FINAL GET (con gender + birth_date + chain) ---
|
|
287
543
|
const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, {
|
|
288
544
|
where: [['id', '=', contactId]],
|
|
289
|
-
select: [
|
|
545
|
+
select: [
|
|
546
|
+
'id',
|
|
547
|
+
'display_name',
|
|
548
|
+
'first_name',
|
|
549
|
+
'last_name',
|
|
550
|
+
'contact_type',
|
|
551
|
+
'gender_id',
|
|
552
|
+
'gender_id:name',
|
|
553
|
+
'birth_date',
|
|
554
|
+
],
|
|
290
555
|
...(resource === 'contact'
|
|
291
556
|
? {
|
|
292
557
|
chain: {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
|
|
1
2
|
import type {
|
|
2
3
|
IExecuteFunctions,
|
|
3
4
|
ILoadOptionsFunctions,
|
|
@@ -42,6 +43,47 @@ const ENTITY_MAP: Record<Resource, string> = {
|
|
|
42
43
|
|
|
43
44
|
type Operation = 'get' | 'getMany' | 'create' | 'update' | 'delete';
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Caché global de location types
|
|
48
|
+
* Se rellena la primera vez que se usa y dura hasta que se reinicia n8n.
|
|
49
|
+
* key: normalizado (lowercase, sin espacios/ símbolos)
|
|
50
|
+
* value: option_value.name (para usar en location_type_id:name)
|
|
51
|
+
*/
|
|
52
|
+
let locationTypeCache: Record<string, string> | null = null;
|
|
53
|
+
|
|
54
|
+
function normalizeLocationKey(s: string): string {
|
|
55
|
+
return String(s || '')
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9]+/g, '');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function getLocationTypeMap(this: IExecuteFunctions): Promise<Record<string, string>> {
|
|
61
|
+
if (locationTypeCache) return locationTypeCache;
|
|
62
|
+
|
|
63
|
+
const res = await civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/OptionValue/get', {
|
|
64
|
+
where: [['option_group_id:name', '=', 'location_type']],
|
|
65
|
+
select: ['name', 'label'],
|
|
66
|
+
limit: 0,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const values = (res?.values || []) as Array<{ name?: string; label?: string }>;
|
|
70
|
+
const map: Record<string, string> = {};
|
|
71
|
+
|
|
72
|
+
for (const v of values) {
|
|
73
|
+
const name = v.name || '';
|
|
74
|
+
const label = v.label || '';
|
|
75
|
+
|
|
76
|
+
const normName = normalizeLocationKey(name);
|
|
77
|
+
const normLabel = normalizeLocationKey(label);
|
|
78
|
+
|
|
79
|
+
if (normName) map[normName] = name;
|
|
80
|
+
if (normLabel) map[normLabel] = name;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
locationTypeCache = map;
|
|
84
|
+
return map;
|
|
85
|
+
}
|
|
86
|
+
|
|
45
87
|
/**
|
|
46
88
|
* Nodo principal CiviCRM para n8n
|
|
47
89
|
*/
|
|
@@ -52,7 +94,37 @@ export class CiviCrm implements INodeType {
|
|
|
52
94
|
icon: 'file:civicrm.svg',
|
|
53
95
|
group: ['transform'],
|
|
54
96
|
version: 1,
|
|
55
|
-
description:
|
|
97
|
+
description:
|
|
98
|
+
'Interact with CiviCRM API v4 (Civi-Go compatible).\\n\\n' +
|
|
99
|
+
'Email / Phone / Address mapping:\\n' +
|
|
100
|
+
'- You can send simple fields: email, phone, address.city, address.country_id, etc.\\n' +
|
|
101
|
+
'- You can also use dynamic prefixes based on real CiviCRM Location Types.\\n' +
|
|
102
|
+
' Example prefixes (from Location Types): Home, Work, Billing, Mobile, Oficina, etc.\\n' +
|
|
103
|
+
'- Prefix format: <location-prefix>.<entity>[.<subfield>]\\n' +
|
|
104
|
+
' Examples:\\n' +
|
|
105
|
+
' email = test@ixiam.com\\n' +
|
|
106
|
+
' phone = 600123123\\n' +
|
|
107
|
+
' address.city = Barcelona\\n' +
|
|
108
|
+
' work.email = test@company.com (Location Type = Work)\\n' +
|
|
109
|
+
' billing.address.postal_code = 28010 (Location Type = Billing)\\n' +
|
|
110
|
+
' home.phone.phone_type_id = 2 (Location Type = Home)\\n\\n' +
|
|
111
|
+
'Subfields examples:\\n' +
|
|
112
|
+
'- email.is_primary, email.signature_html, email.signature_text\\n' +
|
|
113
|
+
'- phone.phone_type_id, phone.phone_ext\\n' +
|
|
114
|
+
'- address.country_id, address.state_province_id, address.postal_code, address.street_address\\n\\n' +
|
|
115
|
+
'Prefixes and Location Types:\\n' +
|
|
116
|
+
'- The prefix (e.g. work, home, billing, oficina) is matched against CiviCRM Location Types (name/label).\\n' +
|
|
117
|
+
'- If the prefix matches, the node sets location_type_id:name automatically.\\n' +
|
|
118
|
+
'- If no prefix is used (e.g. email, phone, address.city), the node uses the Location Type selectors.\\n\\n' +
|
|
119
|
+
'birth_date:\\n' +
|
|
120
|
+
'- Accepted formats: YYYY-MM-DD, DD/MM/YYYY, DD-MM-YYYY, YYYY/MM/DD, YYYY.MM.DD.\\n' +
|
|
121
|
+
'- The node normalizes and validates the date before sending it to CiviCRM.\\n\\n' +
|
|
122
|
+
'Filters in GET MANY:\\n' +
|
|
123
|
+
'- Use API4 "where" JSON array format, e.g.:\\n' +
|
|
124
|
+
' [ ["contact_type","=","Individual"] ]\\n' +
|
|
125
|
+
' [ ["first_name","LIKE","Jul%"] ]\\n' +
|
|
126
|
+
' [ ["birth_date",">","1990-01-01"] ]\\n' +
|
|
127
|
+
' [ ["gender_id","IN",[1,2]] ]',
|
|
56
128
|
defaults: { name: 'CiviCRM' },
|
|
57
129
|
inputs: ['main'],
|
|
58
130
|
outputs: ['main'],
|
|
@@ -71,7 +143,9 @@ export class CiviCrm implements INodeType {
|
|
|
71
143
|
{ name: 'Household', value: 'Household' },
|
|
72
144
|
],
|
|
73
145
|
displayOptions: { show: { resource: ['contact'] } },
|
|
74
|
-
description:
|
|
146
|
+
description:
|
|
147
|
+
'Contact type to filter (GET MANY) or assign (CREATE/UPDATE). ' +
|
|
148
|
+
'For GET MANY, if set, a condition [ "contact_type", "=", selectedType ] is added to the where.',
|
|
75
149
|
},
|
|
76
150
|
{
|
|
77
151
|
displayName: 'Email Location Type',
|
|
@@ -84,6 +158,11 @@ export class CiviCrm implements INodeType {
|
|
|
84
158
|
{ name: 'Other', value: 'Other' },
|
|
85
159
|
],
|
|
86
160
|
displayOptions: { show: { resource: ['contact'], operation: ['create', 'update'] } },
|
|
161
|
+
description:
|
|
162
|
+
'Default Location Type used for email when no prefix is provided.\\n' +
|
|
163
|
+
'- If you use "email" or "email.xxx" as field name, this Location Type will be used.\\n' +
|
|
164
|
+
'- If you use a prefixed field like "work.email", "home.email", etc., ' +
|
|
165
|
+
'the prefix overrides this selector and maps to the matching CiviCRM Location Type.',
|
|
87
166
|
},
|
|
88
167
|
{
|
|
89
168
|
displayName: 'Phone Location Type',
|
|
@@ -97,6 +176,11 @@ export class CiviCrm implements INodeType {
|
|
|
97
176
|
{ name: 'Other', value: 'Other' },
|
|
98
177
|
],
|
|
99
178
|
displayOptions: { show: { resource: ['contact'], operation: ['create', 'update'] } },
|
|
179
|
+
description:
|
|
180
|
+
'Default Location Type used for phone when no prefix is provided.\\n' +
|
|
181
|
+
'- If you use "phone" or "phone.xxx" as field name, this Location Type will be used.\\n' +
|
|
182
|
+
'- If you use a prefixed field like "mobile.phone", "work.phone", etc., ' +
|
|
183
|
+
'the prefix overrides this selector and maps to the matching CiviCRM Location Type.',
|
|
100
184
|
},
|
|
101
185
|
{
|
|
102
186
|
displayName: 'Address Location Type',
|
|
@@ -110,6 +194,11 @@ export class CiviCrm implements INodeType {
|
|
|
110
194
|
{ name: 'Other', value: 'Other' },
|
|
111
195
|
],
|
|
112
196
|
displayOptions: { show: { resource: ['contact'], operation: ['create', 'update'] } },
|
|
197
|
+
description:
|
|
198
|
+
'Default Location Type used for address when no prefix is provided.\\n' +
|
|
199
|
+
'- If you use "address.city", "address.country_id", etc., this Location Type will be used.\\n' +
|
|
200
|
+
'- If you use a prefixed field like "billing.address.postal_code", "home.address.city", etc., ' +
|
|
201
|
+
'the prefix overrides this selector and maps to the matching CiviCRM Location Type.',
|
|
113
202
|
},
|
|
114
203
|
{
|
|
115
204
|
displayName: 'Mark as Primary',
|
|
@@ -117,7 +206,9 @@ export class CiviCrm implements INodeType {
|
|
|
117
206
|
type: 'boolean',
|
|
118
207
|
default: true,
|
|
119
208
|
displayOptions: { show: { resource: ['contact'], operation: ['create', 'update'] } },
|
|
120
|
-
description:
|
|
209
|
+
description:
|
|
210
|
+
'If enabled, previous primary email/phone/address for this contact are deleted before creating the new ones. ' +
|
|
211
|
+
'Useful when you want to replace existing primary contact details.',
|
|
121
212
|
},
|
|
122
213
|
{
|
|
123
214
|
displayName: 'ID',
|
|
@@ -126,6 +217,7 @@ export class CiviCrm implements INodeType {
|
|
|
126
217
|
default: 0,
|
|
127
218
|
required: true,
|
|
128
219
|
displayOptions: { show: { operation: ['get', 'update', 'delete'] } },
|
|
220
|
+
description: 'Record ID of the selected entity (Contact, Event, Case, etc.).',
|
|
129
221
|
},
|
|
130
222
|
...genericFields,
|
|
131
223
|
...upsertFields,
|
|
@@ -161,193 +253,463 @@ export class CiviCrm implements INodeType {
|
|
|
161
253
|
},
|
|
162
254
|
};
|
|
163
255
|
|
|
164
|
-
async execute(this: IExecuteFunctions) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const resource = this.getNodeParameter('resource', 0) as Resource;
|
|
169
|
-
const operation = this.getNodeParameter('operation', 0) as Operation;
|
|
170
|
-
const entity = ENTITY_MAP[resource];
|
|
171
|
-
|
|
172
|
-
for (let i = 0; i < items.length; i++) {
|
|
173
|
-
const emailLocation = this.getNodeParameter('emailLocation', i, 'Work') as string;
|
|
174
|
-
const phoneLocation = this.getNodeParameter('phoneLocation', i, 'Work') as string;
|
|
175
|
-
const addressLocation = this.getNodeParameter('addressLocation', i, 'Home') as string;
|
|
176
|
-
const isPrimary = this.getNodeParameter('isPrimary', i, true) as boolean;
|
|
177
|
-
|
|
178
|
-
// === GET ===
|
|
179
|
-
if (operation === 'get') {
|
|
180
|
-
const id = this.getNodeParameter('id', i) as number;
|
|
181
|
-
const where = [['id', '=', id]];
|
|
182
|
-
|
|
183
|
-
// Solo para Contact → usar chain
|
|
184
|
-
if (resource === 'contact') {
|
|
185
|
-
const params = {
|
|
186
|
-
where,
|
|
187
|
-
limit: 1,
|
|
188
|
-
select: ['id', 'display_name', 'first_name', 'last_name', 'contact_type'],
|
|
189
|
-
chain: {
|
|
190
|
-
emails: ['Email', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
191
|
-
phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
192
|
-
addresses: ['Address', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
193
|
-
},
|
|
194
|
-
};
|
|
195
|
-
const res = await civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/Contact/get`, params);
|
|
196
|
-
out.push({ json: (res?.values?.[0] ?? {}) as IDataObject });
|
|
197
|
-
} else {
|
|
198
|
-
// Otras entidades → select estándar
|
|
199
|
-
const params = {
|
|
200
|
-
where,
|
|
201
|
-
limit: 1,
|
|
202
|
-
select: ['id', 'name', 'title', 'subject', 'display_name'],
|
|
203
|
-
};
|
|
204
|
-
const res = await civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, params);
|
|
205
|
-
out.push({ json: (res?.values?.[0] ?? {}) as IDataObject });
|
|
206
|
-
}
|
|
207
|
-
}
|
|
256
|
+
async execute(this: IExecuteFunctions) {
|
|
257
|
+
const items = this.getInputData();
|
|
258
|
+
const out: INodeExecutionData[] = [];
|
|
208
259
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const limit = this.getNodeParameter('limit', i, 100) as number;
|
|
213
|
-
const whereJson = this.getNodeParameter('whereJson', i, '') as string;
|
|
260
|
+
const resource = this.getNodeParameter('resource', 0) as Resource;
|
|
261
|
+
const operation = this.getNodeParameter('operation', 0) as Operation;
|
|
262
|
+
const entity = ENTITY_MAP[resource];
|
|
214
263
|
|
|
215
|
-
|
|
264
|
+
for (let i = 0; i < items.length; i++) {
|
|
265
|
+
const emailLocationParam = this.getNodeParameter('emailLocation', i, 'Work') as string;
|
|
266
|
+
const phoneLocationParam = this.getNodeParameter('phoneLocation', i, 'Work') as string;
|
|
267
|
+
const addressLocationParam = this.getNodeParameter('addressLocation', i, 'Home') as string;
|
|
268
|
+
const isPrimary = this.getNodeParameter('isPrimary', i, true) as boolean;
|
|
216
269
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
270
|
+
// Valores de location type efectivos (pueden ser sobreescritos por prefijo)
|
|
271
|
+
let emailLocationName = emailLocationParam;
|
|
272
|
+
let phoneLocationName = phoneLocationParam;
|
|
273
|
+
let addressLocationName = addressLocationParam;
|
|
221
274
|
|
|
222
|
-
|
|
275
|
+
// =========== GET ===========
|
|
276
|
+
if (operation === 'get') {
|
|
277
|
+
const id = this.getNodeParameter('id', i) as number;
|
|
278
|
+
const where = [['id', '=', id]];
|
|
223
279
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
280
|
+
if (resource === 'contact') {
|
|
281
|
+
const params = {
|
|
282
|
+
where,
|
|
283
|
+
limit: 1,
|
|
284
|
+
select: [
|
|
285
|
+
'id',
|
|
286
|
+
'display_name',
|
|
287
|
+
'first_name',
|
|
288
|
+
'last_name',
|
|
289
|
+
'contact_type',
|
|
290
|
+
'gender_id',
|
|
291
|
+
'gender_id:name',
|
|
292
|
+
'birth_date',
|
|
293
|
+
],
|
|
294
|
+
chain: {
|
|
295
|
+
emails: ['Email', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
296
|
+
phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
297
|
+
addresses: ['Address', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
const res = await civicrmApiRequest.call(
|
|
301
|
+
this,
|
|
302
|
+
'POST',
|
|
303
|
+
'/civicrm/ajax/api4/Contact/get',
|
|
304
|
+
params,
|
|
305
|
+
);
|
|
306
|
+
out.push({ json: (res?.values?.[0] ?? {}) as IDataObject });
|
|
307
|
+
} else {
|
|
308
|
+
const params = {
|
|
309
|
+
where,
|
|
310
|
+
limit: 1,
|
|
311
|
+
select: ['id', 'name', 'title', 'subject', 'display_name'],
|
|
312
|
+
};
|
|
313
|
+
const res = await civicrmApiRequest.call(
|
|
314
|
+
this,
|
|
315
|
+
'POST',
|
|
316
|
+
`/civicrm/ajax/api4/${entity}/get`,
|
|
317
|
+
params,
|
|
318
|
+
);
|
|
319
|
+
out.push({ json: (res?.values?.[0] ?? {}) as IDataObject });
|
|
320
|
+
}
|
|
239
321
|
}
|
|
240
322
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
323
|
+
// =========== GET MANY ===========
|
|
324
|
+
if (operation === 'getMany') {
|
|
325
|
+
const returnAll = this.getNodeParameter('returnAll', i, false) as boolean;
|
|
326
|
+
const limit = this.getNodeParameter('limit', i, 100) as number;
|
|
327
|
+
const whereJson = this.getNodeParameter('whereJson', i, '') as string;
|
|
328
|
+
|
|
329
|
+
let where = whereJson ? JSON.parse(whereJson) : [];
|
|
330
|
+
|
|
331
|
+
if (resource === 'contact') {
|
|
332
|
+
const contactType = this.getNodeParameter('contactType', i, '') as string;
|
|
333
|
+
if (contactType) where.push(['contact_type', '=', contactType]);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let baseParams: Record<string, unknown>;
|
|
337
|
+
|
|
338
|
+
if (resource === 'contact') {
|
|
339
|
+
baseParams = {
|
|
340
|
+
where,
|
|
341
|
+
select: [
|
|
342
|
+
'id',
|
|
343
|
+
'display_name',
|
|
344
|
+
'first_name',
|
|
345
|
+
'last_name',
|
|
346
|
+
'contact_type',
|
|
347
|
+
'gender_id',
|
|
348
|
+
'gender_id:name',
|
|
349
|
+
'birth_date',
|
|
350
|
+
],
|
|
351
|
+
chain: {
|
|
352
|
+
emails: ['Email', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
353
|
+
phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
354
|
+
addresses: ['Address', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
} else {
|
|
358
|
+
baseParams = {
|
|
359
|
+
where,
|
|
360
|
+
select: ['id', 'name', 'title', 'subject', 'display_name'],
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (returnAll) {
|
|
365
|
+
let offset = 0;
|
|
366
|
+
const page = 500;
|
|
367
|
+
|
|
368
|
+
while (true) {
|
|
369
|
+
const res = await civicrmApiRequest.call(
|
|
370
|
+
this,
|
|
371
|
+
'POST',
|
|
372
|
+
`/civicrm/ajax/api4/${entity}/get`,
|
|
373
|
+
{ ...baseParams, limit: page, offset },
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const vals = (res?.values ?? []) as IDataObject[];
|
|
377
|
+
for (const v of vals) out.push({ json: v });
|
|
378
|
+
|
|
379
|
+
if (vals.length < page) break;
|
|
380
|
+
offset += page;
|
|
381
|
+
}
|
|
382
|
+
} else {
|
|
245
383
|
const res = await civicrmApiRequest.call(
|
|
246
384
|
this,
|
|
247
385
|
'POST',
|
|
248
386
|
`/civicrm/ajax/api4/${entity}/get`,
|
|
249
|
-
{ ...baseParams, limit
|
|
387
|
+
{ ...baseParams, limit },
|
|
250
388
|
);
|
|
251
389
|
const vals = (res?.values ?? []) as IDataObject[];
|
|
252
390
|
for (const v of vals) out.push({ json: v });
|
|
253
|
-
if (vals.length < page) break;
|
|
254
|
-
offset += page;
|
|
255
391
|
}
|
|
256
|
-
} else {
|
|
257
|
-
const res = await civicrmApiRequest.call(
|
|
258
|
-
this,
|
|
259
|
-
'POST',
|
|
260
|
-
`/civicrm/ajax/api4/${entity}/get`,
|
|
261
|
-
{ ...baseParams, limit },
|
|
262
|
-
);
|
|
263
|
-
const vals = (res?.values ?? []) as IDataObject[];
|
|
264
|
-
for (const v of vals) out.push({ json: v });
|
|
265
392
|
}
|
|
266
|
-
}
|
|
267
393
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const pairs = this.getNodeParameter('fields.field', i, []) as Array<{ fieldName: string; fieldValue: string }>;
|
|
273
|
-
const values: Record<string, unknown> = {};
|
|
274
|
-
const emailData: Record<string, unknown> = {};
|
|
275
|
-
const phoneData: Record<string, unknown> = {};
|
|
276
|
-
const addressData: Record<string, unknown> = {};
|
|
277
|
-
|
|
278
|
-
for (const p of pairs) {
|
|
279
|
-
if (!p.fieldName) continue;
|
|
280
|
-
const key = p.fieldName.trim();
|
|
281
|
-
const val = convertValue(p.fieldValue);
|
|
282
|
-
if (key.startsWith('email.')) emailData[key.replace(/^email\./, '')] = val;
|
|
283
|
-
else if (key.startsWith('phone.')) phoneData[key.replace(/^phone\./, '')] = val;
|
|
284
|
-
else if (key.startsWith('address.')) addressData[key.replace(/^address\./, '')] = val;
|
|
285
|
-
else values[key] = val;
|
|
286
|
-
}
|
|
394
|
+
// =========== CREATE / UPDATE ===========
|
|
395
|
+
const isCreate = operation === 'create';
|
|
396
|
+
if (isCreate || operation === 'update') {
|
|
397
|
+
const id = !isCreate ? (this.getNodeParameter('id', i) as number) : undefined;
|
|
287
398
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
await civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/update`, {
|
|
298
|
-
values,
|
|
299
|
-
where: [['id', '=', contactId]],
|
|
300
|
-
});
|
|
301
|
-
}
|
|
399
|
+
const pairs = this.getNodeParameter('fields.field', i, []) as Array<{
|
|
400
|
+
fieldName: string;
|
|
401
|
+
fieldValue: string;
|
|
402
|
+
}>;
|
|
403
|
+
|
|
404
|
+
const values: Record<string, unknown> = {};
|
|
405
|
+
const emailData: Record<string, unknown> = {};
|
|
406
|
+
const phoneData: Record<string, unknown> = {};
|
|
407
|
+
const addressData: Record<string, unknown> = {};
|
|
302
408
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
})
|
|
409
|
+
// Normaliza birth_date a YYYY-MM-DD con validación
|
|
410
|
+
function normalizeBirthDate(input: string): string {
|
|
411
|
+
if (!input) return input;
|
|
412
|
+
|
|
413
|
+
let val = input.trim();
|
|
414
|
+
|
|
415
|
+
// YYYY-MM-DD
|
|
416
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(val)) return val;
|
|
417
|
+
|
|
418
|
+
// DD/MM/YYYY
|
|
419
|
+
if (/^\d{2}\/\d{2}\/\d{4}$/.test(val)) {
|
|
420
|
+
const [d, m, y] = val.split('/');
|
|
421
|
+
val = `${y}-${m}-${d}`;
|
|
422
|
+
}
|
|
423
|
+
// DD-MM-YYYY
|
|
424
|
+
else if (/^\d{2}-\d{2}-\d{4}$/.test(val)) {
|
|
425
|
+
const [d, m, y] = val.split('-');
|
|
426
|
+
val = `${y}-${m}-${d}`;
|
|
427
|
+
}
|
|
428
|
+
// YYYY/MM/DD
|
|
429
|
+
else if (/^\d{4}\/\d{2}\/\d{2}$/.test(val)) {
|
|
430
|
+
val = val.replace(/\//g, '-');
|
|
431
|
+
}
|
|
432
|
+
// YYYY.MM.DD
|
|
433
|
+
else if (/^\d{4}\.\d{2}\.\d{2}$/.test(val)) {
|
|
434
|
+
val = val.replace(/\./g, '-');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const date = new Date(val);
|
|
438
|
+
if (isNaN(date.getTime())) {
|
|
439
|
+
throw new Error(`Invalid birth_date format: ${input}`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return val;
|
|
314
443
|
}
|
|
315
444
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
445
|
+
// Cargar mapa de location types solo si estamos en contacto
|
|
446
|
+
let locationTypeMap: Record<string, string> = {};
|
|
447
|
+
if (resource === 'contact') {
|
|
448
|
+
locationTypeMap = await getLocationTypeMap.call(this);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Field mapping con soporte para:
|
|
452
|
+
// - email, email.xxx
|
|
453
|
+
// - phone, phone.xxx
|
|
454
|
+
// - address.xxx
|
|
455
|
+
// - <prefix>.email[.field]
|
|
456
|
+
// - <prefix>.phone[.field]
|
|
457
|
+
// - <prefix>.address[.field]
|
|
458
|
+
for (const p of pairs) {
|
|
459
|
+
if (!p.fieldName) continue;
|
|
460
|
+
|
|
461
|
+
const key = p.fieldName.trim();
|
|
462
|
+
const rawVal = convertValue(p.fieldValue);
|
|
463
|
+
const val = rawVal;
|
|
464
|
+
|
|
465
|
+
// 1) Casos simples sin prefijo: email / email.xxx / phone / phone.xxx / address.xxx
|
|
466
|
+
|
|
467
|
+
// Email simple
|
|
468
|
+
if (key === 'email') {
|
|
469
|
+
emailData.email = val;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (key.startsWith('email.')) {
|
|
473
|
+
emailData[key.replace(/^email\./, '')] = val;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Phone simple
|
|
478
|
+
if (key === 'phone') {
|
|
479
|
+
phoneData.phone = val;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (key.startsWith('phone.')) {
|
|
483
|
+
phoneData[key.replace(/^phone\./, '')] = val;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Address simple
|
|
488
|
+
if (key.startsWith('address.')) {
|
|
489
|
+
addressData[key.replace(/^address\./, '')] = val;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 2) Prefijo dinámico: <prefix>.email[.field], <prefix>.phone[.field], <prefix>.address[.field]
|
|
494
|
+
const segments = key.split('.');
|
|
495
|
+
if (segments.length >= 2 && resource === 'contact') {
|
|
496
|
+
const prefixRaw = segments[0];
|
|
497
|
+
const root = segments[1]; // email / phone / address
|
|
498
|
+
const subfield = segments.slice(2).join('.') || '';
|
|
499
|
+
|
|
500
|
+
const normalizedPrefix = normalizeLocationKey(prefixRaw);
|
|
501
|
+
const mappedLocationName = locationTypeMap[normalizedPrefix];
|
|
329
502
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
503
|
+
if (root === 'email' || root === 'phone' || root === 'address') {
|
|
504
|
+
// Si el prefijo coincide con un location type, sobreescribir el locationName correspondiente
|
|
505
|
+
if (mappedLocationName) {
|
|
506
|
+
if (root === 'email') emailLocationName = mappedLocationName;
|
|
507
|
+
if (root === 'phone') phoneLocationName = mappedLocationName;
|
|
508
|
+
if (root === 'address') addressLocationName = mappedLocationName;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Asignar campo
|
|
512
|
+
if (root === 'email') {
|
|
513
|
+
if (!subfield) {
|
|
514
|
+
emailData.email = val;
|
|
515
|
+
} else {
|
|
516
|
+
emailData[subfield] = val;
|
|
517
|
+
}
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (root === 'phone') {
|
|
522
|
+
if (!subfield) {
|
|
523
|
+
phoneData.phone = val;
|
|
524
|
+
} else {
|
|
525
|
+
phoneData[subfield] = val;
|
|
526
|
+
}
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (root === 'address') {
|
|
531
|
+
if (!subfield) {
|
|
532
|
+
// No existe campo address "plano", así que ignoramos si no hay subfield
|
|
533
|
+
} else {
|
|
534
|
+
addressData[subfield] = val;
|
|
535
|
+
}
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// 3) gender
|
|
542
|
+
if (key === 'gender' || key === 'gender_id') {
|
|
543
|
+
values.gender_id = val;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// 4) birth_date
|
|
548
|
+
if (key === 'birth_date' || key === 'birth') {
|
|
549
|
+
values.birth_date = normalizeBirthDate(String(val));
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// 5) default → campo de Contact o de la entidad
|
|
554
|
+
values[key] = val;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// contact_type
|
|
558
|
+
const contactType = this.getNodeParameter('contactType', i, '') as string;
|
|
559
|
+
if (resource === 'contact' && contactType) {
|
|
560
|
+
values.contact_type = contactType;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// --- CREATE / UPDATE CONTACT ---
|
|
564
|
+
let contactId = id;
|
|
565
|
+
if (isCreate) {
|
|
566
|
+
const contactRes = await civicrmApiRequest.call(
|
|
567
|
+
this,
|
|
568
|
+
'POST',
|
|
569
|
+
'/civicrm/ajax/api4/Contact/create',
|
|
570
|
+
{ values },
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
contactId = contactRes?.values?.[0]?.id;
|
|
574
|
+
if (!contactId) throw new Error('Failed to create contact.');
|
|
575
|
+
} else {
|
|
576
|
+
await civicrmApiRequest.call(
|
|
577
|
+
this,
|
|
578
|
+
'POST',
|
|
579
|
+
`/civicrm/ajax/api4/${entity}/update`,
|
|
580
|
+
{
|
|
581
|
+
values,
|
|
582
|
+
where: [['id', '=', contactId]],
|
|
583
|
+
},
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// --- SUBENTITIES (solo para contact) ---
|
|
588
|
+
if (resource === 'contact') {
|
|
589
|
+
// Si es primary, limpiar anteriores
|
|
590
|
+
if (isPrimary) {
|
|
591
|
+
await civicrmApiRequest.call(
|
|
592
|
+
this,
|
|
593
|
+
'POST',
|
|
594
|
+
'/civicrm/ajax/api4/Email/delete',
|
|
595
|
+
{
|
|
596
|
+
where: [
|
|
597
|
+
['contact_id', '=', contactId],
|
|
598
|
+
['is_primary', '=', true],
|
|
599
|
+
],
|
|
339
600
|
},
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
601
|
+
);
|
|
602
|
+
await civicrmApiRequest.call(
|
|
603
|
+
this,
|
|
604
|
+
'POST',
|
|
605
|
+
'/civicrm/ajax/api4/Phone/delete',
|
|
606
|
+
{
|
|
607
|
+
where: [
|
|
608
|
+
['contact_id', '=', contactId],
|
|
609
|
+
['is_primary', '=', true],
|
|
610
|
+
],
|
|
611
|
+
},
|
|
612
|
+
);
|
|
613
|
+
await civicrmApiRequest.call(
|
|
614
|
+
this,
|
|
615
|
+
'POST',
|
|
616
|
+
'/civicrm/ajax/api4/Address/delete',
|
|
617
|
+
{
|
|
618
|
+
where: [
|
|
619
|
+
['contact_id', '=', contactId],
|
|
620
|
+
['is_primary', '=', true],
|
|
621
|
+
],
|
|
622
|
+
},
|
|
623
|
+
);
|
|
624
|
+
}
|
|
346
625
|
|
|
347
|
-
|
|
348
|
-
|
|
626
|
+
// Email
|
|
627
|
+
if (Object.keys(emailData).length) {
|
|
628
|
+
await civicrmApiRequest.call(
|
|
629
|
+
this,
|
|
630
|
+
'POST',
|
|
631
|
+
'/civicrm/ajax/api4/Email/create',
|
|
632
|
+
{
|
|
633
|
+
values: {
|
|
634
|
+
...emailData,
|
|
635
|
+
contact_id: contactId,
|
|
636
|
+
is_primary: isPrimary,
|
|
637
|
+
'location_type_id:name': emailLocationName,
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Phone
|
|
644
|
+
if (Object.keys(phoneData).length) {
|
|
645
|
+
await civicrmApiRequest.call(
|
|
646
|
+
this,
|
|
647
|
+
'POST',
|
|
648
|
+
'/civicrm/ajax/api4/Phone/create',
|
|
649
|
+
{
|
|
650
|
+
values: {
|
|
651
|
+
...phoneData,
|
|
652
|
+
contact_id: contactId,
|
|
653
|
+
is_primary: isPrimary,
|
|
654
|
+
'location_type_id:name': phoneLocationName,
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
);
|
|
658
|
+
}
|
|
349
659
|
|
|
660
|
+
// Address
|
|
661
|
+
if (Object.keys(addressData).length) {
|
|
662
|
+
await civicrmApiRequest.call(
|
|
663
|
+
this,
|
|
664
|
+
'POST',
|
|
665
|
+
'/civicrm/ajax/api4/Address/create',
|
|
666
|
+
{
|
|
667
|
+
values: {
|
|
668
|
+
...addressData,
|
|
669
|
+
contact_id: contactId,
|
|
670
|
+
is_primary: isPrimary,
|
|
671
|
+
'location_type_id:name': addressLocationName,
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// --- FINAL GET (con gender + birth_date + chain) ---
|
|
679
|
+
const res = await civicrmApiRequest.call(
|
|
680
|
+
this,
|
|
681
|
+
'POST',
|
|
682
|
+
`/civicrm/ajax/api4/${entity}/get`,
|
|
683
|
+
{
|
|
684
|
+
where: [['id', '=', contactId]],
|
|
685
|
+
select: [
|
|
686
|
+
'id',
|
|
687
|
+
'display_name',
|
|
688
|
+
'first_name',
|
|
689
|
+
'last_name',
|
|
690
|
+
'contact_type',
|
|
691
|
+
'gender_id',
|
|
692
|
+
'gender_id:name',
|
|
693
|
+
'birth_date',
|
|
694
|
+
],
|
|
695
|
+
...(resource === 'contact'
|
|
696
|
+
? {
|
|
697
|
+
chain: {
|
|
698
|
+
emails: ['Email', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
699
|
+
phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
700
|
+
addresses: ['Address', 'get', { where: [['contact_id', '=', '$id']] }],
|
|
701
|
+
},
|
|
702
|
+
}
|
|
703
|
+
: {}),
|
|
704
|
+
},
|
|
705
|
+
);
|
|
350
706
|
|
|
707
|
+
out.push({ json: (res?.values?.[0] ?? {}) as IDataObject });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return [out];
|
|
712
|
+
}
|
|
351
713
|
}
|
|
352
714
|
|
|
353
715
|
function convertValue(val: string): unknown {
|