@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.
@@ -1,4 +1,3 @@
1
-
2
1
  import type {
3
2
  IExecuteFunctions,
4
3
  ILoadOptionsFunctions,
@@ -13,42 +12,31 @@ import { civicrmApiRequest } from '../transport/GenericFunctions';
13
12
  import { resourceProp, operationProp } from './descriptions/resources';
14
13
  import { genericFields, upsertFields } from './descriptions/generic';
15
14
 
16
- /**
17
- * Recursos soportados
18
- */
15
+ /* ============================================================================
16
+ RESOURCES
17
+ ============================================================================ */
18
+
19
19
  type Resource =
20
20
  | 'contact'
21
- | 'event'
22
- | 'case'
23
- | 'contribution'
24
21
  | 'membership'
25
- | 'participant'
26
22
  | 'group'
27
23
  | 'relationship'
28
- | 'email'
29
24
  | 'activity';
30
25
 
26
+ type Operation = 'get' | 'getMany' | 'create' | 'update' | 'delete';
27
+
31
28
  const ENTITY_MAP: Record<Resource, string> = {
32
29
  contact: 'Contact',
33
- event: 'Event',
34
- case: 'Case',
35
- contribution: 'Contribution',
36
30
  membership: 'Membership',
37
- participant: 'Participant',
38
31
  group: 'Group',
39
32
  relationship: 'Relationship',
40
- email: 'Email',
41
33
  activity: 'Activity',
42
34
  };
43
35
 
44
- type Operation = 'get' | 'getMany' | 'create' | 'update' | 'delete';
36
+ /* ============================================================================
37
+ LOCATION TYPE CACHE
38
+ ============================================================================ */
45
39
 
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
40
  let locationTypeCache: Record<string, string> | null = null;
53
41
 
54
42
  function normalizeLocationKey(s: string): string {
@@ -66,27 +54,26 @@ async function getLocationTypeMap(this: IExecuteFunctions): Promise<Record<strin
66
54
  limit: 0,
67
55
  });
68
56
 
69
- const values = (res?.values || []) as Array<{ name?: string; label?: string }>;
70
57
  const map: Record<string, string> = {};
58
+ const values = (res?.values || []) as Array<{ name?: string; label?: string }>;
71
59
 
72
60
  for (const v of values) {
73
61
  const name = v.name || '';
74
62
  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;
63
+ const k1 = normalizeLocationKey(name);
64
+ const k2 = normalizeLocationKey(label);
65
+ if (k1) map[k1] = name;
66
+ if (k2) map[k2] = name;
81
67
  }
82
68
 
83
69
  locationTypeCache = map;
84
70
  return map;
85
71
  }
86
72
 
87
- /**
88
- * Nodo principal CiviCRM para n8n
89
- */
73
+ /* ============================================================================
74
+ NODE DEFINITION
75
+ ============================================================================ */
76
+
90
77
  export class CiviCrm implements INodeType {
91
78
  description: INodeTypeDescription = {
92
79
  displayName: 'CiviCRM',
@@ -95,43 +82,25 @@ export class CiviCrm implements INodeType {
95
82
  group: ['transform'],
96
83
  version: 1,
97
84
  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]] ]',
85
+ 'Interact with CiviCRM API v4 (Civi-Go compatible).\n\n' +
86
+ 'Supports Contact, Membership, Group, Relationship and Activity entities.\n' +
87
+ 'Includes dynamic mapping of email, phone, address and location types.\n' +
88
+ 'Includes birth_date validation and JSON filters for GET MANY.\n',
128
89
  defaults: { name: 'CiviCRM' },
129
90
  inputs: ['main'],
130
91
  outputs: ['main'],
131
92
  credentials: [{ name: 'civiCrmApi', required: true }],
