@ixiam/n8n-nodes-civicrm 0.3.9 → 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.
@@ -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'],
@@ -70,10 +142,73 @@ export class CiviCrm implements INodeType {
70
142
  { name: 'Organization', value: 'Organization' },
71
143
  { name: 'Household', value: 'Household' },
72
144
  ],
73
- displayOptions: {
74
- show: { resource: ['contact'] },
75
- },
76
- description: 'Filter or assign contact type',
145
+ 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
+ },
150
+ {
151
+ displayName: 'Email Location Type',
152
+ name: 'emailLocation',
153
+ type: 'options',
154
+ default: 'Work',
155
+ options: [
156
+ { name: 'Home', value: 'Home' },
157
+ { name: 'Work', value: 'Work' },
158
+ { name: 'Other', value: 'Other' },
159
+ ],
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.',
166
+ },
167
+ {
168
+ displayName: 'Phone Location Type',
169
+ name: 'phoneLocation',
170
+ type: 'options',
171
+ default: 'Work',
172
+ options: [
173
+ { name: 'Home', value: 'Home' },
174
+ { name: 'Work', value: 'Work' },
175
+ { name: 'Mobile', value: 'Mobile' },
176
+ { name: 'Other', value: 'Other' },
177
+ ],
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.',
184
+ },
185
+ {
186
+ displayName: 'Address Location Type',
187
+ name: 'addressLocation',
188
+ type: 'options',
189
+ default: 'Home',
190
+ options: [
191
+ { name: 'Home', value: 'Home' },
192
+ { name: 'Work', value: 'Work' },
193
+ { name: 'Billing', value: 'Billing' },
194
+ { name: 'Other', value: 'Other' },
195
+ ],
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.',
202
+ },
203
+ {
204
+ displayName: 'Mark as Primary',
205
+ name: 'isPrimary',
206
+ type: 'boolean',
207
+ default: true,
208
+ 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.',
77
212
  },
