@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.
@@ -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: 'Filter or assign contact type',
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, replaces previous primary email/phone/address with this one',
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 emailLocation = this.getNodeParameter('emailLocation', i, 'Work');
141
- const phoneLocation = this.getNodeParameter('phoneLocation', i, 'Work');
142
- const addressLocation = this.getNodeParameter('addressLocation', i, 'Home');
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
- // === GET ===
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: ['id', 'display_name', 'first_name', 'last_name', 'contact_type'],
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', `/civicrm/ajax/api4/Contact/get`, params);
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
- // === GET MANY ===
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: ['id', 'display_name', 'first_name', 'last_name', 'contact_type'],
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
- // === CREATE y UPDATE ===
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 val = convertValue(p.fieldValue);
237
- if (key.startsWith('email.'))
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
- else if (key.startsWith('phone.'))
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
- else if (key.startsWith('address.'))
398
+ continue;
399
+ }
400
+ // Address simple
401
+ if (key.startsWith('address.')) {
242
402
  addressData[key.replace(/^address\./, '')] = val;
243
- else
244
- values[key] = val;
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', `/civicrm/ajax/api4/Contact/create`, { values });
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', `/civicrm/ajax/api4/Email/delete`, {
265
- where: [['contact_id', '=', contactId], ['is_primary', '=', true]],
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', `/civicrm/ajax/api4/Phone/delete`, {
268
- where: [['contact_id', '=', contactId], ['is_primary', '=', true]],
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', `/civicrm/ajax/api4/Address/delete`, {
271
- where: [['contact_id', '=', contactId], ['is_primary', '=', true]],
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
- if (Object.keys(emailData).length)
275
- await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/Email/create`, {
276
- values: { ...emailData, contact_id: contactId, is_primary: isPrimary, 'location_type_id:name': emailLocation },
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
- if (Object.keys(phoneData).length)
279
- await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/Phone/create`, {
280
- values: { ...phoneData, contact_id: contactId, is_primary: isPrimary, 'location_type_id:name': phoneLocation },
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
- if (Object.keys(addressData).length)
283
- await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/Address/create`, {
284
- values: { ...addressData, contact_id: contactId, is_primary: isPrimary, 'location_type_id:name': addressLocation },
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: ['id', 'display_name', 'first_name', 'last_name', 'contact_type'],
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: 'Interact with CiviCRM API v4 (Civi-Go compatible)',
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: 'Filter or assign contact type',
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: 'If enabled, replaces previous primary email/phone/address with this one',
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
- const items = this.getInputData();
166
- const out: INodeExecutionData[] = [];
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
- // === GET MANY ===
210
- if (operation === 'getMany') {
211
- const returnAll = this.getNodeParameter('returnAll', i, false) as boolean;
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
- let where = whereJson ? JSON.parse(whereJson) : [];
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
- if (resource === 'contact') {
218
- const contactType = this.getNodeParameter('contactType', i, '') as string;
219
- if (contactType) where.push(['contact_type', '=', contactType]);
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
- let baseParams: Record<string, unknown>;
275
+ // =========== GET ===========
276
+ if (operation === 'get') {
277
+ const id = this.getNodeParameter('id', i) as number;
278
+ const where = [['id', '=', id]];
223
279
 
224
- if (resource === 'contact') {
225
- baseParams = {
226
- where,
227
- select: ['id', 'display_name', 'first_name', 'last_name', 'contact_type'],
228
- chain: {
229
- emails: ['Email', 'get', { where: [['contact_id', '=', '$id']] }],
230
- phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
231
- addresses: ['Address', 'get', { where: [['contact_id', '=', '$id']] }],
232
- },
233
- };
234
- } else {
235
- baseParams = {
236
- where,
237
- select: ['id', 'name', 'title', 'subject', 'display_name'],
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
- if (returnAll) {
242
- let offset = 0;
243
- const page = 500;
244
- while (true) {
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: page, offset },
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
- // === CREATE y UPDATE ===
269
- const isCreate = operation === 'create';
270
- if (isCreate || operation === 'update') {
271
- const id = !isCreate ? (this.getNodeParameter('id', i) as number) : undefined;
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
- const contactType = this.getNodeParameter('contactType', i, '') as string;
289
- if (resource === 'contact' && contactType) values.contact_type = contactType;
290
-
291
- let contactId = id;
292
- if (isCreate) {
293
- const contactRes = await civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/Contact/create`, { values });
294
- contactId = contactRes?.values?.[0]?.id;
295
- if (!contactId) throw new Error('Failed to create contact.');
296
- } else {
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
- if (resource === 'contact') {
304
- if (isPrimary) {
305
- await civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/Email/delete`, {
306
- where: [['contact_id', '=', contactId], ['is_primary', '=', true]],
307
- });
308
- await civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/Phone/delete`, {
309
- where: [['contact_id', '=', contactId], ['is_primary', '=', true]],
310
- });
311
- await civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/Address/delete`, {
312
- where: [['contact_id', '=', contactId], ['is_primary', '=', true]],
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
- if (Object.keys(emailData).length)
317
- await civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/Email/create`, {
318
- values: { ...emailData, contact_id: contactId, is_primary: isPrimary, 'location_type_id:name': emailLocation },
319
- });
320
- if (Object.keys(phoneData).length)
321
- await civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/Phone/create`, {
322
- values: { ...phoneData, contact_id: contactId, is_primary: isPrimary, 'location_type_id:name': phoneLocation },
323
- });
324
- if (Object.keys(addressData).length)
325
- await civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/Address/create`, {
326
- values: { ...addressData, contact_id: contactId, is_primary: isPrimary, 'location_type_id:name': addressLocation },
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
- const res = await civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, {
331
- where: [['id', '=', contactId]],
332
- select: ['id', 'display_name', 'first_name', 'last_name', 'contact_type'],
333
- ...(resource === 'contact'
334
- ? {
335
- chain: {
336
- emails: ['Email', 'get', { where: [['contact_id', '=', '$id']] }],
337
- phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
338
- addresses: ['Address', 'get', { where: [['contact_id', '=', '$id']] }],
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
- out.push({ json: (res?.values?.[0] ?? {}) as IDataObject });
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
- return [out];
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixiam/n8n-nodes-civicrm",
3
- "version": "0.4.1",
3
+ "version": "0.4.4",
4
4
  "description": "CiviCRM API v4",
5
5
  "license": "MIT",
6
6
  "keywords": [