@ixiam/n8n-nodes-civicrm 0.4.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,22 +6,14 @@ const resources_1 = require("./descriptions/resources");
6
6
  const generic_1 = require("./descriptions/generic");
7
7
  const ENTITY_MAP = {
8
8
  contact: 'Contact',
9
- event: 'Event',
10
- case: 'Case',
11
- contribution: 'Contribution',
12
9
  membership: 'Membership',
13
- participant: 'Participant',
14
10
  group: 'Group',
15
11
  relationship: 'Relationship',
16
- email: 'Email',
17
12
  activity: 'Activity',
18
13
  };
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
- */
14
+ /* ============================================================================
15
+ LOCATION TYPE CACHE
16
+ ============================================================================ */
25
17
  let locationTypeCache = null;
26
18
  function normalizeLocationKey(s) {
27
19
  return String(s || '')
@@ -36,24 +28,24 @@ async function getLocationTypeMap() {
36
28
  select: ['name', 'label'],
37
29
  limit: 0,
38
30
  });
39
- const values = (res?.values || []);
40
31
  const map = {};
32
+ const values = (res?.values || []);
41
33
  for (const v of values) {
42
34
  const name = v.name || '';
43
35
  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;
36
+ const k1 = normalizeLocationKey(name);
37
+ const k2 = normalizeLocationKey(label);
38
+ if (k1)
39
+ map[k1] = name;
40
+ if (k2)
41
+ map[k2] = name;
50
42
  }
51
43
  locationTypeCache = map;
52
44
  return map;
53
45
  }