78
213
  {
79
214
  displayName: 'ID',
@@ -82,6 +217,7 @@ export class CiviCrm implements INodeType {
82
217
  default: 0,
83
218
  required: true,
84
219
  displayOptions: { show: { operation: ['get', 'update', 'delete'] } },
220
+ description: 'Record ID of the selected entity (Contact, Event, Case, etc.).',
85
221
  },
86
222
  ...genericFields,
87
223
  ...upsertFields,
@@ -97,7 +233,6 @@ export class CiviCrm implements INodeType {
97
233
  };
98
234
 
99
235
  const url = `${baseUrl.replace(/\/$/, '')}/civicrm/ajax/api4/OptionValue/get`;
100
-
101
236
  const res = await this.helpers.httpRequest({
102
237
  method: 'POST',
103
238
  url,
@@ -105,9 +240,7 @@ export class CiviCrm implements INodeType {
105
240
  'X-Civi-Auth': `Bearer ${apiToken}`,
106
241
  'Content-Type': 'application/x-www-form-urlencoded',
107
242
  },
108
- body: {
109
- params: JSON.stringify({ limit: 5 }),
110
- },
243
+ body: { params: JSON.stringify({ limit: 5 }) },
111
244
  json: true,
112
245
  });
113
246
 
@@ -129,205 +262,449 @@ export class CiviCrm implements INodeType {
129
262
  const entity = ENTITY_MAP[resource];
130
263
 
131
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;
269
+
270
+ // Valores de location type efectivos (pueden ser sobreescritos por prefijo)
271
+ let emailLocationName = emailLocationParam;
272
+ let phoneLocationName = phoneLocationParam;
273
+ let addressLocationName = addressLocationParam;
274
+
275
+ // =========== GET ===========
276
+ if (operation === 'get') {
277
+ const id = this.getNodeParameter('id', i) as number;
278
+ const where = [['id', '=', id]];
279
+
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
+ }
321
+ }
132
322
 
133
- // === GET ===
134
- if (operation === 'get') {
135
- const id = this.getNodeParameter('id', i) as number;
136
- const contactType = this.getNodeParameter('contactType', i, '') as string;
137
-
138
- const where = [['id', '=', id]];
139
- if (resource === 'contact' && contactType) {
140
- where.push(['contact_type', '=', contactType]);
141
- }
142
-
143
- let params: Record<string, unknown>;
144
-
145
- if (resource === 'contact') {
146
- params = {
147
- where,
148
- limit: 1,
149
- select: [
150
- 'id',
151
- 'display_name',
152
- 'first_name',
153
- 'last_name',
154
- 'contact_type',
155
- 'email.email',
156
- 'phone.phone',
157
- 'address.city',
158
- 'address.country_id:label',
159
- 'address.postal_code',
160
- 'address.street_address',
161
- ],
162
- join: [
163
- ['Email AS email', 'LEFT'],
164
- ['Phone AS phone', 'LEFT'],
165
- ['Address AS address', 'LEFT'],
166
- ],
167
- };
168
- } else {
169
- // otras entidades: select básico
170
- params = {
171
- where,
172
- limit: 1,
173
- select: ['id', 'title', 'subject', 'display_name', 'name'],
174
- };
175
- }
176
-
177
- const res = await civicrmApiRequest.call(
178
- this,
179
- 'POST',
180
- `/civicrm/ajax/api4/${entity}/get`,
181
- params,
182
- );
183
-
184
- out.push({ json: (res?.values?.[0] ?? {}) as IDataObject });
185
- }
186
-
187
- // === GET MANY ===
188
- if (operation === 'getMany') {
189
- const returnAll = this.getNodeParameter('returnAll', i, false) as boolean;
190
- const limit = this.getNodeParameter('limit', i, 100) as number;
191
- const whereJson = this.getNodeParameter('whereJson', i, '') as string;
192
- const contactType = this.getNodeParameter('contactType', i, '') as string;
193
-
194
- let where = whereJson ? JSON.parse(whereJson) : [];
195
- if (resource === 'contact' && contactType) {
196
- where.push(['contact_type', '=', contactType]);
197
- }
198
-
199
- let baseParams: Record<string, unknown>;
200
-
201
- if (resource === 'contact') {
202
- baseParams = {
203
- where,
204
- select: [
205
- 'id',
206
- 'display_name',
207
- 'first_name',
208
- 'last_name',
209
- 'contact_type',
210
- 'email.email',
211
- 'phone.phone',
212
- 'address.city',
213
- 'address.country_id:label',
214
- 'address.postal_code',
215
- 'address.street_address',
216
- ],
217
- join: [
218
- ['Email AS email', 'LEFT'],
219
- ['Phone AS phone', 'LEFT'],
220
- ['Address AS address', 'LEFT'],
221
- ],
222
- };
223
- } else {
224
- baseParams = { where, select: ['id', 'title', 'subject', 'display_name', 'name'] };
225
- }
226
-
227
- if (returnAll) {
228
- let offset = 0;
229
- const page = 500;
230
- while (true) {
231
- const res = await civicrmApiRequest.call(
232
- this,
233
- 'POST',
234
- `/civicrm/ajax/api4/${entity}/get`,
235
- { ...baseParams, limit: page, offset },
236
- );
237
- const vals = (res?.values ?? []) as IDataObject[];
238
- for (const v of vals) out.push({ json: v });
239
- if (vals.length < page) break;
240
- offset += page;
241
- }
242
- } else {
243
- const res = await civicrmApiRequest.call(
244
- this,
245
- 'POST',
246
- `/civicrm/ajax/api4/${entity}/get`,
247
- { ...baseParams, limit },
248
- );
249
- const vals = (res?.values ?? []) as IDataObject[];
250
- for (const v of vals) out.push({ json: v });
251
- }
252
- }
253
-
254
- // === CREATE ===
255
- if (operation === 'create') {
256
- const pairs = this.getNodeParameter('fields.field', i, []) as Array<{
257
- fieldName: string;
258
- fieldValue: string;
259
- }>;
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;
260
328
 
261
- const values = Object.fromEntries(
262
- pairs
263
- .filter((p) => p.fieldName)
264
- .map((p) => [p.fieldName, convertValue(p.fieldValue)]),
265
- );
329
+ let where = whereJson ? JSON.parse(whereJson) : [];
266
330
 
267
- const contactType = this.getNodeParameter('contactType', i, '') as string;
268
- if (resource === 'contact' && contactType) {
269
- values.contact_type = contactType;
331
+ if (resource === 'contact') {
332
+ const contactType = this.getNodeParameter('contactType', i, '') as string;
333
+ if (contactType) where.push(['contact_type', '=', contactType]);
270
334
  }
271
335
 
272
- const params = { values };
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
+ }
273
363
 
274
- const res = await civicrmApiRequest.call(
275
- this,
276
- 'POST',
277
- `/civicrm/ajax/api4/${entity}/create`,
278
- params,
279
- );
280
- out.push({ json: res as IDataObject });
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 {
383
+ const res = await civicrmApiRequest.call(
384
+ this,
385
+ 'POST',
386
+ `/civicrm/ajax/api4/${entity}/get`,
387
+ { ...baseParams, limit },
388
+ );
389
+ const vals = (res?.values ?? []) as IDataObject[];
390
+ for (const v of vals) out.push({ json: v });
391
+ }
281
392
  }
282
393
 
283
- // === UPDATE ===
284
- if (operation === 'update') {
285
- const id = this.getNodeParameter('id', i) as number;
394
+ // =========== CREATE / UPDATE ===========
395
+ const isCreate = operation === 'create';
396
+ if (isCreate || operation === 'update') {
397
+ const id = !isCreate ? (this.getNodeParameter('id', i) as number) : undefined;
398
+
286
399
  const pairs = this.getNodeParameter('fields.field', i, []) as Array<{
287
400
  fieldName: string;
288
401
  fieldValue: string;
289
402
  }>;
290
403
 
291
- const values = Object.fromEntries(
292
- pairs
293
- .filter((p) => p.fieldName)
294
- .map((p) => [p.fieldName, convertValue(p.fieldValue)]),
295
- );
404
+ const values: Record<string, unknown> = {};
405
+ const emailData: Record<string, unknown> = {};
406
+ const phoneData: Record<string, unknown> = {};
407
+ const addressData: Record<string, unknown> = {};
408
+
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;
443
+ }
296
444
 
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];
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
+ }
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
297
558
  const contactType = this.getNodeParameter('contactType', i, '') as string;
