@ixiam/n8n-nodes-civicrm 3.0.0 → 3.2.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.
@@ -57,12 +57,9 @@ class CiviCrm {
57
57
  icon: 'file:civicrm.svg',
58
58
  group: ['transform'],
59
59
  version: 1,
60
- description: 'Interact with CiviCRM API v4 (Civi-Go compatible).\n\n' +
61
- 'Supports Contact, Membership, Group, Relationship, Activity entities, and Custom API Call.\n' +
62
- 'Includes dynamic mapping of email, phone, address and location types.\n' +
63
- 'Includes birth_date validation and JSON filters for GET MANY.\n',
60
+ description: 'Interact with CiviCRM API v4 to manage contacts, memberships, groups, and more.',
64
61
  defaults: { name: 'CiviCRM' },
65
- subtitle: '={{{"get":"Get","getMany":"Get Many","create":"Create","update":"Update","delete":"Delete"}[$parameter["operation"]] + ": " + {"contact":"Contact","membership":"Membership","group":"Group","relationship":"Relationship","activity":"Activity","customApi":"Custom API Call"}[$parameter["resource"]]}}',
62
+ subtitle: '={{{"get":"Get","getMany":"Get Many","create":"Create","update":"Update","delete":"Delete","raw":"Raw API Call","getFields":"List Fields","search":"Dynamic Search"}[$parameter["operation"]] + ": " + {"contact":"Contact","membership":"Membership","group":"Group","relationship":"Relationship","activity":"Activity","customApi":"Custom API Call"}[$parameter["resource"]]}}',
66
63
  inputs: [n8n_workflow_1.NodeConnectionTypes.Main],
67
64
  outputs: [n8n_workflow_1.NodeConnectionTypes.Main],
68
65
  // @ts-ignore
@@ -406,6 +403,65 @@ class CiviCrm {
406
403
  },
407
404
  ],
408
405
  },
406
+ // ======================================================================
407
+ // CUSTOM API — LIST FIELDS / DYNAMIC SEARCH
408
+ // ======================================================================
409
+ {
410
+ displayName: 'List Fields (Any Entity)',
411
+ name: 'customApiGetFields',
412
+ action: 'List fields for any CiviCRM entity',
413
+ description: 'Call {Entity}/getFields on any CiviCRM APIv4 entity (Contact, Contribution, Event, Case, custom entities, etc.) and return field metadata (name, type, required, options...) as node output. Run this before a Dynamic Search or Custom API Call for an entity/field you have not verified yet on this installation.',
414
+ displayOptions: { show: { resource: ['customApi'], operation: ['getFields'] } },
415
+ properties: [
416
+ {
417
+ displayName: 'Action Context',
418
+ name: 'getFieldsAction',
419
+ type: 'string',
420
+ default: 'get',
421
+ description: 'CiviCRM action context passed to getFields (e.g. get, create, update). Affects which fields are reported as required/readonly for that context.',
422
+ },
423
+ ],
424
+ },
425
+ {
426
+ displayName: 'Dynamic Search (Any Entity)',
427
+ name: 'customApiSearch',
428
+ action: 'Run a dynamic search on any CiviCRM entity',
429
+ description: 'Run {Entity}/get with a configurable Select and Where for any CiviCRM APIv4 entity, not limited to Contact/Membership/Group/Relationship/Activity. Verify field names first with List Fields (Any Entity).',
430
+ displayOptions: { show: { resource: ['customApi'], operation: ['search'] } },
431
+ properties: [
432
+ {
433
+ displayName: 'Select (JSON)',
434
+ name: 'searchSelectJson',
435
+ type: 'string',
436
+ default: '["id"]',
437
+ placeholder: '["id","display_name","custom_12"]',
438
+ description: 'JSON array of field names to return, verified beforehand via getFields.',
439
+ },
440
+ {
441
+ displayName: 'Where (JSON)',
442
+ name: 'searchWhereJson',
443
+ type: 'string',
444
+ default: '',
445
+ placeholder: '[["city","=","Bilbao"]]',
446
+ description: "JSON array of [field, operator, value] triples, same pattern as Get Many's Where (JSON).",
447
+ },
448
+ {
449
+ displayName: 'Return All',
450
+ name: 'searchReturnAll',
451
+ type: 'boolean',
452
+ default: false,
453
+ description: 'Whether to return all results or only up to a given limit.',
454
+ },
455
+ {
456
+ displayName: 'Limit',
457
+ name: 'searchLimit',
458
+ type: 'number',
459
+ typeOptions: { minValue: 1, maxValue: 1000 },
460
+ default: 100,
461
+ description: 'Max number of results to return. Defaults to 100.',
462
+ },
463
+ ],
464
+ },
409
465
  ],