54
- /**
55
- * Nodo principal CiviCRM para n8n
56
- */
46
+ /* ============================================================================
47
+ NODE DEFINITION
48
+ ============================================================================ */
57
49
  class CiviCrm {
58
50
  constructor() {
59
51
  this.description = {
@@ -62,43 +54,23 @@ class CiviCrm {
62
54
  icon: 'file:civicrm.svg',
63
55
  group: ['transform'],
64
56
  version: 1,
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]] ]',
57
+ description: 'Interact with CiviCRM API v4 (Civi-Go compatible).\n\n' +
58
+ 'Supports Contact, Membership, Group, Relationship and Activity entities.\n' +
59
+ 'Includes dynamic mapping of email, phone, address and location types.\n' +
60
+ 'Includes birth_date validation and JSON filters for GET MANY.\n',
95
61
  defaults: { name: 'CiviCRM' },
96
62
  inputs: ['main'],
97
63
  outputs: ['main'],
98
64
  credentials: [{ name: 'civiCrmApi', required: true }],
99
65
  properties: [
66
+ //
67
+ // RESOURCE + OPERATION
68
+ //
100
69
  resources_1.resourceProp,
101
70
  resources_1.operationProp,
71
+ //
72
+ // CONTACT TYPE
73
+ //
102
74
  {
103
75
  displayName: 'Contact Type',
104
76
  name: 'contactType',
@@ -110,9 +82,10 @@ class CiviCrm {
110
82
  { name: 'Household', value: 'Household' },
111
83
  ],
112
84
  displayOptions: { show: { resource: ['contact'] } },
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.',
115
85
  },
86
+ //
87
+ // LOCATION TYPES
88
+ //
116
89
  {
117
90
  displayName: 'Email Location Type',
118
91
  name: 'emailLocation',
@@ -124,10 +97,6 @@ class CiviCrm {
124
97
  { name: 'Other', value: 'Other' },
125
98
  ],
126
99
  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.',
131
100
  },
132
101
  {
133
102
  displayName: 'Phone Location Type',
@@ -141,10 +110,6 @@ class CiviCrm {
141
110
  { name: 'Other', value: 'Other' },
142
111
  ],
143
112
  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.',
148
113
  },
149
114
  {
150
115
  displayName: 'Address Location Type',
@@ -158,20 +123,20 @@ class CiviCrm {
158
123
  { name: 'Other', value: 'Other' },
159
124
  ],
160
125
  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.',
165
126
  },
127
+ //
128
+ // PRIMARY FLAG
129
+ //
166
130
  {
167
131
  displayName: 'Mark as Primary',
168
132
  name: 'isPrimary',
169
133
  type: 'boolean',
170
134
  default: true,
171
135
  displayOptions: { show: { resource: ['contact'], operation: ['create', 'update'] } },
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.',
174
136
  },
137
+ //
138
+ // ID for GET / UPDATE / DELETE
139
+ //
175
140
  {
176
141
  displayName: 'ID',
177
142
  name: 'id',
@@ -179,25 +144,29 @@ class CiviCrm {
179
144
  default: 0,
180
145
  required: true,
181
146
  displayOptions: { show: { operation: ['get', 'update', 'delete'] } },
182
- description: 'Record ID of the selected entity (Contact, Event, Case, etc.).',
183
147
  },
148
+ //
149
+ // DYNAMIC FIELDS
150
+ //
184
151
  ...generic_1.genericFields,
185
152
  ...generic_1.upsertFields,
186
153
  ],
187
154
  };
155
+ /* ============================================================================
156
+ LOAD OPTIONS (required by n8n verification)
157
+ ============================================================================ */
188
158
  this.methods = {
189
159
  loadOptions: {
190
- async getOptionValues() {
160
+ async loadOptionValues() {
191
161
  const { baseUrl, apiToken } = (await this.getCredentials('civiCrmApi'));
192
- const url = `${baseUrl.replace(/\/$/, '')}/civicrm/ajax/api4/OptionValue/get`;
193
162
  const res = await this.helpers.httpRequest({
194
163
  method: 'POST',
195
- url,
164
+ url: `${baseUrl.replace(/\/$/, '')}/civicrm/ajax/api4/OptionValue/get`,
196
165
  headers: {
197
166
  'X-Civi-Auth': `Bearer ${apiToken}`,
198
167
  'Content-Type': 'application/x-www-form-urlencoded',
199
168
  },
200
- body: { params: JSON.stringify({ limit: 5 }) },
169
+ body: { params: JSON.stringify({ limit: 50, select: ['id', 'label'] }) },
201
170
  json: true,
202
171
  });
203
172
  const values = (res?.values || []);
@@ -209,6 +178,9 @@ class CiviCrm {
209
178
  },
210
179
  };
211
180
  }
181
+ /* ============================================================================
182
+ EXECUTE
183
+ ============================================================================ */
212
184
  async execute() {
213
185
  const items = this.getInputData();
214
186
  const out = [];
@@ -220,17 +192,17 @@ class CiviCrm {
220
192
  const phoneLocationParam = this.getNodeParameter('phoneLocation', i, 'Work');
221
193
  const addressLocationParam = this.getNodeParameter('addressLocation', i, 'Home');
222
194
  const isPrimary = this.getNodeParameter('isPrimary', i, true);
223
- // Valores de location type efectivos (pueden ser sobreescritos por prefijo)
224
195
  let emailLocationName = emailLocationParam;
225
196
  let phoneLocationName = phoneLocationParam;
226
197
  let addressLocationName = addressLocationParam;
227
- // =========== GET ===========
198
+ /* ======================================================
199
+ GET
200
+ ====================================================== */
228
201
  if (operation === 'get') {
229
202
  const id = this.getNodeParameter('id', i);
230
- const where = [['id', '=', id]];
231
- if (resource === 'contact') {
232
- const params = {
233
- where,
203
+ const params = resource === 'contact'
204
+ ? {
205
+ where: [['id', '=', id]],
234
206
  limit: 1,
235
207
  select: [
236
208
  'id',
@@ -247,34 +219,39 @@ class CiviCrm {
247
219
  phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
248
220
  addresses: ['Address', 'get', { where: [['contact_id', '=', '$id']] }],
249
221
  },
250
- };
251
- const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Contact/get', params);
252
- out.push({ json: (res?.values?.[0] ?? {}) });
253
- }
254
- else {
255
- const params = {
256
- where,
222
+ }
223
+ : {
224
+ where: [['id', '=', id]],
257
225
  limit: 1,
258
226
  select: ['id', 'name', 'title', 'subject', 'display_name'],
259
227
  };
260
- const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, params);
261
- out.push({ json: (res?.values?.[0] ?? {}) });
262
- }
228
+ const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, params);
229
+ out.push({ json: res?.values?.[0] ?? {} });
230
+ continue;
263
231
  }
264
- // =========== GET MANY ===========
232
+ /* ======================================================
233
+ GET MANY
234
+ ====================================================== */
265
235
  if (operation === 'getMany') {
266
- const returnAll = this.getNodeParameter('returnAll', i, false);
267
236
  const limit = this.getNodeParameter('limit', i, 100);
237
+ const returnAll = this.getNodeParameter('returnAll', i, false);
268
238
  const whereJson = this.getNodeParameter('whereJson', i, '');
269
- let where = whereJson ? JSON.parse(whereJson) : [];
239
+ let where = [];
240
+ if (whereJson) {
241
+ try {
242
+ where = JSON.parse(whereJson);
243
+ }
244
+ catch {
245
+ throw new Error('Invalid JSON in whereJson');
246
+ }
247
+ }
270
248
  if (resource === 'contact') {
271
249
  const contactType = this.getNodeParameter('contactType', i, '');
272
250
  if (contactType)
273
251
  where.push(['contact_type', '=', contactType]);
274
252
  }
275
- let baseParams;
276
- if (resource === 'contact') {
277
- baseParams = {
253
+ const params = resource === 'contact'
254
+ ? {
278
255
  where,
279
256
  select: [
280
257
  'id',
@@ -291,20 +268,17 @@ class CiviCrm {
291
268
  phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
292
269
  addresses: ['Address', 'get', { where: [['contact_id', '=', '$id']] }],
293
270
  },
294
- };
295
- }
296
- else {
297
- baseParams = {
271
+ }
272
+ : {
298
273
  where,
299
274
  select: ['id', 'name', 'title', 'subject', 'display_name'],
300
275
  };
301
- }
302
276
  if (returnAll) {
303
277
  let offset = 0;
304
278
  const page = 500;
305
279
  while (true) {
306
- const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, { ...baseParams, limit: page, offset });
307
- const vals = (res?.values ?? []);
280
+ const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, { ...params, limit: page, offset });
281
+ const vals = r?.values ?? [];
308
282
  for (const v of vals)
309
283
  out.push({ json: v });
310
284
  if (vals.length < page)
@@ -313,262 +287,254 @@ class CiviCrm {
313
287
  }
314
288
  }
315
289
  else {
316
- const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, { ...baseParams, limit });
317
- const vals = (res?.values ?? []);
290
+ const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, { ...params, limit });
291
+ const vals = r?.values ?? [];
318
292
  for (const v of vals)
319
293
  out.push({ json: v });
320
294
  }
295
+ continue;
321
296
  }
322
- // =========== CREATE / UPDATE ===========
297
+ /* ======================================================
298
+ DELETE
299
+ ====================================================== */
300
+ if (operation === 'delete') {
301
+ const id = this.getNodeParameter('id', i);
302
+ const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/delete`, {
303
+ where: [['id', '=', id]],
304
+ });
305
+ out.push({
306
+ json: {
307
+ success: true,
308
+ message: `${entity} ${id} deleted`,
309
+ deleted_id: id,
310
+ api_response: r,
311
+ },
312
+ });
313
+ continue;
314
+ }
315
+ /* ======================================================
316
+ CREATE / UPDATE
317
+ ====================================================== */
323
318
  const isCreate = operation === 'create';
324
- if (isCreate || operation === 'update') {
325
- const id = !isCreate ? this.getNodeParameter('id', i) : undefined;
326
- const pairs = this.getNodeParameter('fields.field', i, []);
327
- const values = {};
328
- const emailData = {};
329
- const phoneData = {};
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;
319
+ const id = !isCreate ? this.getNodeParameter('id', i) : undefined;
320
+ const pairs = this.getNodeParameter('fields.field', i, []);
321
+ const values = {};
322
+ const emailData = {};
323
+ const phoneData = {};
324
+ const addressData = {};
325
+ function normalizeBirthDate(input) {
326
+ if (!input)
327
+ return input;
328
+ let v = input.trim();
329
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
362
330
  }
363
- // Cargar mapa de location types solo si estamos en contacto
364
- let locationTypeMap = {};
365
- if (resource === 'contact') {
366
- locationTypeMap = await getLocationTypeMap.call(this);
331
+ else if (/^\d{2}\/\d{2}\/\d{4}$/.test(v)) {
332
+ const [d, m, y] = v.split('/');
333
+ v = `${y}-${m}-${d}`;
367
334
  }
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]
375
- for (const p of pairs) {
376
- if (!p.fieldName)
377
- continue;
378
- const key = p.fieldName.trim();
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.')) {
388
- emailData[key.replace(/^email\./, '')] = val;
389
- continue;
390
- }
391
- // Phone simple
392
- if (key === 'phone') {
393
- phoneData.phone = val;
394
- continue;
395
- }
396
- if (key.startsWith('phone.')) {
397
- phoneData[key.replace(/^phone\./, '')] = val;
398
- continue;
399
- }
400
- // Address simple
401
- if (key.startsWith('address.')) {
402
- addressData[key.replace(/^address\./, '')] = 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;
335
+ else if (/^\d{2}-\d{2}-\d{4}$/.test(v)) {
336
+ const [d, m, y] = v.split('-');
337
+ v = `${y}-${m}-${d}`;
465
338
  }
466
- // contact_type
467
- const contactType = this.getNodeParameter('contactType', i, '');
468
- if (resource === 'contact' && contactType) {
469
- values.contact_type = contactType;
339
+ else if (/^\d{4}\/\d{2}\/\d{2}$/.test(v)) {
340
+ v = v.replace(/\//g, '-');
470
341
  }
471
- // --- CREATE / UPDATE CONTACT ---
472
- let contactId = id;
473
- if (isCreate) {
474
- const contactRes = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Contact/create', { values });
475
- contactId = contactRes?.values?.[0]?.id;
476
- if (!contactId)
477
- throw new Error('Failed to create contact.');
342
+ else if (/^\d{4}\.\d{2}\.\d{2}$/.test(v)) {
343
+ v = v.replace(/\./g, '-');
478
344
  }
479
- else {
480
- await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/update`, {
481
- values,
482
- where: [['id', '=', contactId]],
483
- });
345
+ const dt = new Date(v);
346
+ if (isNaN(dt.getTime()))
347
+ throw new Error(`Invalid birth_date: ${input}`);
348
+ return v;
349
+ }
350
+ let locationTypeMap = {};
351
+ if (resource === 'contact') {
352
+ locationTypeMap = await getLocationTypeMap.call(this);
353
+ }
354
+ for (const p of pairs) {
355
+ if (!p.fieldName)
356
+ continue;
357
+ const key = p.fieldName.trim();
358
+ const rawVal = convertValue(p.fieldValue);
359
+ const val = rawVal;
360
+ /* Email simple */
361
+ if (key === 'email') {
362
+ emailData.email = val;
363
+ continue;
484
364
  }
485
- // --- SUBENTITIES (solo para contact) ---
486
- if (resource === 'contact') {
487
- // Si es primary, limpiar anteriores
488
- if (isPrimary) {
489
- await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Email/delete', {
490
- where: [
491
- ['contact_id', '=', contactId],
492
- ['is_primary', '=', true],
493
- ],
494
- });
495
- await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Phone/delete', {
496
- where: [
497
- ['contact_id', '=', contactId],
498
- ['is_primary', '=', true],
499
- ],
500
- });
501
- await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Address/delete', {
502
- where: [
503
- ['contact_id', '=', contactId],
504
- ['is_primary', '=', true],
505
- ],
506
- });
507
- }
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
- },
517
- });
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
- },
528
- });
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
- },
539
- });
365
+ if (key.startsWith('email.')) {
366
+ emailData[key.replace(/^email\./, '')] = val;
367
+ continue;
368
+ }
369
+ /* Phone simple */
370
+ if (key === 'phone') {
371
+ phoneData.phone = val;
372
+ continue;
373
+ }
374
+ if (key.startsWith('phone.')) {
375
+ phoneData[key.replace(/^phone\./, '')] = val;
376
+ continue;
377
+ }
378
+ /* Address */
379
+ if (key.startsWith('address.')) {
380
+ addressData[key.replace(/^address\./, '')] = val;
381
+ continue;
382
+ }
383
+ /* Prefijo dinámico */
384
+ const segments = key.split('.');
385
+ if (segments.length >= 2 && resource === 'contact') {
386
+ const prefixRaw = segments[0];
387
+ const root = segments[1];
388
+ const subfield = segments.slice(2).join('.') || '';
389
+ const normalizedPrefix = normalizeLocationKey(prefixRaw);
390
+ const mappedLocationName = locationTypeMap[normalizedPrefix];
391
+ if (root === 'email' || root === 'phone' || root === 'address') {
392
+ if (mappedLocationName) {
393
+ if (root === 'email')
394
+ emailLocationName = mappedLocationName;
395
+ if (root === 'phone')
396
+ phoneLocationName = mappedLocationName;
397
+ if (root === 'address')
398
+ addressLocationName = mappedLocationName;
399
+ }
400
+ if (root === 'email') {
401
+ if (!subfield)
402
+ emailData.email = val;
403
+ else
404
+ emailData[subfield] = val;
405
+ continue;
406
+ }
407
+ if (root === 'phone') {
408
+ if (!subfield)
409
+ phoneData.phone = val;
410
+ else
411
+ phoneData[subfield] = val;
412
+ continue;
413
+ }
414
+ if (root === 'address') {
415
+ if (subfield)
416
+ addressData[subfield] = val;
417
+ continue;
418
+ }
540
419
  }
541
420
  }
