mcdev 4.3.4 → 5.0.1

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