mcdev 4.3.4 → 5.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.
Files changed (227) hide show
  1. package/.coverage-comment-template.md +20 -0
  2. package/.coverage-comment-template.svelte +178 -0
  3. package/.eslintrc.json +2 -0
  4. package/.github/ISSUE_TEMPLATE/bug.yml +1 -0
  5. package/.github/workflows/code-test.yml +36 -0
  6. package/.github/workflows/coverage-base-update.yml +57 -0
  7. package/.github/workflows/coverage-develop-branch.yml +41 -0
  8. package/.github/workflows/coverage-main-branch.yml +41 -0
  9. package/.github/workflows/coverage.yml +77 -0
  10. package/.husky/post-checkout +1 -1
  11. package/.prettierrc +1 -1
  12. package/.vscode/extensions.json +0 -4
  13. package/boilerplate/config.json +1 -1
  14. package/boilerplate/files/.prettierrc +1 -1
  15. package/boilerplate/files/.vscode/extensions.json +1 -1
  16. package/boilerplate/forcedUpdates.json +4 -0
  17. package/docs/dist/documentation.md +1196 -430
  18. package/lib/Builder.js +6 -1
  19. package/lib/Deployer.js +30 -5
  20. package/lib/MetadataTypeDefinitions.js +8 -6
  21. package/lib/MetadataTypeInfo.js +8 -6
  22. package/lib/cli.js +54 -42
  23. package/lib/index.js +82 -8
  24. package/lib/metadataTypes/Asset.js +73 -1
  25. package/lib/metadataTypes/AttributeGroup.js +0 -1
  26. package/lib/metadataTypes/Automation.js +48 -5
  27. package/lib/metadataTypes/Campaign.js +20 -7
  28. package/lib/metadataTypes/ContentArea.js +1 -1
  29. package/lib/metadataTypes/DataExtension.js +221 -184
  30. package/lib/metadataTypes/DataExtensionField.js +12 -19
  31. package/lib/metadataTypes/DataExtensionTemplate.js +1 -1
  32. package/lib/metadataTypes/DataExtract.js +1 -1
  33. package/lib/metadataTypes/DataExtractType.js +1 -1
  34. package/lib/metadataTypes/Email.js +1 -1
  35. package/lib/metadataTypes/{EmailSendDefinition.js → EmailSend.js} +5 -5
  36. package/lib/metadataTypes/{EventDefinition.js → Event.js} +17 -35
  37. package/lib/metadataTypes/{FtpLocation.js → FileLocation.js} +2 -2
  38. package/lib/metadataTypes/FileTransfer.js +8 -7
  39. package/lib/metadataTypes/Filter.js +1 -1
  40. package/lib/metadataTypes/Folder.js +8 -3
  41. package/lib/metadataTypes/ImportFile.js +6 -6
  42. package/lib/metadataTypes/{Interaction.js → Journey.js} +311 -147
  43. package/lib/metadataTypes/List.js +2 -2
  44. package/lib/metadataTypes/MetadataType.js +318 -90
  45. package/lib/metadataTypes/MobileCode.js +0 -1
  46. package/lib/metadataTypes/MobileKeyword.js +336 -40
  47. package/lib/metadataTypes/MobileMessage.js +473 -0
  48. package/lib/metadataTypes/Query.js +114 -32
  49. package/lib/metadataTypes/Role.js +60 -21
  50. package/lib/metadataTypes/Script.js +2 -3
  51. package/lib/metadataTypes/SendClassification.js +40 -0
  52. package/lib/metadataTypes/SetDefinition.js +1 -7
  53. package/lib/metadataTypes/TransactionalEmail.js +2 -3
  54. package/lib/metadataTypes/TransactionalMessage.js +1 -2
  55. package/lib/metadataTypes/TransactionalSMS.js +8 -15
  56. package/lib/metadataTypes/{TriggeredSendDefinition.js → TriggeredSend.js} +35 -27
  57. package/lib/metadataTypes/User.js +1177 -0
  58. package/lib/metadataTypes/definitions/Asset.definition.js +1 -0
  59. package/lib/metadataTypes/definitions/AttributeGroup.definition.js +1 -0
  60. package/lib/metadataTypes/definitions/Automation.definition.js +3 -2
  61. package/lib/metadataTypes/definitions/Campaign.definition.js +79 -4
  62. package/lib/metadataTypes/definitions/ContentArea.definition.js +1 -0
  63. package/lib/metadataTypes/definitions/DataExtension.definition.js +2 -1
  64. package/lib/metadataTypes/definitions/DataExtensionField.definition.js +1 -0
  65. package/lib/metadataTypes/definitions/DataExtensionTemplate.definition.js +1 -0
  66. package/lib/metadataTypes/definitions/DataExtract.definition.js +1 -0
  67. package/lib/metadataTypes/definitions/DataExtractType.definition.js +1 -0
  68. package/lib/metadataTypes/definitions/Discovery.definition.js +1 -0
  69. package/lib/metadataTypes/definitions/Email.definition.js +1 -0
  70. package/lib/metadataTypes/definitions/{EmailSendDefinition.definition.js → EmailSend.definition.js} +4 -2
  71. package/lib/metadataTypes/definitions/{EventDefinition.definition.js → Event.definition.js} +2 -1
  72. package/lib/metadataTypes/definitions/{FtpLocation.definition.js → FileLocation.definition.js} +4 -3
  73. package/lib/metadataTypes/definitions/FileTransfer.definition.js +3 -2
  74. package/lib/metadataTypes/definitions/Filter.definition.js +1 -0
  75. package/lib/metadataTypes/definitions/Folder.definition.js +2 -0
  76. package/lib/metadataTypes/definitions/ImportFile.definition.js +4 -3
  77. package/lib/metadataTypes/definitions/{Interaction.definition.js → Journey.definition.js} +11 -2
  78. package/lib/metadataTypes/definitions/List.definition.js +1 -0
  79. package/lib/metadataTypes/definitions/MobileCode.definition.js +3 -1
  80. package/lib/metadataTypes/definitions/MobileKeyword.definition.js +27 -17
  81. package/lib/metadataTypes/definitions/MobileMessage.definition.js +743 -0
  82. package/lib/metadataTypes/definitions/Query.definition.js +3 -2
  83. package/lib/metadataTypes/definitions/Role.definition.js +5 -0
  84. package/lib/metadataTypes/definitions/Script.definition.js +1 -0
  85. package/lib/metadataTypes/definitions/SendClassification.definition.js +114 -0
  86. package/lib/metadataTypes/definitions/SetDefinition.definition.js +1 -0
  87. package/lib/metadataTypes/definitions/TransactionalEmail.definition.js +2 -1
  88. package/lib/metadataTypes/definitions/TransactionalPush.definition.js +1 -0
  89. package/lib/metadataTypes/definitions/TransactionalSMS.definition.js +1 -0
  90. package/lib/metadataTypes/definitions/{TriggeredSendDefinition.definition.js → TriggeredSend.definition.js} +5 -3
  91. package/lib/metadataTypes/definitions/User.definition.js +365 -0
  92. package/lib/retrieveChangelog.js +1 -2
  93. package/lib/util/auth.js +29 -9
  94. package/lib/util/businessUnit.js +3 -3
  95. package/lib/util/cli.js +55 -7
  96. package/lib/util/devops.js +6 -4
  97. package/lib/util/file.js +55 -13
  98. package/lib/util/init.config.js +1 -2
  99. package/lib/util/init.npm.js +3 -3
  100. package/lib/util/util.js +23 -14
  101. package/package.json +16 -15
  102. package/test/general.test.js +62 -0
  103. package/test/mockRoot/.mcdevrc.json +7 -5
  104. package/test/mockRoot/deploy/testInstance/_ParentBU_/user/testBlocked_user.user-meta.json +23 -0
  105. package/test/mockRoot/deploy/testInstance/_ParentBU_/user/testExisting_user.user-meta.json +31 -0
  106. package/test/mockRoot/deploy/testInstance/_ParentBU_/user/testNew_user.user-meta.json +27 -0
  107. package/test/mockRoot/deploy/testInstance/testBU/dataExtension/{childBU_dataextension_test.dataExtension-meta.json → testExisting_dataExtension.dataExtension-meta.json} +2 -2
  108. package/test/mockRoot/deploy/testInstance/testBU/dataExtension/{testDataExtension.dataExtension-meta.json → testNew_dataExtension.dataExtension-meta.json} +2 -2
  109. package/test/mockRoot/deploy/testInstance/testBU/journey/testExisting_interaction.interaction-meta.json +576 -0
  110. package/test/mockRoot/deploy/testInstance/testBU/mobileKeyword/testNew_keyword.mobileKeyword-meta.amp +2 -0
  111. package/test/mockRoot/deploy/testInstance/testBU/mobileKeyword/testNew_keyword.mobileKeyword-meta.json +10 -0
  112. package/test/mockRoot/deploy/testInstance/testBU/mobileKeyword/testNew_keyword_blocked.mobileKeyword-meta.amp +2 -0
  113. package/test/mockRoot/deploy/testInstance/testBU/mobileKeyword/testNew_keyword_blocked.mobileKeyword-meta.json +10 -0
  114. package/test/mockRoot/deploy/testInstance/testBU/mobileMessage/NTIzOjc4OjA.mobileMessage-meta.amp +1 -0
  115. package/test/mockRoot/deploy/testInstance/testBU/mobileMessage/NTIzOjc4OjA.mobileMessage-meta.json +61 -0
  116. package/test/mockRoot/deploy/testInstance/testBU/mobileMessage/new.mobileMessage-meta.amp +1 -0
  117. package/test/mockRoot/deploy/testInstance/testBU/mobileMessage/new.mobileMessage-meta.json +60 -0
  118. package/test/mockRoot/deploy/testInstance/testBU/query/testExistingQuery.query-meta.json +1 -1
  119. package/test/mockRoot/deploy/testInstance/testBU/query/testNewQuery.query-meta.json +1 -1
  120. package/test/mockRoot/deploy/testInstance/testBU/query/testNewQuery.query-meta.sql +1 -1
  121. package/test/mockRoot/deploy/testInstance/testBU/transactionalEmail/testExisting_temail.transactionalEmail-meta.json +1 -1
  122. package/test/mockRoot/deploy/testInstance/testBU/transactionalEmail/testNew_temail.transactionalEmail-meta.json +1 -1
  123. package/test/resourceFactory.js +13 -0
  124. package/test/resources/1111111/accountUser/configure-response.xml +70 -0
  125. package/test/resources/1111111/accountUser/create-response.xml +97 -0
  126. package/test/resources/1111111/accountUser/retrieve-response.xml +156 -0
  127. package/test/resources/1111111/accountUser/update-response.xml +111 -0
  128. package/test/resources/1111111/accountUserAccount/retrieve-response.xml +77 -0
  129. package/test/resources/1111111/platform/v1/setup/quickflow/data/get-response.json +455 -0
  130. package/test/resources/1111111/role/retrieve-response.xml +76 -0
  131. package/test/resources/1111111/user/build-expected.json +16 -0
  132. package/test/resources/1111111/user/create-expected.json +21 -0
  133. package/test/resources/1111111/user/retrieve-expected.json +24 -0
  134. package/test/resources/1111111/user/template-expected.json +16 -0
  135. package/test/resources/1111111/user/update-expected.json +21 -0
  136. package/test/resources/9999999/automation/v1/queries/549f0568-607c-4940-afef-437965094dat/delete-response.json +1 -0
  137. package/test/resources/9999999/automation/v1/queries/549f0568-607c-4940-afef-437965094dat/get-response.json +17 -0
  138. package/test/resources/9999999/automation/v1/queries/549f0568-607c-4940-afef-437965094dat/patch-response.json +3 -3
  139. package/test/resources/9999999/automation/v1/queries/get-response.json +21 -4
  140. package/test/resources/9999999/automation/v1/queries/post-response.json +4 -4
  141. package/test/resources/9999999/data/v1/customobjectdata/key/{childBU_dataextension_test → testExisting_dataExtension}/rowset/get-response.json +1 -1
  142. package/test/resources/9999999/dataExtension/build-expected.json +3 -3
  143. package/test/resources/9999999/dataExtension/create-expected.json +2 -2
  144. package/test/resources/9999999/dataExtension/create-response.xml +8 -3
  145. package/test/resources/9999999/dataExtension/retrieve-expected.json +3 -3
  146. package/test/resources/9999999/dataExtension/retrieve-response.xml +9 -4
  147. package/test/resources/9999999/dataExtension/template-expected.json +3 -3
  148. package/test/resources/9999999/dataExtension/update-expected.json +3 -3
  149. package/test/resources/9999999/dataExtension/update-response.xml +9 -4
  150. package/test/resources/9999999/dataExtensionField/retrieve-response.xml +14 -9
  151. package/test/resources/9999999/interaction/v1/interactions/get-response.json +312 -0
  152. package/test/resources/9999999/interaction/v1/interactions/key_testExisting_interaction/get-response.json +312 -0
  153. package/test/resources/9999999/interaction/v1/interactions/key_testExisting_interaction/put-response.json +592 -0
  154. package/test/resources/9999999/journey/build-expected.json +572 -0
  155. package/test/resources/9999999/journey/get-expected.json +576 -0
  156. package/test/resources/9999999/journey/put-expected.json +576 -0
  157. package/test/resources/9999999/journey/template-expected.json +572 -0
  158. package/test/resources/9999999/legacy/v1/beta/mobile/keyword/NXV4ZFMwTEFwRVczd3RaLUF5X3p5dzo4Njow/get-response.json +42 -0
  159. package/test/resources/9999999/legacy/v1/beta/mobile/keyword/cTVJaG5oSDJPVUNHcUh6Z3pQT2tVdzo4Njow/delete-response.json +0 -0
  160. package/test/resources/9999999/legacy/v1/beta/mobile/keyword/get-response.json +1 -0
  161. package/test/resources/9999999/legacy/v1/beta/mobile/keyword/post-response.json +3 -0
  162. package/test/resources/9999999/legacy/v1/beta/mobile/message/NTIzOjc4OjA/delete-response.json +0 -0
  163. package/test/resources/9999999/legacy/v1/beta/mobile/message/NTIzOjc4OjA/get-response.json +106 -0
  164. package/test/resources/9999999/legacy/v1/beta/mobile/message/NTIzOjc4OjA/post-response.json +0 -0
  165. package/test/resources/9999999/legacy/v1/beta/mobile/message/NTQ3Ojc4OjA/get-response.json +127 -0
  166. package/test/resources/9999999/legacy/v1/beta/mobile/message/get-response.json +129 -0
  167. package/test/resources/9999999/legacy/v1/beta/mobile/message/post-response.json +3 -0
  168. package/test/resources/9999999/legacy/v1/beta2/data/campaign/get-response.json +29 -0
  169. package/test/resources/9999999/messaging/v1/email/definitions/post-response.json +1 -1
  170. package/test/resources/9999999/messaging/v1/email/definitions/testExisting_temail/get-response.json +1 -1
  171. package/test/resources/9999999/messaging/v1/email/definitions/testExisting_temail/patch-response.json +1 -1
  172. package/test/resources/9999999/mobileKeyword/build-expected.amp +2 -0
  173. package/test/resources/9999999/mobileKeyword/build-expected.json +9 -0
  174. package/test/resources/9999999/mobileKeyword/get-expected.amp +2 -0
  175. package/test/resources/9999999/mobileKeyword/get-expected.json +15 -0
  176. package/test/resources/9999999/mobileKeyword/post-create-expected.amp +2 -0
  177. package/test/resources/9999999/mobileKeyword/post-create-expected.json +17 -0
  178. package/test/resources/9999999/mobileKeyword/template-expected.amp +2 -0
  179. package/test/resources/9999999/mobileKeyword/template-expected.json +9 -0
  180. package/test/resources/9999999/mobileMessage/build-expected.amp +1 -0
  181. package/test/resources/9999999/mobileMessage/build-expected.json +60 -0
  182. package/test/resources/9999999/mobileMessage/get-expected.amp +1 -0
  183. package/test/resources/9999999/mobileMessage/get-expected.json +61 -0
  184. package/test/resources/9999999/mobileMessage/post-create-expected.amp +1 -0
  185. package/test/resources/9999999/mobileMessage/post-create-expected.json +63 -0
  186. package/test/resources/9999999/mobileMessage/post-update-expected.amp +1 -0
  187. package/test/resources/9999999/mobileMessage/post-update-expected.json +61 -0
  188. package/test/resources/9999999/mobileMessage/template-expected.amp +1 -0
  189. package/test/resources/9999999/mobileMessage/template-expected.json +60 -0
  190. package/test/resources/9999999/query/build-expected.json +1 -1
  191. package/test/resources/9999999/query/get-expected.json +1 -1
  192. package/test/resources/9999999/query/get2-expected.json +11 -0
  193. package/test/resources/9999999/query/patch-expected.json +1 -1
  194. package/test/resources/9999999/query/post-expected.json +1 -1
  195. package/test/resources/9999999/query/template-expected.json +1 -1
  196. package/test/resources/9999999/queryDefinition/retrieve-response.xml +30 -0
  197. package/test/resources/9999999/transactionalEmail/build-expected.json +5 -5
  198. package/test/resources/9999999/transactionalEmail/get-expected.json +1 -1
  199. package/test/resources/9999999/transactionalEmail/patch-expected.json +1 -1
  200. package/test/resources/9999999/transactionalEmail/post-expected.json +1 -1
  201. package/test/resources/9999999/transactionalEmail/template-expected.json +5 -5
  202. package/test/resources/9999999/transactionalPush/build-expected.json +2 -2
  203. package/test/resources/9999999/transactionalPush/template-expected.json +2 -2
  204. package/test/resources/9999999/transactionalSMS/build-expected.json +3 -3
  205. package/test/resources/9999999/transactionalSMS/template-expected.json +3 -3
  206. package/test/{dataExtension.test.js → type.dataExtension.test.js} +78 -21
  207. package/test/{interaction.test.js → type.journey.test.js} +64 -30
  208. package/test/type.mobileKeyword.test.js +250 -0
  209. package/test/type.mobileMessage.test.js +205 -0
  210. package/test/{query.test.js → type.query.test.js} +102 -5
  211. package/test/{transactionalEmail.test.js → type.transactionalEmail.test.js} +40 -2
  212. package/test/{transactionalPush.test.js → type.transactionalPush.test.js} +41 -2
  213. package/test/{transactionalSMS.test.js → type.transactionalSMS.test.js} +73 -3
  214. package/test/type.user.test.js +160 -0
  215. package/test/utils.js +17 -5
  216. package/types/mcdev.d.js +48 -15
  217. package/.github/workflows/code-analysis.yml +0 -57
  218. package/lib/metadataTypes/AccountUser.js +0 -426
  219. package/lib/metadataTypes/definitions/AccountUser.definition.js +0 -227
  220. package/test/mockRoot/deploy/testInstance/testBU/interaction/testExisting_interaction.interaction-meta.json +0 -266
  221. package/test/resources/9999999/interaction/build-expected.json +0 -260
  222. package/test/resources/9999999/interaction/get-expected.json +0 -264
  223. package/test/resources/9999999/interaction/put-expected.json +0 -264
  224. package/test/resources/9999999/interaction/template-expected.json +0 -260
  225. package/test/resources/9999999/interaction/v1/interactions/put-response.json +0 -280
  226. /package/test/mockRoot/deploy/testInstance/testBU/{interaction → journey}/testNew_interaction.interaction-meta.json +0 -0
  227. /package/test/resources/9999999/{interaction → journey}/post-expected.json +0 -0