93
+
132
94
  properties: [
95
+ //
96
+ // RESOURCE + OPERATION
97
+ //
133
98
  resourceProp,
134
99
  operationProp,
100
+
101
+ //
102
+ // CONTACT TYPE
103
+ //
135
104
  {
136
105
  displayName: 'Contact Type',
137
106
  name: 'contactType',
@@ -143,10 +112,11 @@ export class CiviCrm implements INodeType {
143
112
  { name: 'Household', value: 'Household' },
144
113
  ],
145
114
  displayOptions: { show: { resource: ['contact'] } },
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.',
149
115
  },
116
+
117
+ //
118
+ // LOCATION TYPES
119
+ //
150
120
  {
151
121
  displayName: 'Email Location Type',
152
122
  name: 'emailLocation',
@@ -158,11 +128,6 @@ export class CiviCrm implements INodeType {
158
128
  { name: 'Other', value: 'Other' },
159
129
  ],
160
130
  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.',
166
131
  },
167
132
  {
168
133
  displayName: 'Phone Location Type',
@@ -176,11 +141,6 @@ export class CiviCrm implements INodeType {
176
141
  { name: 'Other', value: 'Other' },
177
142
  ],
178
143
  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.',
184
144
  },
185
145
  {
186
146
  displayName: 'Address Location Type',
@@ -194,22 +154,22 @@ export class CiviCrm implements INodeType {
194
154
  { name: 'Other', value: 'Other' },
195
155
  ],
196
156
  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.',
202
157
  },
158
+
159
+ //
160
+ // PRIMARY FLAG
161
+ //
203
162
  {
204
163
  displayName: 'Mark as Primary',
205
164
  name: 'isPrimary',
206
165
  type: 'boolean',
207
166
  default: true,
208
167
  displayOptions: { show: { resource: ['contact'], operation: ['create', 'update'] } },
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.',
212
168
  },
169
+
170
+ //
171
+ // ID for GET / UPDATE / DELETE
172
+ //
213
173
  {
214
174
  displayName: 'ID',
215
175
  name: 'id',
@@ -217,35 +177,42 @@ export class CiviCrm implements INodeType {
217
177
  default: 0,
218
178
  required: true,
219
179
  displayOptions: { show: { operation: ['get', 'update', 'delete'] } },
220
- description: 'Record ID of the selected entity (Contact, Event, Case, etc.).',
221
180
  },
181
+
182
+ //
183
+ // DYNAMIC FIELDS
184
+ //
222
185
  ...genericFields,
223
186
  ...upsertFields,
224
187
  ],
225
188
  };
226
189
 