542
- // --- FINAL GET (con gender + birth_date + chain) ---
543
- const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, {
421
+ /* gender */
422
+ if (key === 'gender' || key === 'gender_id') {
423
+ values.gender_id = val;
424
+ continue;
425
+ }
426
+ /* birth_date */
427
+ if (key === 'birth_date' || key === 'birth') {
428
+ values.birth_date = normalizeBirthDate(String(val));
429
+ continue;
430
+ }
431
+ /* default */
432
+ values[key] = val;
433
+ }
434
+ /* Contact type */
435
+ const contactType = this.getNodeParameter('contactType', i, '');
436
+ if (resource === 'contact' && contactType) {
437
+ values.contact_type = contactType;
438
+ }
439
+ /* CREATE/UPDATE contact */
440
+ let contactId = id;
441
+ if (isCreate) {
442
+ const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Contact/create', { values });
443
+ contactId = r?.values?.[0]?.id;
444
+ if (!contactId)
445
+ throw new Error('Failed to create contact.');
446
+ }
447
+ else {
448
+ await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/update`, {
449
+ values,
544
450
  where: [['id', '=', contactId]],
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
- ],
555
- ...(resource === 'contact'
556
- ? {
557
- chain: {
558
- emails: ['Email', 'get', { where: [['contact_id', '=', '$id']] }],
559
- phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
560
- addresses: ['Address', 'get', { where: [['contact_id', '=', '$id']] }],
561
- },
562
- }
563
- : {}),
564
451
  });
565
- out.push({ json: (res?.values?.[0] ?? {}) });
566
452
  }
453
+ /* SUBENTITIES */
454
+ if (resource === 'contact') {
455
+ if (isPrimary) {
456
+ await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Email/delete', {
457
+ where: [
458
+ ['contact_id', '=', contactId],
459
+ ['is_primary', '=', true],
460
+ ],
461
+ });
462
+ await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Phone/delete', {
463
+ where: [
464
+ ['contact_id', '=', contactId],
465
+ ['is_primary', '=', true],
466
+ ],
467
+ });
468
+ await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Address/delete', {
469
+ where: [
470
+ ['contact_id', '=', contactId],
471
+ ['is_primary', '=', true],
472
+ ],
473
+ });
474
+ }
475
+ if (Object.keys(emailData).length) {
476
+ await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Email/create', {
477
+ values: {
478
+ ...emailData,
479
+ contact_id: contactId,
480
+ is_primary: isPrimary,
481
+ 'location_type_id:name': emailLocationName,
482
+ },
483
+ });
484
+ }
485
+ if (Object.keys(phoneData).length) {
486
+ await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Phone/create', {
487
+ values: {
488
+ ...phoneData,
489
+ contact_id: contactId,
490
+ is_primary: isPrimary,
491
+ 'location_type_id:name': phoneLocationName,
492
+ },
493
+ });
494
+ }
495
+ if (Object.keys(addressData).length) {
496
+ await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Address/create', {
497
+ values: {
498
+ ...addressData,
499
+ contact_id: contactId,
500
+ is_primary: isPrimary,
501
+ 'location_type_id:name': addressLocationName,
502
+ },
503
+ });
504
+ }
505
+ }
506
+ /* FINAL GET */
507
+ const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, {
508
+ where: [['id', '=', contactId]],
509
+ select: [
510
+ 'id',
511
+ 'display_name',
512
+ 'first_name',
513
+ 'last_name',
514
+ 'contact_type',
515
+ 'gender_id',
516
+ 'gender_id:name',
517
+ 'birth_date',
518
+ ],
519
+ ...(resource === 'contact'
520
+ ? {
521
+ chain: {
522
+ emails: ['Email', 'get', { where: [['contact_id', '=', '$id']] }],
523
+ phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
524
+ addresses: ['Address', 'get', { where: [['contact_id', '=', '$id']] }],
525
+ },
526
+ }
527
+ : {}),
528
+ });
529
+ out.push({ json: res?.values?.[0] ?? {} });
567
530
  }
568
531
  return [out];
569
532
  }
570
533
  }
571
534
  exports.CiviCrm = CiviCrm;
535
+ /* ============================================================================
536
+ UTILS
537
+ ============================================================================ */
572
538
  function convertValue(val) {
573
539
  const t = String(val ?? '').trim();
574
540
  if (t === '')