298
559
  if (resource === 'contact' && contactType) {
299
560
  values.contact_type = contactType;
300
561
  }
301
562
 
302
- const params = {
303
- values,
304
- where: [['id', '=', id]],
305
- };
306
-
307
- const res = await civicrmApiRequest.call(
308
- this,
309
- 'POST',
310
- `/civicrm/ajax/api4/${entity}/update`,
311
- params,
312
- );
313
- out.push({ json: res as IDataObject });
314
- }
315
-
316
- // === DELETE ===
317
- if (operation === 'delete') {
318
- const id = this.getNodeParameter('id', i) as number;
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
+ }
319
586
 
320
- const params = {
321
- where: [['id', '=', id]],
322
- };
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
+ }
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
+ }
323
677
 
678
+ // --- FINAL GET (con gender + birth_date + chain) ---
324
679
  const res = await civicrmApiRequest.call(
325
680
  this,
326
681
  'POST',
327
- `/civicrm/ajax/api4/${entity}/delete`,
328
- params,
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
+ },
329
705
  );
330
- out.push({ json: res as IDataObject });
706
+
707
+ out.push({ json: (res?.values?.[0] ?? {}) as IDataObject });
331
708
  }
332
709
  }
333
710
 
@@ -335,9 +712,6 @@ if (operation === 'getMany') {
335
712
  }
336
713
  }
337
714
 
338
- /**
339
- * Convierte string a número, boolean, objeto o deja string
340
- */
341
715
  function convertValue(val: string): unknown {
342
716
  const t = String(val ?? '').trim();
343
717
  if (t === '') return '';
@@ -351,5 +725,4 @@ function convertValue(val: string): unknown {
351
725
  return val;
352
726
  }
353
727
 
354
- // Requerido por n8n >=1.110
355
728
  export default CiviCrm;