190
+ /* ============================================================================
191
+ LOAD OPTIONS (required by n8n verification)
192
+ ============================================================================ */
193
+
227
194
  methods = {
228
195
  loadOptions: {
229
- async getOptionValues(this: ILoadOptionsFunctions) {
196
+ async loadOptionValues(this: ILoadOptionsFunctions) {
230
197
  const { baseUrl, apiToken } = (await this.getCredentials('civiCrmApi')) as {
231
198
  baseUrl: string;
232
199
  apiToken: string;
233
200
  };
234
201
 
235
- const url = `${baseUrl.replace(/\/$/, '')}/civicrm/ajax/api4/OptionValue/get`;
236
202
  const res = await this.helpers.httpRequest({
237
203
  method: 'POST',
238
- url,
204
+ url: `${baseUrl.replace(/\/$/, '')}/civicrm/ajax/api4/OptionValue/get`,
239
205
  headers: {
240
206
  'X-Civi-Auth': `Bearer ${apiToken}`,
241
207
  'Content-Type': 'application/x-www-form-urlencoded',
242
208
  },
243
- body: { params: JSON.stringify({ limit: 5 }) },
209
+ body: { params: JSON.stringify({ limit: 50, select: ['id', 'label'] }) },
244
210
  json: true,
245
211
  });
246
212
 
247
213
  const values = (res?.values || []) as Array<{ id: number; label: string }>;
248
- return values.map((v): INodePropertyOptions => ({
214
+
215
+ return values.map((v) => ({
249
216
  name: v.label,
250
217
  value: v.id,
251
218
  }));
@@ -253,6 +220,10 @@ export class CiviCrm implements INodeType {
253
220
  },
254
221
  };
255
222
 
223
+ /* ============================================================================
224
+ EXECUTE
225
+ ============================================================================ */
226
+
256
227
  async execute(this: IExecuteFunctions) {
257
228
  const items = this.getInputData();
258
229
  const out: INodeExecutionData[] = [];
@@ -267,451 +238,416 @@ export class CiviCrm implements INodeType {
267
238
  const addressLocationParam = this.getNodeParameter('addressLocation', i, 'Home') as string;
268
239
  const isPrimary = this.getNodeParameter('isPrimary', i, true) as boolean;
269
240
 
270
- // Valores de location type efectivos (pueden ser sobreescritos por prefijo)
271
241
  let emailLocationName = emailLocationParam;
272
242
  let phoneLocationName = phoneLocationParam;
273
243
  let addressLocationName = addressLocationParam;
274
244
 
275
- // =========== GET ===========
245
+ /* ======================================================
246
+ GET
247
+ ====================================================== */
276
248
  if (operation === 'get') {
277
249
  const id = this.getNodeParameter('id', i) as number;
278
- const where = [['id', '=', id]];
279
250
 
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
- }
251
+ const params =
252
+ resource === 'contact'
253
+ ? {
254
+ where: [['id', '=', id]],
255
+ limit: 1,
256
+ select: [
257
+ 'id',
258
+ 'display_name',
259
+ 'first_name',
260
+ 'last_name',
261
+ 'contact_type',
262
+ 'gender_id',
263
+ 'gender_id:name',
264
+ 'birth_date',
265
+ ],
266
+ chain: {
267
+ emails: ['Email', 'get', { where: [['contact_id', '=', '$id']] }],
268
+ phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
269
+ addresses: ['Address', 'get', { where: [['contact_id', '=', '$id']] }],
270
+ },
271
+ }
272
+ : {
273
+ where: [['id', '=', id]],
274
+ limit: 1,
275
+ select: ['id', 'name', 'title', 'subject', 'display_name'],
276
+ };
277
+
278
+ const res = await civicrmApiRequest.call(
279
+ this,
280
+ 'POST',
281
+ `/civicrm/ajax/api4/${entity}/get`,
282
+ params,
283
+ );
284
+
285
+ out.push({ json: res?.values?.[0] ?? {} });
286
+ continue;
321
287
  }
322
288
 
323
- // =========== GET MANY ===========
289
+ /* ======================================================
290
+ GET MANY
291
+ ====================================================== */
324
292
  if (operation === 'getMany') {
325
- const returnAll = this.getNodeParameter('returnAll', i, false) as boolean;
326
293
  const limit = this.getNodeParameter('limit', i, 100) as number;
294
+ const returnAll = this.getNodeParameter('returnAll', i, false) as boolean;
327
295
  const whereJson = this.getNodeParameter('whereJson', i, '') as string;
328
296
 
329
- let where = whereJson ? JSON.parse(whereJson) : [];
297
+ let where: any[] = [];
298
+ if (whereJson) {
299
+ try {
300
+ where = JSON.parse(whereJson);
301
+ } catch {
302
+ throw new Error('Invalid JSON in whereJson');
303
+ }
304
+ }
330
305
 
331
306
  if (resource === 'contact') {
332
307
  const contactType = this.getNodeParameter('contactType', i, '') as string;
333
308
  if (contactType) where.push(['contact_type', '=', contactType]);
334
309
  }
335
310
 
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
- }
311
+ const params =
312
+ resource === 'contact'
313
+ ? {
314
+ where,
315
+ select: [
316
+ 'id',
317
+ 'display_name',
318
+ 'first_name',
319
+ 'last_name',
320
+ 'contact_type',
321
+ 'gender_id',
322
+ 'gender_id:name',
323
+ 'birth_date',
324
+ ],
325
+ chain: {
326
+ emails: ['Email', 'get', { where: [['contact_id', '=', '$id']] }],
327
+ phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
328
+ addresses: ['Address', 'get', { where: [['contact_id', '=', '$id']] }],
329
+ },
330
+ }
331
+ : {
332
+ where,
333
+ select: ['id', 'name', 'title', 'subject', 'display_name'],
334
+ };
363
335
 
364
336
  if (returnAll) {
365
337
  let offset = 0;
366
338
  const page = 500;
367
339
 
368
340
  while (true) {
369
- const res = await civicrmApiRequest.call(
341
+ const r = await civicrmApiRequest.call(
370
342
  this,
371
343
  'POST',
372
344
  `/civicrm/ajax/api4/${entity}/get`,
373
- { ...baseParams, limit: page, offset },
345
+ { ...params, limit: page, offset },
374
346
  );
375
347
 
376
- const vals = (res?.values ?? []) as IDataObject[];
348
+ const vals = r?.values ?? [];
377
349
  for (const v of vals) out.push({ json: v });
378
350
 
379
351
  if (vals.length < page) break;
380
352
  offset += page;
381
353
  }
382
354
  } else {
383
- const res = await civicrmApiRequest.call(
355
+ const r = await civicrmApiRequest.call(
384
356
  this,
385
357
  'POST',
386
358
  `/civicrm/ajax/api4/${entity}/get`,
387
- { ...baseParams, limit },
359
+ { ...params, limit },
388
360
  );
389
- const vals = (res?.values ?? []) as IDataObject[];
361
+
362
+ const vals = r?.values ?? [];
390
363
  for (const v of vals) out.push({ json: v });
391
364
  }
365
+
366
+ continue;
392
367
  }
393
368
 
394
- // =========== CREATE / UPDATE ===========
395
- const isCreate = operation === 'create';
396
- if (isCreate || operation === 'update') {
397
- const id = !isCreate ? (this.getNodeParameter('id', i) as number) : undefined;
369
+ /* ======================================================
370
+ DELETE
371
+ ====================================================== */
372
+ if (operation === 'delete') {
373
+ const id = this.getNodeParameter('id', i) as number;
398
374
 
399
- const pairs = this.getNodeParameter('fields.field', i, []) as Array<{
400
- fieldName: string;
401
- fieldValue: string;
402
- }>;
375
+ const r = await civicrmApiRequest.call(
376
+ this,
377
+ 'POST',
378
+ `/civicrm/ajax/api4/${entity}/delete`,
379
+ {
380
+ where: [['id', '=', id]],
381
+ },
382
+ );
403
383
 
404
- const values: Record<string, unknown> = {};
405
- const emailData: Record<string, unknown> = {};
406
- const phoneData: Record<string, unknown> = {};
407
- const addressData: Record<string, unknown> = {};
384
+ out.push({
385
+ json: {
386
+ success: true,
387
+ message: `${entity} ${id} deleted`,
388
+ deleted_id: id,
389
+ api_response: r,
390
+ },
391
+ });
408
392
 
409
- // Normaliza birth_date a YYYY-MM-DD con validación
410
- function normalizeBirthDate(input: string): string {
411
- if (!input) return input;
393
+ continue;
394
+ }
412
395
 
413
- let val = input.trim();
396
+ /* ======================================================
397
+ CREATE / UPDATE
398
+ ====================================================== */
414
399
 
415
- // YYYY-MM-DD
416
- if (/^\d{4}-\d{2}-\d{2}$/.test(val)) return val;
400
+ const isCreate = operation === 'create';
401
+ const id = !isCreate ? (this.getNodeParameter('id', i) as number) : undefined;
402
+
403
+ const pairs = this.getNodeParameter('fields.field', i, []) as Array<{
404
+ fieldName: string;
405
+ fieldValue: string;
406
+ }>;
407
+
408
+ const values: Record<string, unknown> = {};
409
+ const emailData: Record<string, unknown> = {};
410
+ const phoneData: Record<string, unknown> = {};
411
+ const addressData: Record<string, unknown> = {};
412
+
413
+ function normalizeBirthDate(input: string): string {
414
+ if (!input) return input;
415
+ let v = input.trim();
416
+
417
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
418
+ } else if (/^\d{2}\/\d{2}\/\d{4}$/.test(v)) {
419
+ const [d, m, y] = v.split('/');
420
+ v = `${y}-${m}-${d}`;
421
+ } else if (/^\d{2}-\d{2}-\d{4}$/.test(v)) {
422
+ const [d, m, y] = v.split('-');
423
+ v = `${y}-${m}-${d}`;
424
+ } else if (/^\d{4}\/\d{2}\/\d{2}$/.test(v)) {
425
+ v = v.replace(/\//g, '-');
426
+ } else if (/^\d{4}\.\d{2}\.\d{2}$/.test(v)) {
427
+ v = v.replace(/\./g, '-');
428
+ }
417
429
 
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
- }
430
+ const dt = new Date(v);
431
+ if (isNaN(dt.getTime())) throw new Error(`Invalid birth_date: ${input}`);
432
+ return v;
433
+ }
436
434
 
437
- const date = new Date(val);
438
- if (isNaN(date.getTime())) {
439
- throw new Error(`Invalid birth_date format: ${input}`);
440
- }
435
+ let locationTypeMap: Record<string, string> = {};
436
+ if (resource === 'contact') {
437
+ locationTypeMap = await getLocationTypeMap.call(this);
438
+ }
441
439
 
442
- return val;
443
- }
440
+ for (const p of pairs) {
441
+ if (!p.fieldName) continue;
444
442
 
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);
443
+ const key = p.fieldName.trim();
444
+ const rawVal = convertValue(p.fieldValue);
445
+ const val = rawVal;
446
+
447
+ /* Email simple */
448
+ if (key === 'email') {
449
+ emailData.email = val;
450
+ continue;
451
+ }
452
+ if (key.startsWith('email.')) {
453
+ emailData[key.replace(/^email\./, '')] = val;
454
+ continue;
449
455
  }
450
456
 
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
- }
457
+ /* Phone simple */
458
+ if (key === 'phone') {
459
+ phoneData.phone = val;
460
+ continue;
461
+ }
462
+ if (key.startsWith('phone.')) {
463
+ phoneData[key.replace(/^phone\./, '')] = val;
464
+ continue;
465
+ }
476
466
 
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
- }
467
+ /* Address */
468
+ if (key.startsWith('address.')) {
469
+ addressData[key.replace(/^address\./, '')] = val;
470
+ continue;
471
+ }
486
472
 
487
- // Address simple
488
- if (key.startsWith('address.')) {
489
- addressData[key.replace(/^address\./, '')] = val;
490
- continue;
491
- }
473
+ /* Prefijo dinámico */
474
+ const segments = key.split('.');
475
+ if (segments.length >= 2 && resource === 'contact') {
476
+ const prefixRaw = segments[0];
477
+ const root = segments[1];
478
+ const subfield = segments.slice(2).join('.') || '';
479
+
480
+ const normalizedPrefix = normalizeLocationKey(prefixRaw);
481
+ const mappedLocationName = locationTypeMap[normalizedPrefix];
482
+
483
+ if (root === 'email' || root === 'phone' || root === 'address') {
484
+ if (mappedLocationName) {
485
+ if (root === 'email') emailLocationName = mappedLocationName;
486
+ if (root === 'phone') phoneLocationName = mappedLocationName;
487
+ if (root === 'address') addressLocationName = mappedLocationName;
488
+ }
492
489
 
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];
502
-
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
- }
490
+ if (root === 'email') {
491
+ if (!subfield) emailData.email = val;
492
+ else emailData[subfield] = val;
493
+ continue;
538
494
  }
539
- }
540
495
 
541
- // 3) gender
542
- if (key === 'gender' || key === 'gender_id') {
543
- values.gender_id = val;
544
- continue;
545
- }
496
+ if (root === 'phone') {
497
+ if (!subfield) phoneData.phone = val;
498
+ else phoneData[subfield] = val;
499
+ continue;
500
+ }
546
501
 
547
- // 4) birth_date
548
- if (key === 'birth_date' || key === 'birth') {
549
- values.birth_date = normalizeBirthDate(String(val));
550
- continue;
502
+ if (root === 'address') {
503
+ if (subfield) addressData[subfield] = val;
504
+ continue;
505
+ }
551
506
  }
552
-
553
- // 5) default → campo de Contact o de la entidad
554
- values[key] = val;
555
507
  }
556
508
 
557
- // contact_type
558
- const contactType = this.getNodeParameter('contactType', i, '') as string;
559
- if (resource === 'contact' && contactType) {
560
- values.contact_type = contactType;
509
+ /* gender */
510
+ if (key === 'gender' || key === 'gender_id') {
511
+ values.gender_id = val;
512
+ continue;
561
513
  }
562
514
 
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
- );
515
+ /* birth_date */
516
+ if (key === 'birth_date' || key === 'birth') {
517
+ values.birth_date = normalizeBirthDate(String(val));
518
+ continue;
585
519
  }
586
520
 
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
- ],
600
- },
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
- }
625
-
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
- }
521
+ /* default */
522
+ values[key] = val;
523
+ }
659
524
 
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
- }
525
+ /* Contact type */
526
+ const contactType = this.getNodeParameter('contactType', i, '') as string;
527
+ if (resource === 'contact' && contactType) {
528
+ values.contact_type = contactType;
529
+ }
677
530
 
