@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.
- package/dist/nodes/CiviCrm/CiviCrm.node.js +408 -85
- package/nodes/CiviCrm/CiviCrm.node.ts +560 -187
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
|
|
1
2
|
import type {
|
|
2
3
|
IExecuteFunctions,
|
|
3
4
|
ILoadOptionsFunctions,
|
|
@@ -42,6 +43,47 @@ const ENTITY_MAP: Record<Resource, string> = {
|
|
|
42
43
|
|
|
43
44
|
type Operation = 'get' | 'getMany' | 'create' | 'update' | 'delete';
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Caché global de location types
|
|
48
|
+
* Se rellena la primera vez que se usa y dura hasta que se reinicia n8n.
|
|
49
|
+
* key: normalizado (lowercase, sin espacios/ símbolos)
|
|
50
|
+
* value: option_value.name (para usar en location_type_id:name)
|
|
51
|
+
*/
|
|
52
|
+
let locationTypeCache: Record<string, string> | null = null;
|
|
53
|
+
|
|
54
|
+
function normalizeLocationKey(s: string): string {
|
|
55
|
+
return String(s || '')
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9]+/g, '');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function getLocationTypeMap(this: IExecuteFunctions): Promise<Record<string, string>> {
|
|
61
|
+
if (locationTypeCache) return locationTypeCache;
|
|
62
|
+
|
|
63
|
+
const res = await civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/OptionValue/get', {
|
|
64
|
+
where: [['option_group_id:name', '=', 'location_type']],
|
|
65
|
+
select: ['name', 'label'],
|
|
66
|
+
limit: 0,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const values = (res?.values || []) as Array<{ name?: string; label?: string }>;
|
|
70
|
+
const map: Record<string, string> = {};
|
|
71
|
+
|
|
72
|
+
for (const v of values) {
|
|
73
|
+
const name = v.name || '';
|
|
74
|
+
const label = v.label || '';
|
|
75
|
+
|
|
76
|
+
const normName = normalizeLocationKey(name);
|
|
77
|
+
const normLabel = normalizeLocationKey(label);
|
|
78
|
+
|
|
79
|
+
if (normName) map[normName] = name;
|
|
80
|
+
if (normLabel) map[normLabel] = name;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
locationTypeCache = map;
|
|
84
|
+
return map;
|
|
85
|
+
}
|
|
86
|
+
|
|
45
87
|
/**
|
|
46
88
|
* Nodo principal CiviCRM para n8n
|
|
47
89
|
*/
|
|
@@ -52,7 +94,37 @@ export class CiviCrm implements INodeType {
|
|
|
52
94
|
icon: 'file:civicrm.svg',
|
|
53
95
|
group: ['transform'],
|
|
54
96
|
version: 1,
|
|
55
|
-
description:
|
|
97
|
+
description:
|
|
98
|
+
'Interact with CiviCRM API v4 (Civi-Go compatible).\\n\\n' +
|
|
99
|
+
'Email / Phone / Address mapping:\\n' +
|
|
100
|
+
'- You can send simple fields: email, phone, address.city, address.country_id, etc.\\n' +
|
|
101
|
+
'- You can also use dynamic prefixes based on real CiviCRM Location Types.\\n' +
|
|
102
|
+
' Example prefixes (from Location Types): Home, Work, Billing, Mobile, Oficina, etc.\\n' +
|
|
103
|
+
'- Prefix format: <location-prefix>.<entity>[.<subfield>]\\n' +
|
|
104
|
+
' Examples:\\n' +
|
|
105
|
+
' email = test@ixiam.com\\n' +
|
|
106
|
+
' phone = 600123123\\n' +
|
|
107
|
+
' address.city = Barcelona\\n' +
|
|
108
|
+
' work.email = test@company.com (Location Type = Work)\\n' +
|
|
109
|
+
' billing.address.postal_code = 28010 (Location Type = Billing)\\n' +
|
|
110
|
+
' home.phone.phone_type_id = 2 (Location Type = Home)\\n\\n' +
|
|
111
|
+
'Subfields examples:\\n' +
|
|
112
|
+
'- email.is_primary, email.signature_html, email.signature_text\\n' +
|
|
113
|
+
'- phone.phone_type_id, phone.phone_ext\\n' +
|
|
114
|
+
'- address.country_id, address.state_province_id, address.postal_code, address.street_address\\n\\n' +
|
|
115
|
+
'Prefixes and Location Types:\\n' +
|
|
116
|
+
'- The prefix (e.g. work, home, billing, oficina) is matched against CiviCRM Location Types (name/label).\\n' +
|
|
117
|
+
'- If the prefix matches, the node sets location_type_id:name automatically.\\n' +
|
|
118
|
+
'- If no prefix is used (e.g. email, phone, address.city), the node uses the Location Type selectors.\\n\\n' +
|
|
119
|
+
'birth_date:\\n' +
|
|
120
|
+
'- Accepted formats: YYYY-MM-DD, DD/MM/YYYY, DD-MM-YYYY, YYYY/MM/DD, YYYY.MM.DD.\\n' +
|
|
121
|
+
'- The node normalizes and validates the date before sending it to CiviCRM.\\n\\n' +
|
|
122
|
+
'Filters in GET MANY:\\n' +
|
|
123
|
+
'- Use API4 "where" JSON array format, e.g.:\\n' +
|
|
124
|
+
' [ ["contact_type","=","Individual"] ]\\n' +
|
|
125
|
+
' [ ["first_name","LIKE","Jul%"] ]\\n' +
|
|
126
|
+
' [ ["birth_date",">","1990-01-01"] ]\\n' +
|
|
127
|
+
' [ ["gender_id","IN",[1,2]] ]',
|
|
56
128
|
defaults: { name: 'CiviCRM' },
|
|
57
129
|
inputs: ['main'],
|
|
58
130
|
outputs: ['main'],
|
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
//
|
|
134
|
-
if (operation === '
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
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 =
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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}/
|
|
328
|
-
|
|
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
|
-
|
|
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;
|