@@ -0,0 +1,1177 @@
1
+ 'use strict';
2
+
3
+ const TYPE = require('../../types/mcdev.d');
4
+ const MetadataType = require('./MetadataType');
5
+ const Util = require('../util/util');
6
+ const File = require('../util/file');
7
+ const cache = require('../util/cache');
8
+
9
+ /**
10
+ * MetadataType
11
+ *
12
+ * @augments MetadataType
13
+ */
14
+ class User extends MetadataType {
15
+ /**
16
+ * Retrieves SOAP based metadata of metadata type into local filesystem. executes callback with retrieved metadata
17
+ *
18
+ * @param {string} retrieveDir Directory where retrieved metadata directory will be saved
19
+ * @param {void} _ unused parameter
20
+ * @param {void} [__] unused parameter
21
+ * @param {string} [key] customer key of single item to retrieve
22
+ * @returns {Promise.<TYPE.MetadataTypeMapObj>} Promise of metadata
23
+ */
24
+ static async retrieve(retrieveDir, _, __, key) {
25
+ if (this.buObject.eid !== this.buObject.mid) {
26
+ Util.logger.info(' - Skipping User retrieval on non-parent BU');
27
+ return;
28
+ }
29
+ return this._retrieve(retrieveDir, key);
30
+ }
31
+ /**
32
+ * Retrieves import definition metadata for caching
33
+ *
34
+ * @returns {Promise.<TYPE.MetadataTypeMapObj>} Promise
35
+ */
36
+ static async retrieveForCache() {
37
+ return this.retrieve(null);
38
+ }
39
+
40
+ /**
41
+ * Create a single item.
42
+ *
43
+ * @param {TYPE.MetadataTypeItem} metadata single metadata entry
44
+ * @returns {Promise} Promise
45
+ */
46
+ static async create(metadata) {
47
+ if (this.buObject.eid !== this.buObject.mid) {
48
+ Util.logger.info(' - Skipping User creation on non-parent BU');
49
+ return;
50
+ }
51
+ return super.createSOAP(metadata);
52
+ }
53
+
54
+ /**
55
+ * Updates a single item.
56
+ *
57
+ * @param {TYPE.MetadataTypeItem} metadata single metadata entry
58
+ * @returns {Promise} Promise
59
+ */
60
+ static async update(metadata) {
61
+ if (this.buObject.eid !== this.buObject.mid) {
62
+ Util.logger.info(' - Skipping User update on non-parent BU');
63
+ return;
64
+ }
65
+ return super.updateSOAP(metadata);
66
+ }
67
+ /**
68
+ * prepares a item for deployment
69
+ *
70
+ * @param {TYPE.UserDocument} metadata of a single item
71
+ * @returns {TYPE.UserDocument} metadata object
72
+ */
73
+ static async preDeployTasks(metadata) {
74
+ metadata.Client = {
75
+ ID: this.buObject.mid,
76
+ };
77
+
78
+ // convert roles into API compliant format
79
+ if (metadata.c__RoleNamesGlobal?.length) {
80
+ metadata.Roles = {
81
+ Role: metadata.c__RoleNamesGlobal
82
+ .filter(
83
+ // individual role (which are not manageable nor visible in the GUI)
84
+ (roleName) => !roleName.startsWith('Individual role for ')
85
+ )
86
+ .map((roleName) => {
87
+ let roleCache;
88
+ try {
89
+ const roleKey = cache.searchForField(
90
+ 'role',
91
+ roleName,
92
+ 'Name',
93
+ 'CustomerKey'
94
+ );
95
+ roleCache = cache.getByKey('role', roleKey);
96
+ } catch {
97
+ // skip this role in case of errors
98
+ return;
99
+ }
100
+ if (roleCache?.c__notAssignable) {
101
+ throw new Error(
102
+ `Default roles starting on 'Marketing Cloud' are not assignable via API and get removed upon update. Please create/update the user manually in the GUI or remove that role.`
103
+ );
104
+ }
105
+ return {
106
+ ObjectID: roleCache.ObjectID,
107
+ Name: roleName,
108
+ };
109
+ })
110
+ .filter(Boolean),
111
+ };
112
+ }
113
+ delete metadata.c__RoleNamesGlobal;
114
+
115
+ // check if DefaultBusinessUnit is listed in AssociatedBUs
116
+ if (!metadata.c__AssociatedBusinessUnits.includes(metadata.DefaultBusinessUnit)) {
117
+ metadata.c__AssociatedBusinessUnits.push(metadata.DefaultBusinessUnit);
118
+ Util.logger.info(
119
+ Util.getGrayMsg(
120
+ ` - adding DefaultBusinessUnit to list of associated Business Units (${metadata.CustomerKey} / ${metadata.Name}): ${metadata.DefaultBusinessUnit}`
121
+ )
122
+ );
123
+ }
124
+ if (metadata.c__AssociatedBusinessUnits.length) {
125
+ // ensure we do not have duplicates in the list - could happen due to user error or due to templating
126
+ metadata.c__AssociatedBusinessUnits = [...new Set(metadata.c__AssociatedBusinessUnits)];
127
+ }
128
+
129
+ // Timezone
130
+ if (metadata.c__TimeZoneName) {
131
+ // find the ID of the timezone
132
+ metadata.TimeZone = { Name: metadata.c__TimeZoneName };
133
+ metadata.TimeZone.ID = cache.searchForField(
134
+ '_timezone',
135
+ metadata.c__TimeZoneName,
136
+ 'description',
137
+ 'id'
138
+ );
139
+ delete metadata.c__TimeZoneName;
140
+ }
141
+
142
+ // Locale
143
+ if (metadata.c__LocaleCode) {
144
+ // we cannot easily confirm if hte code is valid but SFMC's API will likely throw an error if not
145
+ metadata.Locale = { LocaleCode: metadata.c__LocaleCode };
146
+ delete metadata.c__LocaleCode;
147
+ }
148
+
149
+ // convert SSO / Federation Token into API compliant format
150
+ if (metadata.SsoIdentity || metadata.SsoIdentities) {
151
+ const ssoIdentity = {};
152
+ if (metadata.SsoIdentity) {
153
+ // assume metadata.SsoIdentity is an object
154
+ ssoIdentity.IsActive = metadata.SsoIdentity.IsActive;
155
+ ssoIdentity.FederatedId = metadata.SsoIdentity.FederatedId;
156
+ delete metadata.SsoIdentity;
157
+ } else if (Array.isArray(metadata.SsoIdentities)) {
158
+ // be nice and allow SsoIdentities as an alternative if its an array of objects
159
+ ssoIdentity.IsActive = metadata.SsoIdentities[0].IsActive;
160
+ ssoIdentity.FederatedId = metadata.SsoIdentities[0].FederatedId;
161
+ } else if (
162
+ Array.isArray(metadata.SsoIdentities?.SsoIdentity) &&
163
+ metadata.SsoIdentities?.SsoIdentity.length
164
+ ) {
165
+ // API-compliant format already provided; just use it
166
+ ssoIdentity.IsActive = metadata.SsoIdentities.SsoIdentity[0]?.IsActive;
167
+ ssoIdentity.FederatedId = metadata.SsoIdentities.SsoIdentity[0]?.FederatedId;
168
+ } else {
169
+ throw new TypeError(
170
+ 'SsoIdentity should be an object with IsActive and FederatedId properties.'
171
+ );
172
+ }
173
+ // if SsoIdentity is set, assume this was on purpose and bring it
174
+ metadata.SsoIdentities = {
175
+ SsoIdentity: [
176
+ {
177
+ IsActive: ssoIdentity.IsActive,
178
+ FederatedId: ssoIdentity.FederatedId,
179
+ },
180
+ ],
181
+ };
182
+ }
183
+
184
+ delete metadata.c__type;
185
+ delete metadata.c__AccountUserID;
186
+ delete metadata.c__IsLocked_readOnly;
187
+
188
+ return metadata;
189
+ }
190
+ /**
191
+ * helper for {@link MetadataType.upsert}
192
+ *
193
+ * @param {TYPE.MetadataTypeMap} metadata list of metadata
194
+ * @param {string} metadataKey key of item we are looking at
195
+ * @param {boolean} hasError error flag from previous code
196
+ * @param {TYPE.UserDocumentDiff[]} metadataToUpdate list of items to update
197
+ * @param {TYPE.UserDocument[]} metadataToCreate list of items to create
198
+ * @returns {void}
199
+ */
200
+ static createOrUpdate(metadata, metadataKey, hasError, metadataToUpdate, metadataToCreate) {
201
+ const action = super.createOrUpdate(
202
+ metadata,
203
+ metadataKey,
204
+ hasError,
205
+ metadataToUpdate,
206
+ metadataToCreate
207
+ );
208
+
209
+ if (action === 'create') {
210
+ const createItem = metadataToCreate[metadataToCreate.length - 1];
211
+ User._setPasswordForNewUser(createItem);
212
+ User._prepareRoleAssignments({ after: createItem });
213
+ User._prepareBuAssignments(metadata[metadataKey], null, createItem);
214
+ } else if (action === 'update') {
215
+ const updateItem = metadataToUpdate[metadataToUpdate.length - 1];
216
+ User._prepareRoleAssignments(updateItem);
217
+ User._prepareBuAssignments(metadata[metadataKey], updateItem, null);
218
+ }
219
+ }
220
+
221
+ /**
222
+ *
223
+ * @private
224
+ * @param {TYPE.MetadataTypeItem} metadata single metadata itme
225
+ * @param {TYPE.UserDocumentDiff} [updateItem] item to update
226
+ * @param {TYPE.UserDocument} [createItem] item to create
227
+ */
228
+ static _prepareBuAssignments(metadata, updateItem, createItem) {
229
+ this.userBUassignments ||= { add: {}, delete: {} };
230
+ if (updateItem) {
231
+ // remove business units that were unassigned
232
+ const deletedBUs = [];
233
+ updateItem.before.c__AssociatedBusinessUnits =
234
+ this.userIdBuMap[updateItem.before.ID] || [];
235
+ for (const oldBuAssignment of updateItem.before.c__AssociatedBusinessUnits) {
236
+ // check if oldRole is missing in list of new roles
237
+ if (!updateItem.after.c__AssociatedBusinessUnits.includes(oldBuAssignment)) {
238
+ deletedBUs.push(oldBuAssignment);
239
+ }
240
+ }
241
+ if (deletedBUs.length > 0) {
242
+ this.userBUassignments['delete'][updateItem.before.AccountUserID] = deletedBUs;
243
+ }
244
+ // add business units that were newly assigned
245
+ const addedBUs = [];
246
+ for (const newBuAssignment of updateItem.after.c__AssociatedBusinessUnits) {
247
+ // check if oldRole is missing in list of new roles
248
+ if (!updateItem.before.c__AssociatedBusinessUnits.includes(newBuAssignment)) {
249
+ addedBUs.push(newBuAssignment);
250
+ }
251
+ }
252
+ if (addedBUs.length > 0) {
253
+ this.userBUassignments['add'][updateItem.before.AccountUserID] = addedBUs;
254
+ }
255
+ }
256
+ // add BUs for new users
257
+ if (createItem) {
258
+ const addedBUs = createItem.c__AssociatedBusinessUnits || [];
259
+ if (addedBUs.length > 0) {
260
+ this.userBUassignments['add']['key:' + createItem.CustomerKey] = addedBUs;
261
+ }
262
+ }
263
+ delete metadata.c__AssociatedBusinessUnits;
264
+ }
265
+
266
+ /**
267
+ * Gets executed after deployment of metadata type
268
+ *
269
+ * @param {TYPE.UserDocumentMap} upsertResults metadata mapped by their keyField
270
+ * @returns {Promise.<void>} promise
271
+ */
272
+ static async postDeployTasks(upsertResults) {
273
+ if (Object.keys(upsertResults).length) {
274
+ await this._handleBuAssignments(upsertResults);
275
+ }
276
+ }
277
+ /**
278
+ * create/update business unit assignments
279
+ *
280
+ * @private
281
+ * @param {TYPE.UserDocumentMap} upsertResults metadata mapped by their keyField
282
+ * @returns {void}
283
+ */
284
+ static async _handleBuAssignments(upsertResults) {
285
+ /** @type {TYPE.UserConfiguration[]} */
286
+ const configs = [];
287
+ for (const action in this.userBUassignments) {
288
+ for (const data of Object.entries(this.userBUassignments[action])) {
289
+ const buIds = data[1];
290
+ let userId = data[0];
291
+ if (!userId) {
292
+ continue;
293
+ }
294
+ userId = userId.startsWith('key:') ? upsertResults[userId.slice(4)].ID : userId;
295
+ configs.push(
296
+ /** @type {TYPE.UserConfiguration} */ {
297
+ Client: { ID: this.buObject.eid },
298
+ ID: userId,
299
+ BusinessUnitAssignmentConfiguration: {
300
+ BusinessUnitIds: { BusinessUnitId: buIds },
301
+ IsDelete: action === 'delete',
302
+ },
303
+ }
304
+ );
305
+ }
306
+ }
307
+ if (configs.length > 0) {
308
+ Util.logger.info('Deploying: BU assignment changes');
309
+ // run update
310
+ const buResponse = await this.client.soap.configure('AccountUser', configs);
311
+ // process response
312
+ if (buResponse.OverallStatus === 'OK') {
313
+ // get userIdNameMap
314
+ const userIdNameMap = {};
315
+ for (const user of Object.values(upsertResults)) {
316
+ userIdNameMap[user.ID] = `${user.CustomerKey} / ${user.Name}`;
317
+ }
318
+ // log what was added / removed
319
+ let configureResults = buResponse.Results?.[0]?.Result;
320
+ if (!configureResults) {
321
+ Util.logger.debug(
322
+ 'buResponse.Results?.[0]?.Result not defined: ' + JSON.stringify(buResponse)
323
+ );
324
+ return;
325
+ }
326
+ if (!Array.isArray(configureResults)) {
327
+ configureResults = [configureResults];
328
+ }
329
+ const userBUresults = {};
330
+ for (const result of configureResults) {
331
+ if (result.StatusCode === 'OK') {
332
+ /** @type {TYPE.UserConfiguration} */
333
+ const config = result.Object;
334
+ const buArr =
335
+ config.BusinessUnitAssignmentConfiguration.BusinessUnitIds
336
+ .BusinessUnitId;
337
+ userBUresults[config.ID] ||= {
338
+ add: [],
339
+ delete: [],
340
+ };
341
+ userBUresults[config.ID][
342
+ config.BusinessUnitAssignmentConfiguration.IsDelete ? 'delete' : 'add'
343
+ ] = Array.isArray(buArr) ? buArr : [buArr];
344
+ } else {
345
+ Util.logger.debug(
346
+ `Unknown error occured during processing of BU assignment reponse: ${JSON.stringify(
347
+ result
348
+ )}`
349
+ );
350
+ }
351
+ }
352
+ for (const [userId, buResult] of Object.entries(userBUresults)) {
353
+ // show CLI log
354
+ const msgs = [];
355
+ if (buResult['add']?.length) {
356
+ msgs.push(`MID ${buResult['add'].join(', ')} access granted`);
357
+ } else {
358
+ msgs.push('no new access granted');
359
+ }
360
+ if (buResult['delete']?.length) {
361
+ msgs.push(`MID ${buResult['delete'].join(', ')} access removed`);
362
+ } else {
363
+ msgs.push('no access removed');
364
+ }
365
+ Util.logger.info(` - user ${userIdNameMap[userId]}: ${msgs.join(' / ')}`);
366
+ // update BU map in local variable
367
+ if (buResult['add']?.length) {
368
+ this.userIdBuMap[userId] ||= [];
369
+ this.userIdBuMap[userId].push(
370
+ ...buResult['add'].filter(
371
+ (item) => !this.userIdBuMap[userId].includes(item)
372
+ )
373
+ );
374
+ }
375
+ if (buResult['delete']?.length) {
376
+ this.userIdBuMap[userId] ||= [];
377
+ this.userIdBuMap[userId] = this.userIdBuMap[userId].filter(
378
+ (item) => !buResult['delete'].includes(item)
379
+ );
380
+ }
381
+ }
382
+ }
383
+ }
384
+ }
385
+
386
+ /**
387
+ * helper for {@link User.createOrUpdate}
388
+ *
389
+ * @private
390
+ * @param {TYPE.UserDocument} metadata single created user
391
+ * @returns {void}
392
+ */
393
+ static _setPasswordForNewUser(metadata) {
394
+ // if Password is not set during CREATE, generate one
395
+ // avoids error "Name, Email, UserID, and Password are required fields when creating a new user. (Code 11003)"
396
+ if (!metadata.Password) {
397
+ metadata.Password = this._generatePassword();
398
+ Util.logger.info(
399
+ ` - Password for ${metadata.UserID} was not given. Generated password: ${metadata.Password}`
400
+ );
401
+ }
402
+ }
403
+
404
+ /**
405
+ * helper for {@link User.createOrUpdate}
406
+ * It searches for roles that were removed from the user and unassigns them; it also prints a summary of added/removed roles
407
+ * Adding roles works automatically for roles listed on the user
408
+ *
409
+ * @private
410
+ * @param {TYPE.UserDocumentDiff} item updated user with before and after state
411
+ * @returns {void}
412
+ */
413
+ static _prepareRoleAssignments(item) {
414
+ // delete global roles from user that were not in the c__RoleNamesGlobal array / Roles.Role
415
+ const deletedRoles = [];
416
+ const deletedRoleNames = [];
417
+ if (item.after?.Roles?.Role && !Array.isArray(item.after.Roles.Role)) {
418
+ item.after.Roles.Role = [item.after.Roles.Role];
419
+ }
420
+ if (item.before?.Roles?.Role) {
421
+ if (!Array.isArray(item.before.Roles.Role)) {
422
+ item.before.Roles.Role = [item.before.Roles.Role];
423
+ }
424
+ for (const oldRole of item.before.Roles.Role) {
425
+ // check if oldRole is missing in list of new roles --> removing role
426
+ let oldRoleFound = false;
427
+ if (item.after.Roles?.Role) {
428
+ for (const newRole of item.after.Roles.Role) {
429
+ if (newRole.ObjectID == oldRole.ObjectID) {
430
+ oldRoleFound = true;
431
+ break;
432
+ }
433
+ }
434
+ }
435
+ if (!oldRoleFound && !oldRole.Name.startsWith('Individual role for ')) {
436
+ // delete role unless it is an individual role (which are not manageable nor visible in the GUI)
437
+ deletedRoles.push({ ObjectID: oldRole.ObjectID, Name: oldRole.Name });
438
+ deletedRoleNames.push(oldRole.Name);
439
+ }
440
+ }
441
+ }
442
+ const addedRoleNames = [];
443
+ if (item.after?.Roles?.Role) {
444
+ for (const newRole of item.after.Roles.Role) {
445
+ // check if newRole is missing in list of old roles --> adding role
446
+ let roleAlreadyExisting = false;
447
+ if (item.before?.Roles?.Role) {
448
+ for (const oldRole of item.before.Roles.Role) {
449
+ if (newRole.ObjectID == oldRole.ObjectID) {
450
+ roleAlreadyExisting = true;
451
+ break;
452
+ }
453
+ }
454
+ }
455
+ if (!roleAlreadyExisting) {
456
+ addedRoleNames.push(newRole.Name);
457
+ // Note: no AssignmentConfigurations property is needed to ADD global roles
458
+ }
459
+ }
460
+ if (addedRoleNames.length) {
461
+ Util.logger.info(
462
+ Util.getGrayMsg(
463
+ ` - adding role-assignment (${item.after.CustomerKey} / ${
464
+ item.after.Name
465
+ }): ${addedRoleNames.join(', ')}`
466
+ )
467
+ );
468
+ }
469
+ }
470
+ if (deletedRoles.length) {
471
+ Util.logger.info(
472
+ Util.getGrayMsg(
473
+ ` - removing role-assignment (${item.after.CustomerKey} / ${
474
+ item.after.Name
475
+ }): ${deletedRoleNames.join(', ')}`
476
+ )
477
+ );
478
+ // add deleted roles to payload with IsDelete=true
479
+ if (!item.after.Roles) {
480
+ item.after.Roles = { Role: [] };
481
+ } else if (!item.after.Roles.Role) {
482
+ item.after.Roles.Role = [];
483
+ }
484
+ item.after.Roles.Role.push(
485
+ ...deletedRoles.map((role) =>
486
+ this._getRoleObjectForDeploy(
487
+ role.ObjectID,
488
+ role.Name,
489
+ item.after.AccountUserID,
490
+ false,
491
+ true
492
+ )
493
+ )
494
+ );
495
+ }
496
+ }
497
+
498
+ /**
499
+ * helper for {@link User._prepareRoleAssignments}
500
+ *
501
+ * @param {string} roleId role.ObjectID
502
+ * @param {string} roleName role.Name
503
+ * @param {number} userId user.AccountUserID
504
+ * @param {boolean} assignmentOnly if true, only assignment configuration will be returned
505
+ * @param {boolean} [isRoleRemovale=false] if true, role will be removed from user; otherwise added
506
+ * @returns {object} format needed by API
507
+ */
508
+ static _getRoleObjectForDeploy(
509
+ roleId,
510
+ roleName,
511
+ userId,
512
+ assignmentOnly,
513
+ isRoleRemovale = false
514
+ ) {
515
+ const assignmentConfigurations = {
516
+ AssignmentConfiguration: [
517
+ {
518
+ AccountUserId: userId,
519
+ AssignmentConfigureType: 'RoleUser',
520
+ IsDelete: isRoleRemovale,
521
+ },
522
+ ],
523
+ };
524
+ return assignmentOnly
525
+ ? assignmentConfigurations
526
+ : {
527
+ ObjectID: roleId,
528
+ Name: roleName,
529
+ AssignmentConfigurations: assignmentConfigurations,
530
+ };
531
+ }
532
+
533
+ /**
534
+ * Retrieves SOAP based metadata of metadata type into local filesystem. executes callback with retrieved metadata
535
+ *
536
+ * @returns {Promise.<TYPE.MetadataTypeMapObj>} Promise of metadata
537
+ */
538
+ static async retrieveChangelog() {
539
+ return this._retrieve();
540
+ }
541
+ /**
542
+ * Retrieves SOAP based metadata of metadata type into local filesystem. executes callback with retrieved metadata
543
+ *
544
+ * @private
545
+ * @param {string} retrieveDir Directory where retrieved metadata directory will be saved
546
+ * @param {string} [key] customer key of single item to retrieve
547
+ * @returns {Promise.<TYPE.MetadataTypeMapObj>} Promise of metadata
548
+ */
549
+ static async _retrieve(retrieveDir, key) {
550
+ this.userIdBuMap = {};
551
+ /** @type {TYPE.SoapRequestParams} */
552
+ const requestParams = {
553
+ QueryAllAccounts: true,
554
+
555
+ filter: {
556
+ // normal users
557
+ leftOperand: 'Email',
558
+ operator: 'like',
559
+ rightOperand: '@',
560
+ },
561
+ };
562
+ if (key) {
563
+ // move original filter down one level into rightOperand and add key filter into leftOperand
564
+ requestParams.filter = {
565
+ leftOperand: {
566
+ leftOperand: 'CustomerKey',
567
+ operator: 'equals',
568
+ rightOperand: key,
569
+ },
570
+ operator: 'AND',
571
+ rightOperand: requestParams.filter,
572
+ };
573
+ } else {
574
+ // if we are not filtering by key the following requests will take long. Warn the user
575
+ Util.logger.info(` - Loading ${this.definition.type}s. This might take a while...`);
576
+ }
577
+
578
+ // get actual user details
579
+ return this.retrieveSOAP(retrieveDir, requestParams, key);
580
+ }
581
+
582
+ /**
583
+ * Retrieves SOAP via generic fuel-soap wrapper based metadata of metadata type into local filesystem. executes callback with retrieved metadata
584
+ *
585
+ * @param {string} retrieveDir Directory where retrieved metadata directory will be saved
586
+ * @param {TYPE.SoapRequestParams} [requestParams] required for the specific request (filter for example)
587
+ * @param {string|number} [singleRetrieve] key of single item to filter by
588
+ * @param {string[]} [additionalFields] Returns specified fields even if their retrieve definition is not set to true
589
+ * @returns {Promise.<TYPE.MetadataTypeMapObj>} Promise of item map
590
+ */
591
+ static async retrieveSOAP(retrieveDir, requestParams, singleRetrieve, additionalFields) {
592
+ // to avoid not retrieving roles and userPermissions for users above the 2500 records limit we need to retrieve users twice, once with ActiveFlag=true and once with ActiveFlag=false
593
+ const requestParamsUser = {
594
+ QueryAllAccounts: true,
595
+ filter: {
596
+ leftOperand: {
597
+ leftOperand: 'ActiveFlag',
598
+ operator: 'equals',
599
+ rightOperand: null,
600
+ },
601
+ operator: 'AND',
602
+ rightOperand: requestParams.filter,
603
+ },
604
+ };
605
+ const fields = this.getFieldNamesToRetrieve(additionalFields, !retrieveDir);
606
+ const soapType = this.definition.soapType || this.definition.type;
607
+ let resultsBulk;
608
+ let foundSingle = false;
609
+ for (const active of [true, false]) {
610
+ requestParamsUser.filter.leftOperand.rightOperand = active;
611
+ try {
612
+ const resultsBatch = await this.client.soap.retrieveBulk(
613
+ soapType,
614
+ fields,
615
+ requestParamsUser
616
+ );
617
+ if (Array.isArray(resultsBatch?.Results)) {
618
+ Util.logger.debug(
619
+ Util.getGrayMsg(
620
+ ` - found ${resultsBatch?.Results.length} ${
621
+ active ? 'active' : 'inactive'
622
+ } ${this.definition.type}s`
623
+ )
624
+ );
625
+ if (resultsBulk) {
626
+ // once first batch is done, the follow just add to result payload
627
+ resultsBulk.Results.push(...resultsBatch.Results);
628
+ } else {
629
+ resultsBulk = resultsBatch;
630
+ }
631
+ if (singleRetrieve && resultsBatch?.Results.length) {
632
+ foundSingle = true;
633
+ break;
634
+ }
635
+ }
636
+ } catch (ex) {
637
+ this._handleSOAPErrors(ex, 'retrieving');
638
+ return {};
639
+ }
640
+ }
641
+ if (
642
+ !foundSingle &&
643
+ !(await this._retrieveSOAP_installedPackage(
644
+ requestParams,
645
+ soapType,
646
+ fields,
647
+ resultsBulk
648
+ ))
649
+ ) {
650
+ return {};
651
+ }
652
+
653
+ const metadata = this.parseResponseBody(resultsBulk);
654
+ // get BUs that each users have access to
655
+ if (resultsBulk?.Results?.length > 0) {
656
+ if (!singleRetrieve) {
657
+ Util.logger.info(
658
+ Util.getGrayMsg(` - found ${resultsBulk?.Results.length} users`)
659
+ );
660
+ }
661
+ // split array resultsBulk?.Results into chunks to avoid not getting all roles
662
+ const chunkSize = 100;
663
+ this.userIdBuMap = {};
664
+ Util.logger.info(` - Caching dependent Metadata: Business Unit assignments`);
665
+ for (let i = 0; i < resultsBulk?.Results?.length; i += chunkSize) {
666
+ if (i > 0) {
667
+ Util.logger.info(
668
+ Util.getGrayMsg(` - Requesting next batch (retrieved BUs for ${i} users)`)
669
+ );
670
+ }
671
+ const chunk = resultsBulk?.Results?.slice(i, i + chunkSize);
672
+ const resultsBatch = (
673
+ await this.client.soap.retrieveBulk(
674
+ 'AccountUserAccount',
675
+ ['AccountUser.AccountUserID', 'Account.ID'],
676
+ {
677
+ filter: {
678
+ leftOperand: 'AccountUser.AccountUserID',
679
+ operator: chunk.length > 1 ? 'IN' : 'equals', // API does not allow IN for single item
680
+ rightOperand: chunk.map((item) => item.AccountUserID),
681
+ },
682
+ }
683
+ )
684
+ ).Results;
685
+ for (const item of resultsBatch) {
686
+ this.userIdBuMap[item.AccountUser.AccountUserID] ||= [];
687
+ // push to array if not already in array
688
+ if (
689
+ !this.userIdBuMap[item.AccountUser.AccountUserID].includes(item.Account.ID)
690
+ ) {
691
+ this.userIdBuMap[item.AccountUser.AccountUserID].push(item.Account.ID);
692
+ }
693
+ }
694
+ }
695
+ }
696
+
697
+ if (retrieveDir) {
698
+ const savedMetadata = await this.saveResults(metadata, retrieveDir, null);
699
+ Util.logger.info(
700
+ `Downloaded: ${this.definition.type} (${Object.keys(savedMetadata).length})` +
701
+ Util.getKeysString(singleRetrieve)
702
+ );
703
+ if (!singleRetrieve) {
704
+ // print summary to cli
705
+ const counter = {
706
+ userActive: 0,
707
+ userInactive: 0,
708
+ installedPackage: 0,
709
+ };
710
+ for (const id in savedMetadata) {
711
+ const item = savedMetadata[id];
712
+ if (item.c__type === 'Installed Package') {
713
+ counter.installedPackage++;
714
+ } else if (item.ActiveFlag) {
715
+ counter.userActive++;
716
+ } else {
717
+ counter.userInactive++;
718
+ }
719
+ }
720
+ Util.logger.info(
721
+ Util.getGrayMsg(
722
+ `Found ${counter.userActive} active users / ${counter.userInactive} inactive users / ${counter.installedPackage} installed packages`
723
+ )
724
+ );
725
+ }
726
+ if (
727
+ !singleRetrieve &&
728
+ this.buObject &&
729
+ this.properties.metaDataTypes.documentOnRetrieve.includes(this.definition.type)
730
+ ) {
731
+ await this.document(savedMetadata);
732
+ }
733
+ }
734
+ return { metadata: metadata, type: this.definition.type };
735
+ }
736
+
737
+ /**
738
+ * helper for {@link retrieveSOAP}
739
+ *
740
+ * @private
741
+ * @param {TYPE.SoapRequestParams} [requestParams] required for the specific request (filter for example)
742
+ * @param {string} soapType e.g. AccountUser
743
+ * @param {string[]} fields list of fields to retrieve
744
+ * @param {object} resultsBulk actual return value of this method
745
+ * @returns {Promise.<boolean>} success flag
746
+ */
747
+ static async _retrieveSOAP_installedPackage(requestParams, soapType, fields, resultsBulk) {
748
+ const requestParamsInstalledPackage = {
749
+ QueryAllAccounts: true,
750
+
751
+ filter: {
752
+ leftOperand: {
753
+ leftOperand: 'ActiveFlag',
754
+ operator: 'equals',
755
+ rightOperand: true, // inactive installed packages are not visible in UI and hence cannot be reactivated there. Let's not retrieve them
756
+ },
757
+ operator: 'AND',
758
+ rightOperand: {
759
+ leftOperand: {
760
+ // filter out normal users
761
+ leftOperand: 'Email',
762
+ operator: 'isNull',
763
+ },
764
+ operator: 'OR',
765
+ rightOperand: {
766
+ // installed packages
767
+ leftOperand: {
768
+ leftOperand: 'Name',
769
+ operator: 'like',
770
+ rightOperand: ' app user', // ! will not work if the name was too long as "app user" might be cut off
771
+ },
772
+ operator: 'AND',
773
+ rightOperand: {
774
+ // this is used to filter out system generated installed packages. in our testing, at least those installed packages created in the last few years have hat set this to false while additional (hidden) installed packages have it set to true.
775
+ leftOperand: 'MustChangePassword',
776
+ operator: 'equals',
777
+ rightOperand: 'false',
778
+ },
779
+ },
780
+ },
781
+ },
782
+ };
783
+ if (requestParams?.filter?.leftOperand?.leftOperand === 'CustomerKey') {
784
+ requestParamsInstalledPackage.filter = {
785
+ leftOperand: requestParams?.filter?.leftOperand,
786
+ operator: 'AND',
787
+ rightOperand: requestParamsInstalledPackage.filter,
788
+ };
789
+ }
790
+ try {
791
+ const resultsBatch = await this.client.soap.retrieveBulk(
792
+ soapType,
793
+ fields,
794
+ requestParamsInstalledPackage
795
+ );
796
+ if (Array.isArray(resultsBatch?.Results)) {
797
+ Util.logger.debug(
798
+ Util.getGrayMsg(` - found ${resultsBatch?.Results.length} installed packages`)
799
+ );
800
+ if (resultsBulk) {
801
+ // once first batch is done, the follow just add to result payload
802
+ resultsBulk.Results.push(...resultsBatch.Results);
803
+ } else {
804
+ resultsBulk = resultsBatch;
805
+ }
806
+ }
807
+ } catch (ex) {
808
+ this._handleSOAPErrors(ex, 'retrieving');
809
+ return false;
810
+ }
811
+ return true;
812
+ }
813
+
814
+ /**
815
+ *
816
+ * @private
817
+ * @param {string} date first date
818
+ * @returns {number} time difference
819
+ */
820
+ static _timeSinceDate(date) {
821
+ const interval = 'days';
822
+ const second = 1000,
823
+ minute = second * 60,
824
+ hour = minute * 60,
825
+ day = hour * 24,
826
+ week = day * 7;
827
+ date = new Date(date);
828
+ const now = new Date();
829
+ const timediff = now - date;
830
+ if (Number.isNaN(timediff)) {
831
+ return Number.NaN;
832
+ }
833
+ let result;
834
+ switch (interval) {
835
+ case 'years': {
836
+ result = now.getFullYear() - date.getFullYear();
837
+ break;
838
+ }
839
+ case 'months': {
840
+ result =
841
+ now.getFullYear() * 12 +
842
+ now.getMonth() -
843
+ (date.getFullYear() * 12 + date.getMonth());
844
+ break;
845
+ }
846
+ case 'weeks': {
847
+ result = Math.floor(timediff / week);
848
+ break;
849
+ }
850
+ case 'days': {
851
+ result = Math.floor(timediff / day);
852
+ break;
853
+ }
854
+ case 'hours': {
855
+ result = Math.floor(timediff / hour);
856
+ break;
857
+ }
858
+ case 'minutes': {
859
+ result = Math.floor(timediff / minute);
860
+ break;
861
+ }
862
+ case 'seconds': {
863
+ result = Math.floor(timediff / second);
864
+ break;
865
+ }
866
+ default: {
867
+ return;
868
+ }
869
+ }
870
+ return result + ' ' + interval;
871
+ }
872
+ /**
873
+ * helper to print bu names
874
+ *
875
+ * @private
876
+ * @param {number} id bu id
877
+ * @returns {string} "bu name (bu id)""
878
+ */
879
+ static _getBuName(id) {
880
+ const name = this.buObject.eid == id ? '_ParentBU_' : this.buIdName[id];
881
+ return `<nobr>${name} (${id})</nobr>`;
882
+ }
883
+
884
+ /**
885
+ * helper that gets BU names from config
886
+ *
887
+ * @private
888
+ */
889
+ static _getBuNames() {
890
+ this.buIdName = {};
891
+ for (const cred in this.properties.credentials) {
892
+ for (const buName in this.properties.credentials[cred].businessUnits) {
893
+ this.buIdName[this.properties.credentials[cred].businessUnits[buName]] = buName;
894
+ }
895
+ }
896
+ }
897
+ /**
898
+ * helper for {@link createOrUpdate} to generate a random initial password for new users
899
+ * note: possible minimum length values in SFMC are 6, 8, 10, 15 chars. Therefore we should default here to 15 chars.
900
+ *
901
+ * @private
902
+ * @param {number} [length] length of password; defaults to 15
903
+ * @returns {string} random password
904
+ */
905
+ static _generatePassword(length = 15) {
906
+ const alpha = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
907
+ const special = '!@#$%&';
908
+ const numeric = '0123456789';
909
+ const charset = alpha + special + numeric;
910
+ let retVal;
911
+ do {
912
+ retVal = '';
913
+ for (let i = 0, n = charset.length; i < length; ++i) {
914
+ retVal += charset.charAt(Math.floor(Math.random() * n));
915
+ }
916
+ // check if password contains at least one of each character type or else generate a new one
917
+ } while (
918
+ !/[a-z]/.test(retVal) ||
919
+ !/[A-Z]/.test(retVal) ||
920
+ !/[0-9]/.test(retVal) ||
921
+ !/[!@#$%&]/.test(retVal)
922
+ );
923
+ return retVal;
924
+ }
925
+ /**
926
+ * Creates markdown documentation of all roles
927
+ *
928
+ * @param {TYPE.MetadataTypeMap} [metadata] user list
929
+ * @returns {Promise.<void>} -
930
+ */
931
+ static async document(metadata) {
932
+ if (this.buObject.eid !== this.buObject.mid) {
933
+ Util.logger.error(
934
+ `Users can only be retrieved & documented for the ${Util.parentBuName}`
935
+ );
936
+ return;
937
+ }
938
+
939
+ // if ran as part of retrieve/deploy with key, exit here
940
+ if (metadata && Object.keys(metadata).length === 1) {
941
+ Util.logger.debug(
942
+ 'Only 1 user found. Skipping documentation, assuming we ran retrieve-by-key.'
943
+ );
944
+ return;
945
+ }
946
+
947
+ if (!metadata) {
948
+ // load users from disk if document was called directly and not part of a retrieve
949
+ try {
950
+ metadata = this.readBUMetadataForType(
951
+ File.normalizePath([
952
+ this.properties.directories.retrieve,
953
+ this.buObject.credential,
954
+ Util.parentBuName,
955
+ ]),
956
+ true
957
+ ).user;
958
+ } catch (ex) {
959
+ Util.logger.error(ex.message);
960
+ return;
961
+ }
962
+ }
963
+
964
+ // init map of BU Ids > BU Name
965
+ this._getBuNames();
966
+
967
+ /**
968
+ * @type {TYPE.UserDocument[]}
969
+ */
970
+ const users = [];
971
+
972
+ for (const id in metadata) {
973
+ const user = metadata[id];
974
+ // TODO resolve user permissions to something readable
975
+ // user roles
976
+ let roles = '';
977
+ if (user.c__RoleNamesGlobal) {
978
+ roles = '<nobr>' + user.c__RoleNamesGlobal.join(',</nobr><br> <nobr>') + '</nobr>';
979
+ }
980
+ let associatedBus = '';
981
+ if (user.c__AssociatedBusinessUnits) {
982
+ // ensure Parent BU is first in list
983
+ user.c__AssociatedBusinessUnits.push(user.DefaultBusinessUnit);
984
+ // ensure associatedBus have no duplicates
985
+ associatedBus = [
986
+ ...new Set(user.c__AssociatedBusinessUnits.map((mid) => this._getBuName(mid))),
987
+ ]
988
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
989
+ .join(',<br> ');
990
+ }
991
+ const defaultBUName = this._getBuName(user.DefaultBusinessUnit);
992
+ const LastSuccessfulLogin = user.LastSuccessfulLogin.split('.')[0];
993
+ users.push({
994
+ TYPE: user.c__type,
995
+ UserID: user.UserID,
996
+ AccountUserID: user.c__AccountUserID,
997
+ CustomerKey: user.CustomerKey,
998
+ Name: user.Name,
999
+ Email: user.Email,
1000
+ NotificationEmailAddress: user.NotificationEmailAddress,
1001
+ ActiveFlag: user.ActiveFlag === true ? '✓' : '-',
1002
+ IsLocked: user.IsLocked === true ? '✓' : '-',
1003
+ IsAPIUser: user.IsAPIUser === true ? '✓' : '-',
1004
+ MustChangePassword: user.MustChangePassword === true ? '✓' : '-',
1005
+ DefaultBusinessUnit: defaultBUName,
1006
+ AssociatedBus: associatedBus,
1007
+ Roles: roles,
1008
+ LastSuccessfulLogin: user.LastSuccessfulLogin
1009
+ ? // on create & update, LastSuccessfulLogin often gets overwritten with the current date
1010
+ LastSuccessfulLogin === user.CreatedDate.split('.')[0] ||
1011
+ LastSuccessfulLogin === user.ModifiedDate.split('.')[0]
1012
+ ? 'unknown'
1013
+ : this._timeSinceDate(user.LastSuccessfulLogin)
1014
+ : 'never',
1015
+ CreatedDate: user.CreatedDate ? user.CreatedDate.split('T').join(' ') : 'n/a',
1016
+ ModifiedDate: user.ModifiedDate ? user.ModifiedDate.split('T').join(' ') : 'n/a',
1017
+ ModifiedBy: user.Client.ModifiedBy || 'n/a',
1018
+ TimeZoneName: user.c__TimeZoneName.slice(1, 10),
1019
+ LocaleCode: user.c__LocaleCode,
1020
+ });
1021
+ }
1022
+ users.sort((a, b) => (a.Name < b.Name ? -1 : a.Name > b.Name ? 1 : 0));
1023
+ const columnsToPrint = [
1024
+ ['Name', 'Name'],
1025
+ ['Last successful Login', 'LastSuccessfulLogin'],
1026
+ ['Active', 'ActiveFlag'],
1027
+ ['Access Locked out', 'IsLocked'],
1028
+ ['API User', 'IsAPIUser'],
1029
+ ['Must change PW', 'MustChangePassword'],
1030
+ ['Default BU', 'DefaultBusinessUnit'],
1031
+ ['BU Access', 'AssociatedBus'],
1032
+ ['Roles', 'Roles'],
1033
+ ['Login', 'UserID'],
1034
+ ['ID', 'AccountUserID'],
1035
+ ['Key', 'CustomerKey'],
1036
+ ['E-Mail', 'Email'],
1037
+ ['Notification E-Mail', 'NotificationEmailAddress'],
1038
+ ['Timezone', 'TimeZoneName'],
1039
+ ['SFMC Locale', 'LocaleCode'],
1040
+ ['Modified Date', 'ModifiedDate'],
1041
+ ['Modified By', 'ModifiedBy'],
1042
+ ['Created Date', 'CreatedDate'],
1043
+ ];
1044
+ let output = `# User Overview - ${this.buObject.credential}`;
1045
+ output += this._generateDocMd(
1046
+ users.filter((user) => user.TYPE === 'User' && user.ActiveFlag === '✓'),
1047
+ 'User',
1048
+ columnsToPrint
1049
+ );
1050
+ output += this._generateDocMd(
1051
+ users.filter((user) => user.TYPE === 'User' && user.ActiveFlag === '-'),
1052
+ 'Inactivated User',
1053
+ columnsToPrint
1054
+ );
1055
+ output += this._generateDocMd(
1056
+ users.filter((user) => user.TYPE === 'Installed Package'),
1057
+ 'Installed Package',
1058
+ columnsToPrint
1059
+ );
1060
+ const docPath = File.normalizePath([this.properties.directories.docs, 'user']);
1061
+
1062
+ try {
1063
+ const filename = this.buObject.credential;
1064
+ // write to disk
1065
+ await File.writeToFile(docPath, filename + '.users', 'md', output);
1066
+ Util.logger.info(`Created ${File.normalizePath([docPath, filename])}.users.md`);
1067
+ if (['html', 'both'].includes(this.properties.options.documentType)) {
1068
+ Util.logger.warn(' - HTML-based documentation of user currently not supported.');
1069
+ }
1070
+ } catch (ex) {
1071
+ Util.logger.error(`user.document():: error | `, ex.message);
1072
+ }
1073
+ }
1074
+ /**
1075
+ *
1076
+ * @private
1077
+ * @param {object[]} users list of users and installed package
1078
+ * @param {'Installed Package'|'User'} type choose what sub type to print
1079
+ * @param {Array[]} columnsToPrint helper array
1080
+ * @returns {string} markdown
1081
+ */
1082
+ static _generateDocMd(users, type, columnsToPrint) {
1083
+ let output = `\n\n## ${type}s (${users.length})\n\n`;
1084
+ let tableSeparator = '';
1085
+ for (const column of columnsToPrint) {
1086
+ output += `| ${column[0]} `;
1087
+ tableSeparator += '| --- ';
1088
+ }
1089
+ output += `|\n${tableSeparator}|\n`;
1090
+ for (const user of users) {
1091
+ for (const column of columnsToPrint) {
1092
+ output += `| ${user[column[1]]} `;
1093
+ }
1094
+ output += `|\n`;
1095
+ }
1096
+ return output;
1097
+ }
1098
+
1099
+ /**
1100
+ * manages post retrieve steps
1101
+ *
1102
+ * @param {TYPE.MetadataTypeItem} metadata a single item
1103
+ * @returns {TYPE.MetadataTypeItem | void} a single item
1104
+ */
1105
+ static postRetrieveTasks(metadata) {
1106
+ metadata.c__type = 'Installed Package';
1107
+ if (metadata.Email.includes('@') && !metadata.Name.endsWith('app user')) {
1108
+ metadata.c__type = 'User';
1109
+ }
1110
+ if (metadata.c__type === 'Installed Package' && !metadata.ActiveFlag) {
1111
+ // deleted installed package - we do try to filter them in the API call but sometimes they slip through in the other calls
1112
+ return;
1113
+ }
1114
+
1115
+ // rewrite AccountUserID to avoid accidental overwrites by create attempts but still allow users to search for this ID
1116
+ metadata.c__AccountUserID = metadata.AccountUserID;
1117
+ delete metadata.AccountUserID;
1118
+
1119
+ // the actual field cannot be updated. to avoid confusion, we rename it
1120
+ metadata.c__IsLocked_readOnly = metadata.IsLocked;
1121
+ delete metadata.IsLocked;
1122
+ if (metadata.c__IsLocked_readOnly) {
1123
+ // add this field in case the user is locked to offer the opportunity to unlock it via api
1124
+ metadata.Unlock = false;
1125
+ }
1126
+
1127
+ metadata.c__AssociatedBusinessUnits = this.userIdBuMap[metadata.ID] || [];
1128
+ metadata.c__AssociatedBusinessUnits.sort();
1129
+
1130
+ // make roles easily accessible
1131
+ let roles;
1132
+ if (metadata.Roles?.Role) {
1133
+ // normalize to always use array
1134
+ if (!metadata.Roles.Role.length) {
1135
+ metadata.Roles.Role = [metadata.Roles.Role];
1136
+ }
1137
+ // convert complex object into basic set of info
1138
+ // turns out, Role Names are unique and hence we can turn this into a simple array of names
1139
+ roles = metadata.Roles.Role.map((item) => item.Name)
1140
+ .filter(Boolean)
1141
+ .filter(
1142
+ // individual role (which are not manageable nor visible in the GUI)
1143
+ (roleName) => !roleName.startsWith('Individual role for ')
1144
+ )
1145
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1146
+ } else {
1147
+ // set to empty array
1148
+ roles = [];
1149
+ }
1150
+ metadata.c__RoleNamesGlobal = roles;
1151
+ delete metadata.Roles;
1152
+
1153
+ // Timezone
1154
+ if (metadata.TimeZone?.ID) {
1155
+ metadata.c__TimeZoneName = cache.searchForField(
1156
+ '_timezone',
1157
+ metadata.TimeZone.ID,
1158
+ 'id',
1159
+ 'description'
1160
+ );
1161
+ delete metadata.TimeZone;
1162
+ }
1163
+
1164
+ // Locale
1165
+ if (metadata.Locale?.LocaleCode) {
1166
+ metadata.c__LocaleCode = metadata.Locale.LocaleCode;
1167
+ delete metadata.Locale;
1168
+ }
1169
+
1170
+ return metadata;
1171
+ }
1172
+ }
1173
+
1174
+ // Assign definition to static attributes
1175
+ User.definition = require('../MetadataTypeDefinitions').user;
1176
+
1177
+ module.exports = User;