678
- // --- FINAL GET (con gender + birth_date + chain) ---
679
- const res = await civicrmApiRequest.call(
531
+ /* CREATE/UPDATE contact */
532
+ let contactId = id;
533
+ if (isCreate) {
534
+ const r = await civicrmApiRequest.call(
680
535
  this,
681
536
  'POST',
682
- `/civicrm/ajax/api4/${entity}/get`,
537
+ '/civicrm/ajax/api4/Contact/create',
538
+ { values },
539
+ );
540
+ contactId = r?.values?.[0]?.id;
541
+ if (!contactId) throw new Error('Failed to create contact.');
542
+ } else {
543
+ await civicrmApiRequest.call(
544
+ this,
545
+ 'POST',
546
+ `/civicrm/ajax/api4/${entity}/update`,
683
547
  {
548
+ values,
684
549
  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
550
  },
705
551
  );
552
+ }
553
+
554
+ /* SUBENTITIES */
555
+ if (resource === 'contact') {
556
+ if (isPrimary) {
557
+ await civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Email/delete', {
558
+ where: [
559
+ ['contact_id', '=', contactId],
560
+ ['is_primary', '=', true],
561
+ ],
562
+ });
563
+ await civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Phone/delete', {
564
+ where: [
565
+ ['contact_id', '=', contactId],
566
+ ['is_primary', '=', true],
567
+ ],
568
+ });
569
+ await civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Address/delete', {
570
+ where: [
571
+ ['contact_id', '=', contactId],
572
+ ['is_primary', '=', true],
573
+ ],
574
+ });
575
+ }
576
+
577
+ if (Object.keys(emailData).length) {
578
+ await civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Email/create', {
579
+ values: {
580
+ ...emailData,
581
+ contact_id: contactId,
582
+ is_primary: isPrimary,
583
+ 'location_type_id:name': emailLocationName,
584
+ },
585
+ });
586
+ }
706
587
 