410
466
  credentials: [{ name: 'civiCrmApi', required: true }],
411
467
  properties: [
@@ -414,6 +470,7 @@ class CiviCrm {
414
470
  //
415
471
  resources_1.resourceProp,
416
472
  resources_1.operationProp,
473
+ resources_1.customApiOperationProp,
417
474
  //
418
475
  // CONTACT TYPE
419
476
  //
@@ -548,7 +605,15 @@ class CiviCrm {
548
605
  default: 'get',
549
606
  required: true,
550
607
  description: 'CiviCRM API4 action, for example get, getFields, create, update, delete, getOne, etc.',
551
- displayOptions: { show: { resource: ['customApi'] } },
608
+ // Also shown for the legacy operation values a customApi node saved
609
+ // before `customApiOperationProp` existed may still carry (see that
610
+ // prop's comments) - execute() treats all of them as the raw path.
611
+ displayOptions: {
612
+ show: {
613
+ resource: ['customApi'],
614
+ operation: ['raw', 'get', 'getMany', 'create', 'update', 'delete'],
615
+ },
616
+ },
552
617
  },
553
618
  {
554
619
  displayName: 'Params (JSON)',
@@ -559,7 +624,88 @@ class CiviCrm {
559
624
  },
560
625
  default: '{\n "limit": 25\n}',
561
626
  description: 'Raw API4 params JSON passed as-is to CiviCRM. It must be a valid JSON object (no trailing commas).',
562
- displayOptions: { show: { resource: ['customApi'] } },
627
+ displayOptions: {
628
+ show: {
629
+ resource: ['customApi'],
630
+ operation: ['raw', 'get', 'getMany', 'create', 'update', 'delete'],
631
+ },
632
+ },
633
+ },
634
+ //
635
+ // CUSTOM API — LIST FIELDS (ANY ENTITY)
636
+ //
637
+ {
638
+ displayName: 'Action Context',
639
+ name: 'getFieldsAction',
640
+ type: 'string',
641
+ default: 'get',
642
+ description: 'CiviCRM action context passed to getFields (e.g. get, create, update). Affects which fields are reported as required/readonly for that context.',
643
+ displayOptions: { show: { resource: ['customApi'], operation: ['getFields'] } },
644
+ },
645
+ //
646
+ // CUSTOM API — DYNAMIC SEARCH (ANY ENTITY)
647
+ //
648
+ {
649
+ displayName: 'Select (JSON)',
650
+ name: 'searchSelectJson',
651
+ type: 'string',
652
+ default: '["id"]',
653
+ placeholder: '["id","display_name","custom_12"]',
654
+ description: 'JSON array of field names to return, verified beforehand via getFields.',
655
+ displayOptions: { show: { resource: ['customApi'], operation: ['search'] } },
656
+ },
657
+ {
658
+ displayName: 'Where (JSON)',
659
+ name: 'searchWhereJson',
660
+ type: 'string',
661
+ default: '',
662
+ placeholder: '[["city","=","Bilbao"]]',
663
+ description: "JSON array of [field, operator, value] triples, same pattern as Get Many's Where (JSON).",
664
+ displayOptions: { show: { resource: ['customApi'], operation: ['search'] } },
665
+ },
666
+ {
667
+ displayName: 'Return All',
668
+ name: 'searchReturnAll',
669
+ type: 'boolean',
670
+ default: false,
671
+ description: 'Whether to return all results or only up to a given limit.',
672
+ displayOptions: { show: { resource: ['customApi'], operation: ['search'] } },
673
+ },
674
+ {
675
+ displayName: 'Limit',
676
+ name: 'searchLimit',
677
+ type: 'number',
678
+ typeOptions: { minValue: 1, maxValue: 1000 },
679
+ default: 100,
680
+ description: 'Max number of results to return. Defaults to 100.',
681
+ displayOptions: {
682
+ show: { resource: ['customApi'], operation: ['search'], searchReturnAll: [false] },
683
+ },
684
+ },
685
+ //
686
+ // RUNTIME BEARER TOKEN (per-execution JWT, e.g. Authx JWT of the real
687
+ // logged-in CiviCRM user - see GenericFunctions.civicrmApiRequest)
688
+ //
689
+ {
690
+ displayName: 'Runtime Bearer Token (Optional)',
691
+ name: 'runtimeBearerToken',
692
+ type: 'string',
693
+ typeOptions: { password: true },
694
+ default: '',
695
+ description: 'A JWT already issued for a specific real user (e.g. a CiviCRM Authx JWT minted by ' +
696
+ 'Drupal for the logged-in contact), usually set by expression such as ' +
697
+ '={{ $json.user_jwt }}. If set, it is used exactly as given as the ' +
698
+ '"Authorization: Bearer" header for this call, bypassing this node\'s ' +
699
+ "credential-based JWT auto-resolve and API key entirely. If the CiviCRM response " +
700
+ 'is empty or an error with this token, it is returned/thrown as-is - the node does ' +
701
+ 'NOT retry with the credential\'s API key, because an empty result is what a correct ' +
702
+ "permission check looks like when this user lacks access, not a failure to compensate " +
703
+ 'for. Leave empty to use the credential (JWT auto-resolve or API key) as before.',
704
+ displayOptions: {
705
+ show: {
706
+ operation: ['get', 'getMany', 'getFields', 'search', 'raw'],
707
+ },
708
+ },
563
709
  },
564
710
  //
565
711
  // DYNAMIC FIELDS
@@ -597,7 +743,7 @@ class CiviCrm {
597
743
  EXECUTE
598
744
  ============================================================================ */
599
745
  async execute() {
600
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
746
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
601
747
  const items = this.getInputData();
602
748
  const out = [];
603
749
  const resource = this.getNodeParameter('resource', 0);
@@ -605,9 +751,96 @@ class CiviCrm {
605
751
  const entity = resource !== 'customApi' ? ENTITY_MAP[resource] : '';
606
752
  for (let i = 0; i < items.length; i++) {
607
753
  try {
608
- // Custom API call: generic passthrough to any API4 entity/action
754
+ // Per-execution JWT for a specific real user (issue #25 - permissions
755
+ // by real user via Authx), read once per item so it can come from an
756
+ // expression like ={{ $json.user_jwt }}. Only threaded into the
757
+ // read-only operations this parameter is exposed for (get/getMany/
758
+ // getFields/search/raw Custom API Call) - see civicrmApiRequest for
759
+ // why an empty result with this token must never fall back to the
760
+ // credential's API key.
761
+ const runtimeBearerToken = this.getNodeParameter('runtimeBearerToken', i, '').trim() || undefined;
762
+ // Custom API resource: raw passthrough, plus first-class List Fields /
763
+ // Dynamic Search operations for any API4 entity (not just the 5 fixed
764
+ // resources above).
609
765
  if (resource === 'customApi') {
610
766
  const customEntity = this.getNodeParameter('customEntity', i);
767
+ // Any value other than 'getFields'/'search' (including the new
768
+ // 'raw' default and every legacy value the shared operationProp
769
+ // used to allow here) resolves to the original raw passthrough.
770
+ const customOperation = this.getNodeParameter('operation', i, 'raw');
771
+ /* --------------------------------------------------------
772
+ LIST FIELDS: {Entity}/getFields
773
+ -------------------------------------------------------- */
774
+ if (customOperation === 'getFields') {
775
+ const actionContext = this.getNodeParameter('getFieldsAction', i, 'get');
776
+ const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${customEntity}/getFields`, { action: actionContext, loadOptions: true }, runtimeBearerToken);
777
+ const vals = ((_a = res === null || res === void 0 ? void 0 : res.values) !== null && _a !== void 0 ? _a : []);
778
+ if (Array.isArray(vals) && vals.length) {
779
+ for (const v of vals) {
780
+ out.push({ json: v, pairedItem: { item: i } });
781
+ }
782
+ }
783
+ else {
784
+ out.push({ json: (_b = res) !== null && _b !== void 0 ? _b : {}, pairedItem: { item: i } });
785
+ }
786
+ continue;
787
+ }
788
+ /* --------------------------------------------------------
789
+ DYNAMIC SEARCH: {Entity}/get with configurable select/where
790
+ -------------------------------------------------------- */
791
+ if (customOperation === 'search') {
792
+ const selectJson = this.getNodeParameter('searchSelectJson', i, '["id"]');
793
+ const whereJson = this.getNodeParameter('searchWhereJson', i, '');
794
+ const returnAll = this.getNodeParameter('searchReturnAll', i, false);
795
+ const limit = this.getNodeParameter('searchLimit', i, 100);
796
+ let select = ['id'];
797
+ if (selectJson) {
798
+ try {
799
+ select = JSON.parse(selectJson);
800
+ }
801
+ catch (error) {
802
+ throw new Error('Invalid JSON in "Select (JSON)"');
803
+ }
804
+ }
805
+ let where = [];
806
+ if (whereJson) {
807
+ try {
808
+ where = JSON.parse(whereJson);
809
+ }
810
+ catch (error) {
811
+ throw new Error('Invalid JSON in "Where (JSON)"');
812
+ }
813
+ }
814
+ if (returnAll) {
815
+ let offset = 0;
816
+ const page = 500;
817
+ let hasMore = true;
818
+ while (hasMore) {
819
+ const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${customEntity}/get`, { select, where, limit: page, offset }, runtimeBearerToken);
820
+ const vals = ((_c = r === null || r === void 0 ? void 0 : r.values) !== null && _c !== void 0 ? _c : []);
821
+ for (const v of vals) {
822
+ out.push({ json: v, pairedItem: { item: i } });
823
+ }
824
+ if (vals.length < page) {
825
+ hasMore = false;
826
+ }
827
+ else {
828
+ offset += page;
829
+ }
830
+ }
831
+ }
832
+ else {
833
+ const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${customEntity}/get`, { select, where, limit }, runtimeBearerToken);
834
+ const vals = ((_d = r === null || r === void 0 ? void 0 : r.values) !== null && _d !== void 0 ? _d : []);
835
+ for (const v of vals) {
836
+ out.push({ json: v, pairedItem: { item: i } });
837
+ }
838
+ }
839
+ continue;
840
+ }
841
+ /* --------------------------------------------------------
842
+ RAW API CALL (original behavior, unchanged)
843
+ -------------------------------------------------------- */
611
844
  const action = this.getNodeParameter('customAction', i);
612
845
  const paramsJson = this.getNodeParameter('customParamsJson', i, '');
613
846
  let params = {};
@@ -619,7 +852,7 @@ class CiviCrm {
619
852
  throw new Error('Invalid JSON in "Params (JSON)"');
620
853
  }
621
854
  }
622
- const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${customEntity}/${action}`, params);
855
+ const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${customEntity}/${action}`, params, runtimeBearerToken);
623
856
  // Return the raw API4 response so advanced users can work with values and metadata
624
857
  out.push({
625
858
  json: res,
@@ -668,9 +901,9 @@ class CiviCrm {
668
901
  limit: 1,
669
902
  select: ['id', 'name', 'title', 'subject', 'display_name'],
670
903
  };
671
- const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, params);
904
+ const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, params, runtimeBearerToken);
672
905
  out.push({
673
- json: (_b = (_a = res === null || res === void 0 ? void 0 : res.values) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : {},
906
+ json: (_f = (_e = res === null || res === void 0 ? void 0 : res.values) === null || _e === void 0 ? void 0 : _e[0]) !== null && _f !== void 0 ? _f : {},
674
907
  pairedItem: { item: i },
675
908
  });
676
909
  continue;
@@ -726,8 +959,8 @@ class CiviCrm {
726
959
  const page = 500;
727
960
  let hasMore = true;
728
961
  while (hasMore) {
729
- const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, { ...params, limit: page, offset });
730
- const vals = (_c = r === null || r === void 0 ? void 0 : r.values) !== null && _c !== void 0 ? _c : [];
962
+ const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, { ...params, limit: page, offset }, runtimeBearerToken);
963
+ const vals = (_g = r === null || r === void 0 ? void 0 : r.values) !== null && _g !== void 0 ? _g : [];
731
964
  for (const v of vals) {
732
965
  out.push({
733
966
  json: v,
@@ -743,8 +976,8 @@ class CiviCrm {
743
976
  }
744
977
  }
745
978
  else {
746
- const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, { ...params, limit });
747
- const vals = (_d = r === null || r === void 0 ? void 0 : r.values) !== null && _d !== void 0 ? _d : [];
979
+ const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, { ...params, limit }, runtimeBearerToken);
980
+ const vals = (_h = r === null || r === void 0 ? void 0 : r.values) !== null && _h !== void 0 ? _h : [];
748
981
  for (const v of vals) {
749
982
  out.push({
750
983
  json: v,
@@ -898,7 +1131,7 @@ class CiviCrm {
898
1131
  let contactId = id;
899
1132
  if (isCreate) {
900
1133
  const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Contact/create', { values });
901
- contactId = (_f = (_e = r === null || r === void 0 ? void 0 : r.values) === null || _e === void 0 ? void 0 : _e[0]) === null || _f === void 0 ? void 0 : _f.id;
1134
+ contactId = (_k = (_j = r === null || r === void 0 ? void 0 : r.values) === null || _j === void 0 ? void 0 : _j[0]) === null || _k === void 0 ? void 0 : _k.id;
902
1135
  if (!contactId)
903
1136
  throw new Error('Failed to create contact.');
904
1137
  }
@@ -954,7 +1187,7 @@ class CiviCrm {
954
1187
  limit: 1,
955
1188
  select: ['id'],
956
1189
  });
957
- const existingEmailId = (_h = (_g = existingEmail === null || existingEmail === void 0 ? void 0 : existingEmail.values) === null || _g === void 0 ? void 0 : _g[0]) === null || _h === void 0 ? void 0 : _h.id;
1190
+ const existingEmailId = (_m = (_l = existingEmail === null || existingEmail === void 0 ? void 0 : existingEmail.values) === null || _l === void 0 ? void 0 : _l[0]) === null || _m === void 0 ? void 0 : _m.id;
958
1191
  if (existingEmailId) {
959
1192
  await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Email/update', {
960
1193
  values: {
@@ -997,7 +1230,7 @@ class CiviCrm {
997
1230
  limit: 1,
998
1231
  select: ['id'],
999
1232
  });
1000
- const existingPhoneId = (_k = (_j = existingPhone === null || existingPhone === void 0 ? void 0 : existingPhone.values) === null || _j === void 0 ? void 0 : _j[0]) === null || _k === void 0 ? void 0 : _k.id;
1233
+ const existingPhoneId = (_p = (_o = existingPhone === null || existingPhone === void 0 ? void 0 : existingPhone.values) === null || _o === void 0 ? void 0 : _o[0]) === null || _p === void 0 ? void 0 : _p.id;
1001
1234
  if (existingPhoneId) {
1002
1235
  await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Phone/update', {
1003
1236
  values: {
@@ -1040,7 +1273,7 @@ class CiviCrm {
1040
1273
  limit: 1,
1041
1274
  select: ['id'],
1042
1275
  });
1043
- const existingAddressId = (_m = (_l = existingAddress === null || existingAddress === void 0 ? void 0 : existingAddress.values) === null || _l === void 0 ? void 0 : _l[0]) === null || _m === void 0 ? void 0 : _m.id;
1276
+ const existingAddressId = (_r = (_q = existingAddress === null || existingAddress === void 0 ? void 0 : existingAddress.values) === null || _q === void 0 ? void 0 : _q[0]) === null || _r === void 0 ? void 0 : _r.id;
1044
1277
  if (existingAddressId) {
1045
1278
  await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Address/update', {
1046
1279
  values: {
@@ -1090,7 +1323,7 @@ class CiviCrm {
1090
1323
  : {}),
1091
1324
  });
1092
1325
  out.push({
1093
- json: (_p = (_o = res === null || res === void 0 ? void 0 : res.values) === null || _o === void 0 ? void 0 : _o[0]) !== null && _p !== void 0 ? _p : {},
1326
+ json: (_t = (_s = res === null || res === void 0 ? void 0 : res.values) === null || _s === void 0 ? void 0 : _s[0]) !== null && _t !== void 0 ? _t : {},
1094
1327
  pairedItem: { item: i },
1095
1328
  });
1096
1329
  }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.operationProp = exports.resourceProp = void 0;
3
+ exports.customApiOperationProp = exports.operationProp = exports.resourceProp = void 0;
4
4
  //
5
5
  // =======================
6
6
  // RESOURCE SELECTOR
@@ -27,6 +27,12 @@ exports.resourceProp = {
27
27
  // OPERATION SELECTOR
28
28
  // =======================
29
29
  //
30
+ // Scoped to the 5 fixed resources only (Custom API Call has its own operation
31
+ // dropdown below, `customApiOperationProp`). n8n resolves which of the two
32
+ // same-named `operation` properties to render based on the current `resource`
33
+ // value, since their `displayOptions.show.resource` lists are mutually
34
+ // exclusive - this keeps the fixed-resource CRUD operations completely
35
+ // unchanged while giving Custom API Call room for its own operation set.
30
36
  exports.operationProp = {
31
37
  displayName: 'Operation',
32
38
  name: 'operation',
@@ -34,6 +40,7 @@ exports.operationProp = {
34
40
  default: 'getMany',
35
41
  noDataExpression: true,
36
42
  description: 'The action to perform on the selected resource.',
43
+ displayOptions: { show: { resource: ['contact', 'membership', 'group', 'relationship', 'activity'] } },
37
44
  options: [
38
45
  { name: 'Create', value: 'create', description: 'Create a new record' },
39
46
  { name: 'Delete', value: 'delete', description: 'Delete a record by ID' },
@@ -42,3 +49,37 @@ exports.operationProp = {
42
49
  { name: 'Update', value: 'update', description: 'Update a record by ID' },
43
50
  ],
44
51
  };
52
+ //
53
+ // =======================
54
+ // CUSTOM API OPERATION SELECTOR
55
+ // =======================
56
+ //
57
+ // Only shown when Resource = "Custom API Call". `raw` preserves the original
58
+ // hand-typed entity/action/params passthrough. `getFields` and `search` are
59
+ // new, structured, discoverable operations for any CiviCRM APIv4 entity (not
60
+ // just the 5 fixed resources) - see CiviCrm.node.ts for how they're executed.
61
+ //
62
+ // The option list intentionally also matches every legacy value the old,
63
+ // shared `operationProp` could have stored for a `customApi` resource node
64
+ // saved before this property existed (get/getMany/create/update/delete), so
65
+ // pre-existing saved workflows keep resolving to the `raw` execution path
66
+ // with their `customAction`/`customParamsJson` fields still visible/editable.
67
+ exports.customApiOperationProp = {
68
+ displayName: 'Operation',
69
+ name: 'operation',
70
+ type: 'options',
71
+ default: 'raw',
72
+ noDataExpression: true,
73
+ description: 'The action to perform via the Custom API Call resource.',
74
+ displayOptions: { show: { resource: ['customApi'] } },
75
+ options: [
76
+ { name: 'Raw API Call', value: 'raw', description: 'Hand-typed entity/action/params passthrough to any CiviCRM APIv4 endpoint (advanced/escape hatch)' },
77
+ { name: 'List Fields', value: 'getFields', description: 'Call {Entity}/getFields and return field metadata for any CiviCRM entity' },
78
+ { name: 'Dynamic Search', value: 'search', description: 'Run {Entity}/get with a configurable Select and Where for any CiviCRM entity' },
79
+ { name: 'Get (Legacy)', value: 'get', description: 'Legacy value, resolves to Raw API Call' },
80
+ { name: 'Get Many (Legacy)', value: 'getMany', description: 'Legacy value, resolves to Raw API Call' },
81
+ { name: 'Create (Legacy)', value: 'create', description: 'Legacy value, resolves to Raw API Call' },
82
+ { name: 'Update (Legacy)', value: 'update', description: 'Legacy value, resolves to Raw API Call' },
83
+ { name: 'Delete (Legacy)', value: 'delete', description: 'Legacy value, resolves to Raw API Call' },
84
+ ],
85
+ };
@@ -55,17 +55,60 @@ function buildCiviAuthHeaders(credentials, baseUrl, jwtToken) {
55
55
  /**
56
56
  * Executes a CiviCRM API v4 call with authentication (JWT or API Key).
57
57
  * Falls back to API Key if JWT is unavailable, fails, or returns empty results.
58
+ *
59
+ * `runtimeBearerToken` (optional) is a per-execution JWT that the caller
60
+ * already holds for a specific real user (e.g. a CiviCRM Authx JWT minted by
61
+ * Drupal for the logged-in contact, passed into the node via the "Runtime
62
+ * Bearer Token" parameter/expression). When provided, it takes absolute
63
+ * priority: it is sent as-is and none of the credential-based logic below
64
+ * (JWT auto-resolve via `getServerIssuedJwt`/`resolveContactId`, or the
65
+ * empty-response/error fallback to the credential's API key) runs at all.
66
+ * See the early-return block right below for why.
58
67
  */
59
- async function civicrmApiRequest(method, path, body) {
60
- var _a, _b, _c;
68
+ async function civicrmApiRequest(method, path, body, runtimeBearerToken) {
69
+ var _a, _b, _c, _d;
61
70
  const credentials = (await this.getCredentials('civiCrmApi'));
62
71
  const baseUrl = credentials.baseUrl.replace(/\/$/, '');
63
72
  const apiToken = credentials.apiToken;
73
+ // Runtime bearer token path: used exactly as given, with no fallback.
74
+ //
75
+ // This exists for per-user permission enforcement (issue #25): the token
76
+ // here already belongs to a specific real CiviCRM contact (not the
77
+ // credential's own contact/API key owner), so an empty or denied response
78
+ // is a *correct* outcome - it means that real user lacks permission for
79
+ // the requested data - not a failure to silently "fix" by retrying with a
80
+ // more privileged identity. That is exactly the behavior the credential-based
81
+ // path below has (empty JWT response -> retry with the plaintext API key),
82
+ // and it is exactly what must NOT happen here, so this path never falls
83
+ // through into that logic.
84
+ if (runtimeBearerToken) {
85
+ const headers = {
86
+ 'Content-Type': 'application/x-www-form-urlencoded',
87
+ Authorization: `Bearer ${runtimeBearerToken}`,
88
+ };
89
+ const options = {
90
+ method,
91
+ url: `${baseUrl}${path}`,
92
+ headers,
93
+ body: {
94
+ params: JSON.stringify((_a = body.params) !== null && _a !== void 0 ? _a : body),
95
+ },
96
+ json: true,
97
+ };
98
+ try {
99
+ // Whatever CiviCRM returns (including an empty `values: []`) is
100
+ // returned to the caller untouched - no retry, no fallback.
101
+ return await this.helpers.httpRequest.call(this, options);
102
+ }
103
+ catch (error) {
104
+ throw new n8n_workflow_1.NodeApiError(this.getNode(), error);
105
+ }
106
+ }
64
107
  let jwtToken;
65
108
  let useJwt = false;
66
109
  // Attempt to obtain JWT if enabled
67
110
  if ((0, JwtAuth_1.isJwtAuthEnabled)(credentials)) {
68
- const ttl = Number((_a = credentials.jwtExpiry) !== null && _a !== void 0 ? _a : 3600);
111
+ const ttl = Number((_b = credentials.jwtExpiry) !== null && _b !== void 0 ? _b : 3600);
69
112
  try {
70
113
  // Auto-resolve contact ID is built-in to getServerIssuedJwt
71
114
  jwtToken = await (0, JwtAuth_1.getServerIssuedJwt)(this, baseUrl, apiToken, 0, ttl);
@@ -99,7 +142,7 @@ async function civicrmApiRequest(method, path, body) {
99
142
  url: `${baseUrl}${path}`,
100
143
  headers,
101
144
  body: {
102
- params: JSON.stringify((_b = body.params) !== null && _b !== void 0 ? _b : body),
145
+ params: JSON.stringify((_c = body.params) !== null && _c !== void 0 ? _c : body),
103
146
  },
104
147
  json: true,
105
148
  };
@@ -138,7 +181,7 @@ async function civicrmApiRequest(method, path, body) {
138
181
  url: `${baseUrl}${path}`,
139
182
  headers: apiKeyHeaders,
140
183
  body: {
141
- params: JSON.stringify((_c = body.params) !== null && _c !== void 0 ? _c : body),
184
+ params: JSON.stringify((_d = body.params) !== null && _d !== void 0 ? _d : body),
142
185
  },
143
186
  json: true,
144
187
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixiam/n8n-nodes-civicrm",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "Full-featured CiviCRM API v4 integration for n8n",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -21,7 +21,8 @@
21
21
  "copy:assets": "mkdir -p dist/src/nodes/CiviCrm dist/credentials && cp src/nodes/CiviCrm/civicrm.svg dist/src/nodes/CiviCrm/ && cp src/nodes/CiviCrm/civicrm.svg dist/credentials/",
22
22
  "lint": "n8n-node lint",
23
23
  "release": "n8n-node release",
24
- "test": "npm run build"
24
+ "test": "npm run build",
25
+ "test:unit": "jest"
25
26
  },
26
27
  "repository": {
27
28
  "type": "git",
@@ -50,11 +51,14 @@
50
51
  },
51
52
  "devDependencies": {
52
53
  "@n8n/node-cli": "^0.23.1",
54
+ "@types/jest": "^30.0.0",
53
55
  "@types/jsonwebtoken": "^9.0.10",
54
56
  "@types/node": "^20.11.30",
55
57
  "eslint": "9.32.0",
58
+ "jest": "^30.5.1",
56
59
  "prettier": "3.6.2",
57
60
  "release-it": "^19.0.6",
61
+ "ts-jest": "^29.4.12",
58
62
  "typescript": "5.9.2"
59
63
  },
60
64
  "dependencies": {