707
- out.push({ json: (res?.values?.[0] ?? {}) as IDataObject });
588
+ if (Object.keys(phoneData).length) {
589
+ await civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Phone/create', {
590
+ values: {
591
+ ...phoneData,
592
+ contact_id: contactId,
593
+ is_primary: isPrimary,
594
+ 'location_type_id:name': phoneLocationName,
595
+ },
596
+ });
597
+ }
598
+
599
+ if (Object.keys(addressData).length) {
600
+ await civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Address/create', {
601
+ values: {
602
+ ...addressData,
603
+ contact_id: contactId,
604
+ is_primary: isPrimary,
605
+ 'location_type_id:name': addressLocationName,
606
+ },
607
+ });
608
+ }
708
609
  }
610
+
611
+ /* FINAL GET */
612
+ const res = await civicrmApiRequest.call(
613
+ this,
614
+ 'POST',
615
+ `/civicrm/ajax/api4/${entity}/get`,
616
+ {
617
+ where: [['id', '=', contactId]],
618
+ select: [
619
+ 'id',
620
+ 'display_name',
621
+ 'first_name',
622
+ 'last_name',
623
+ 'contact_type',
624
+ 'gender_id',
625
+ 'gender_id:name',
626
+ 'birth_date',
627
+ ],
628
+ ...(resource === 'contact'
629
+ ? {
630
+ chain: {
631
+ emails: ['Email', 'get', { where: [['contact_id', '=', '$id']] }],
632
+ phones: ['Phone', 'get', { where: [['contact_id', '=', '$id']] }],
633
+ addresses: ['Address', 'get', { where: [['contact_id', '=', '$id']] }],
634
+ },
635
+ }
636
+ : {}),
637
+ },
638
+ );
639
+
640
+ out.push({ json: res?.values?.[0] ?? {} });
709
641
  }
710
642
 
711
643
  return [out];
712
644
  }
713
645
  }
714
646
 
647
+ /* ============================================================================
648
+ UTILS
649
+ ============================================================================ */
650
+
715
651
  function convertValue(val: string): unknown {
716
652
  const t = String(val ?? '').trim();
717
653
  if (t === '') return '';