@mcp-consultant-tools/powerplatform-data 1.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.
- package/build/PowerPlatformService.d.ts +568 -0
- package/build/PowerPlatformService.d.ts.map +1 -0
- package/build/PowerPlatformService.js +2329 -0
- package/build/PowerPlatformService.js.map +1 -0
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +205 -0
- package/build/index.js.map +1 -0
- package/build/utils/bestPractices.d.ts +152 -0
- package/build/utils/bestPractices.d.ts.map +1 -0
- package/build/utils/bestPractices.js +338 -0
- package/build/utils/bestPractices.js.map +1 -0
- package/build/utils/iconManager.d.ts +84 -0
- package/build/utils/iconManager.d.ts.map +1 -0
- package/build/utils/iconManager.js +342 -0
- package/build/utils/iconManager.js.map +1 -0
- package/build/utils/prompt-templates.d.ts +9 -0
- package/build/utils/prompt-templates.d.ts.map +1 -0
- package/build/utils/prompt-templates.js +31 -0
- package/build/utils/prompt-templates.js.map +1 -0
- package/build/utils/rate-limiter.d.ts +108 -0
- package/build/utils/rate-limiter.d.ts.map +1 -0
- package/build/utils/rate-limiter.js +241 -0
- package/build/utils/rate-limiter.js.map +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,2329 @@
|
|
|
1
|
+
import { ConfidentialClientApplication } from '@azure/msal-node';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { bestPracticesValidator } from './utils/bestPractices.js';
|
|
4
|
+
import { iconManager } from './utils/iconManager.js';
|
|
5
|
+
import { auditLogger } from '@mcp-consultant-tools/core';
|
|
6
|
+
import { rateLimiter } from './utils/rate-limiter.js';
|
|
7
|
+
export class PowerPlatformService {
|
|
8
|
+
config;
|
|
9
|
+
msalClient;
|
|
10
|
+
accessToken = null;
|
|
11
|
+
tokenExpirationTime = 0;
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
// Initialize MSAL client
|
|
15
|
+
this.msalClient = new ConfidentialClientApplication({
|
|
16
|
+
auth: {
|
|
17
|
+
clientId: this.config.clientId,
|
|
18
|
+
clientSecret: this.config.clientSecret,
|
|
19
|
+
authority: `https://login.microsoftonline.com/${this.config.tenantId}`,
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Get an access token for the PowerPlatform API
|
|
25
|
+
*/
|
|
26
|
+
async getAccessToken() {
|
|
27
|
+
const currentTime = Date.now();
|
|
28
|
+
// If we have a token that isn't expired, return it
|
|
29
|
+
if (this.accessToken && this.tokenExpirationTime > currentTime) {
|
|
30
|
+
return this.accessToken;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
// Get a new token
|
|
34
|
+
const result = await this.msalClient.acquireTokenByClientCredential({
|
|
35
|
+
scopes: [`${this.config.organizationUrl}/.default`],
|
|
36
|
+
});
|
|
37
|
+
if (!result || !result.accessToken) {
|
|
38
|
+
throw new Error('Failed to acquire access token');
|
|
39
|
+
}
|
|
40
|
+
this.accessToken = result.accessToken;
|
|
41
|
+
// Set expiration time (subtract 5 minutes to refresh early)
|
|
42
|
+
if (result.expiresOn) {
|
|
43
|
+
this.tokenExpirationTime = result.expiresOn.getTime() - (5 * 60 * 1000);
|
|
44
|
+
}
|
|
45
|
+
return this.accessToken;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
console.error('Error acquiring access token:', error);
|
|
49
|
+
throw new Error('Authentication failed');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Make an authenticated request to the PowerPlatform API
|
|
54
|
+
* Extended to support all HTTP methods for write operations
|
|
55
|
+
*/
|
|
56
|
+
async makeRequest(endpoint, method = 'GET', data, additionalHeaders) {
|
|
57
|
+
try {
|
|
58
|
+
const token = await this.getAccessToken();
|
|
59
|
+
const headers = {
|
|
60
|
+
'Authorization': `Bearer ${token}`,
|
|
61
|
+
'Accept': 'application/json',
|
|
62
|
+
'OData-MaxVersion': '4.0',
|
|
63
|
+
'OData-Version': '4.0',
|
|
64
|
+
...additionalHeaders
|
|
65
|
+
};
|
|
66
|
+
// Add Content-Type for POST/PUT/PATCH requests
|
|
67
|
+
if (method !== 'GET' && method !== 'DELETE' && data) {
|
|
68
|
+
headers['Content-Type'] = 'application/json';
|
|
69
|
+
}
|
|
70
|
+
const response = await axios({
|
|
71
|
+
method,
|
|
72
|
+
url: `${this.config.organizationUrl}/${endpoint}`,
|
|
73
|
+
headers,
|
|
74
|
+
data
|
|
75
|
+
});
|
|
76
|
+
return response.data;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
const errorDetails = error.response?.data?.error || error.response?.data || error.message;
|
|
80
|
+
console.error('PowerPlatform API request failed:', {
|
|
81
|
+
endpoint,
|
|
82
|
+
method,
|
|
83
|
+
status: error.response?.status,
|
|
84
|
+
statusText: error.response?.statusText,
|
|
85
|
+
error: errorDetails
|
|
86
|
+
});
|
|
87
|
+
throw new Error(`PowerPlatform API request failed: ${error.message} - ${JSON.stringify(errorDetails)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Get metadata about an entity
|
|
92
|
+
* @param entityName The logical name of the entity
|
|
93
|
+
*/
|
|
94
|
+
async getEntityMetadata(entityName) {
|
|
95
|
+
const response = await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')`);
|
|
96
|
+
// Remove Privileges property if it exists
|
|
97
|
+
if (response && typeof response === 'object' && 'Privileges' in response) {
|
|
98
|
+
delete response.Privileges;
|
|
99
|
+
}
|
|
100
|
+
return response;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Get metadata about entity attributes/fields
|
|
104
|
+
* @param entityName The logical name of the entity
|
|
105
|
+
*/
|
|
106
|
+
async getEntityAttributes(entityName) {
|
|
107
|
+
const selectProperties = [
|
|
108
|
+
'LogicalName',
|
|
109
|
+
].join(',');
|
|
110
|
+
// Make the request to get attributes
|
|
111
|
+
const response = await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/Attributes?$select=${selectProperties}&$filter=AttributeType ne 'Virtual'`);
|
|
112
|
+
if (response && response.value) {
|
|
113
|
+
// First pass: Filter out attributes that end with 'yominame'
|
|
114
|
+
response.value = response.value.filter((attribute) => {
|
|
115
|
+
const logicalName = attribute.LogicalName || '';
|
|
116
|
+
return !logicalName.endsWith('yominame');
|
|
117
|
+
});
|
|
118
|
+
// Filter out attributes that end with 'name' if there is another attribute with the same name without the 'name' suffix
|
|
119
|
+
const baseNames = new Set();
|
|
120
|
+
const namesAttributes = new Map();
|
|
121
|
+
for (const attribute of response.value) {
|
|
122
|
+
const logicalName = attribute.LogicalName || '';
|
|
123
|
+
if (logicalName.endsWith('name') && logicalName.length > 4) {
|
|
124
|
+
const baseName = logicalName.slice(0, -4); // Remove 'name' suffix
|
|
125
|
+
namesAttributes.set(baseName, attribute);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
// This is a potential base attribute
|
|
129
|
+
baseNames.add(logicalName);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Find attributes to remove that match the pattern
|
|
133
|
+
const attributesToRemove = new Set();
|
|
134
|
+
for (const [baseName, nameAttribute] of namesAttributes.entries()) {
|
|
135
|
+
if (baseNames.has(baseName)) {
|
|
136
|
+
attributesToRemove.add(nameAttribute);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
response.value = response.value.filter(attribute => !attributesToRemove.has(attribute));
|
|
140
|
+
}
|
|
141
|
+
return response;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Get metadata about a specific entity attribute/field
|
|
145
|
+
* @param entityName The logical name of the entity
|
|
146
|
+
* @param attributeName The logical name of the attribute
|
|
147
|
+
*/
|
|
148
|
+
async getEntityAttribute(entityName, attributeName) {
|
|
149
|
+
return this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/Attributes(LogicalName='${attributeName}')`);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Get one-to-many relationships for an entity
|
|
153
|
+
* @param entityName The logical name of the entity
|
|
154
|
+
*/
|
|
155
|
+
async getEntityOneToManyRelationships(entityName) {
|
|
156
|
+
const selectProperties = [
|
|
157
|
+
'SchemaName',
|
|
158
|
+
'RelationshipType',
|
|
159
|
+
'ReferencedAttribute',
|
|
160
|
+
'ReferencedEntity',
|
|
161
|
+
'ReferencingAttribute',
|
|
162
|
+
'ReferencingEntity',
|
|
163
|
+
'ReferencedEntityNavigationPropertyName',
|
|
164
|
+
'ReferencingEntityNavigationPropertyName'
|
|
165
|
+
].join(',');
|
|
166
|
+
// Only filter by ReferencingAttribute in the OData query since startswith isn't supported
|
|
167
|
+
const response = await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/OneToManyRelationships?$select=${selectProperties}&$filter=ReferencingAttribute ne 'regardingobjectid'`);
|
|
168
|
+
// Filter the response to exclude relationships with ReferencingEntity starting with 'msdyn_' or 'adx_'
|
|
169
|
+
if (response && response.value) {
|
|
170
|
+
response.value = response.value.filter((relationship) => {
|
|
171
|
+
const referencingEntity = relationship.ReferencingEntity || '';
|
|
172
|
+
return !(referencingEntity.startsWith('msdyn_') || referencingEntity.startsWith('adx_'));
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return response;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Get many-to-many relationships for an entity
|
|
179
|
+
* @param entityName The logical name of the entity
|
|
180
|
+
*/
|
|
181
|
+
async getEntityManyToManyRelationships(entityName) {
|
|
182
|
+
const selectProperties = [
|
|
183
|
+
'SchemaName',
|
|
184
|
+
'RelationshipType',
|
|
185
|
+
'Entity1LogicalName',
|
|
186
|
+
'Entity2LogicalName',
|
|
187
|
+
'Entity1IntersectAttribute',
|
|
188
|
+
'Entity2IntersectAttribute',
|
|
189
|
+
'Entity1NavigationPropertyName',
|
|
190
|
+
'Entity2NavigationPropertyName'
|
|
191
|
+
].join(',');
|
|
192
|
+
return this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/ManyToManyRelationships?$select=${selectProperties}`);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Get all relationships (one-to-many and many-to-many) for an entity
|
|
196
|
+
* @param entityName The logical name of the entity
|
|
197
|
+
*/
|
|
198
|
+
async getEntityRelationships(entityName) {
|
|
199
|
+
const [oneToMany, manyToMany] = await Promise.all([
|
|
200
|
+
this.getEntityOneToManyRelationships(entityName),
|
|
201
|
+
this.getEntityManyToManyRelationships(entityName)
|
|
202
|
+
]);
|
|
203
|
+
return {
|
|
204
|
+
oneToMany,
|
|
205
|
+
manyToMany
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Get a global option set definition by name
|
|
210
|
+
* @param optionSetName The name of the global option set
|
|
211
|
+
* @returns The global option set definition
|
|
212
|
+
*/
|
|
213
|
+
async getGlobalOptionSet(optionSetName) {
|
|
214
|
+
return this.makeRequest(`api/data/v9.2/GlobalOptionSetDefinitions(Name='${optionSetName}')`);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Get a specific record by entity name (plural) and ID
|
|
218
|
+
* @param entityNamePlural The plural name of the entity (e.g., 'accounts', 'contacts')
|
|
219
|
+
* @param recordId The GUID of the record
|
|
220
|
+
* @returns The record data
|
|
221
|
+
*/
|
|
222
|
+
async getRecord(entityNamePlural, recordId) {
|
|
223
|
+
return this.makeRequest(`api/data/v9.2/${entityNamePlural}(${recordId})`);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Query records using entity name (plural) and a filter expression
|
|
227
|
+
* @param entityNamePlural The plural name of the entity (e.g., 'accounts', 'contacts')
|
|
228
|
+
* @param filter OData filter expression (e.g., "name eq 'test'")
|
|
229
|
+
* @param maxRecords Maximum number of records to retrieve (default: 50)
|
|
230
|
+
* @returns Filtered list of records
|
|
231
|
+
*/
|
|
232
|
+
async queryRecords(entityNamePlural, filter, maxRecords = 50) {
|
|
233
|
+
return this.makeRequest(`api/data/v9.2/${entityNamePlural}?$filter=${encodeURIComponent(filter)}&$top=${maxRecords}`);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Create a new record in Dataverse
|
|
237
|
+
* @param entityNamePlural The plural name of the entity (e.g., 'accounts', 'contacts')
|
|
238
|
+
* @param data Record data as JSON object (field names must match logical names)
|
|
239
|
+
* @returns Created record with ID and OData context
|
|
240
|
+
*/
|
|
241
|
+
async createRecord(entityNamePlural, data) {
|
|
242
|
+
const timer = auditLogger.startTimer();
|
|
243
|
+
try {
|
|
244
|
+
// Validate data is not empty
|
|
245
|
+
if (!data || Object.keys(data).length === 0) {
|
|
246
|
+
throw new Error('Record data cannot be empty');
|
|
247
|
+
}
|
|
248
|
+
// Make POST request to create record
|
|
249
|
+
const response = await this.makeRequest(`api/data/v9.2/${entityNamePlural}`, 'POST', data, {
|
|
250
|
+
'Prefer': 'return=representation', // Return the created record
|
|
251
|
+
});
|
|
252
|
+
// Audit logging
|
|
253
|
+
auditLogger.log({
|
|
254
|
+
operation: 'create-record',
|
|
255
|
+
operationType: 'CREATE',
|
|
256
|
+
componentType: 'Record',
|
|
257
|
+
componentName: entityNamePlural,
|
|
258
|
+
success: true,
|
|
259
|
+
executionTimeMs: timer(),
|
|
260
|
+
});
|
|
261
|
+
return response;
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
// Audit failed operation
|
|
265
|
+
auditLogger.log({
|
|
266
|
+
operation: 'create-record',
|
|
267
|
+
operationType: 'CREATE',
|
|
268
|
+
componentType: 'Record',
|
|
269
|
+
componentName: entityNamePlural,
|
|
270
|
+
success: false,
|
|
271
|
+
error: error.message,
|
|
272
|
+
executionTimeMs: timer(),
|
|
273
|
+
});
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Update an existing record in Dataverse
|
|
279
|
+
* @param entityNamePlural The plural name of the entity (e.g., 'accounts', 'contacts')
|
|
280
|
+
* @param recordId The GUID of the record to update
|
|
281
|
+
* @param data Partial record data to update (only fields being changed)
|
|
282
|
+
* @returns Updated record (if Prefer header used) or void
|
|
283
|
+
*/
|
|
284
|
+
async updateRecord(entityNamePlural, recordId, data) {
|
|
285
|
+
const timer = auditLogger.startTimer();
|
|
286
|
+
try {
|
|
287
|
+
// Validate data is not empty
|
|
288
|
+
if (!data || Object.keys(data).length === 0) {
|
|
289
|
+
throw new Error('Update data cannot be empty');
|
|
290
|
+
}
|
|
291
|
+
// Validate recordId is a valid GUID
|
|
292
|
+
const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
293
|
+
if (!guidRegex.test(recordId)) {
|
|
294
|
+
throw new Error(`Invalid record ID format: ${recordId}. Must be a valid GUID.`);
|
|
295
|
+
}
|
|
296
|
+
// Make PATCH request to update record
|
|
297
|
+
const response = await this.makeRequest(`api/data/v9.2/${entityNamePlural}(${recordId})`, 'PATCH', data, {
|
|
298
|
+
'Prefer': 'return=representation', // Return the updated record
|
|
299
|
+
});
|
|
300
|
+
// Audit logging
|
|
301
|
+
auditLogger.log({
|
|
302
|
+
operation: 'update-record',
|
|
303
|
+
operationType: 'UPDATE',
|
|
304
|
+
componentType: 'Record',
|
|
305
|
+
componentName: `${entityNamePlural}(${recordId})`,
|
|
306
|
+
success: true,
|
|
307
|
+
executionTimeMs: timer(),
|
|
308
|
+
});
|
|
309
|
+
return response;
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
// Audit failed operation
|
|
313
|
+
auditLogger.log({
|
|
314
|
+
operation: 'update-record',
|
|
315
|
+
operationType: 'UPDATE',
|
|
316
|
+
componentType: 'Record',
|
|
317
|
+
componentName: `${entityNamePlural}(${recordId})`,
|
|
318
|
+
success: false,
|
|
319
|
+
error: error.message,
|
|
320
|
+
executionTimeMs: timer(),
|
|
321
|
+
});
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Delete a record from Dataverse
|
|
327
|
+
* @param entityNamePlural The plural name of the entity (e.g., 'accounts', 'contacts')
|
|
328
|
+
* @param recordId The GUID of the record to delete
|
|
329
|
+
* @returns Void (successful deletion returns 204 No Content)
|
|
330
|
+
*/
|
|
331
|
+
async deleteRecord(entityNamePlural, recordId) {
|
|
332
|
+
const timer = auditLogger.startTimer();
|
|
333
|
+
try {
|
|
334
|
+
// Validate recordId is a valid GUID
|
|
335
|
+
const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
336
|
+
if (!guidRegex.test(recordId)) {
|
|
337
|
+
throw new Error(`Invalid record ID format: ${recordId}. Must be a valid GUID.`);
|
|
338
|
+
}
|
|
339
|
+
// Make DELETE request
|
|
340
|
+
await this.makeRequest(`api/data/v9.2/${entityNamePlural}(${recordId})`, 'DELETE');
|
|
341
|
+
// Audit logging
|
|
342
|
+
auditLogger.log({
|
|
343
|
+
operation: 'delete-record',
|
|
344
|
+
operationType: 'DELETE',
|
|
345
|
+
componentType: 'Record',
|
|
346
|
+
componentName: `${entityNamePlural}(${recordId})`,
|
|
347
|
+
success: true,
|
|
348
|
+
executionTimeMs: timer(),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
// Audit failed operation
|
|
353
|
+
auditLogger.log({
|
|
354
|
+
operation: 'delete-record',
|
|
355
|
+
operationType: 'DELETE',
|
|
356
|
+
componentType: 'Record',
|
|
357
|
+
componentName: `${entityNamePlural}(${recordId})`,
|
|
358
|
+
success: false,
|
|
359
|
+
error: error.message,
|
|
360
|
+
executionTimeMs: timer(),
|
|
361
|
+
});
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Get all plugin assemblies in the environment
|
|
367
|
+
* @param includeManaged Include managed assemblies (default: false)
|
|
368
|
+
* @param maxRecords Maximum number of assemblies to return (default: 100)
|
|
369
|
+
* @returns List of plugin assemblies with basic information
|
|
370
|
+
*/
|
|
371
|
+
async getPluginAssemblies(includeManaged = false, maxRecords = 100) {
|
|
372
|
+
const managedFilter = includeManaged ? '' : '$filter=ismanaged eq false&';
|
|
373
|
+
const assemblies = await this.makeRequest(`api/data/v9.2/pluginassemblies?${managedFilter}$select=pluginassemblyid,name,version,culture,publickeytoken,isolationmode,sourcetype,major,minor,createdon,modifiedon,ismanaged,ishidden&$expand=modifiedby($select=fullname)&$orderby=name&$top=${maxRecords}`);
|
|
374
|
+
// Filter out hidden assemblies and format the results with more readable properties
|
|
375
|
+
// Note: ishidden is a ManagedProperty object with a Value property
|
|
376
|
+
const formattedAssemblies = assemblies.value
|
|
377
|
+
.filter((assembly) => {
|
|
378
|
+
const isHidden = assembly.ishidden?.Value !== undefined ? assembly.ishidden.Value : assembly.ishidden;
|
|
379
|
+
return !isHidden;
|
|
380
|
+
})
|
|
381
|
+
.map((assembly) => ({
|
|
382
|
+
pluginassemblyid: assembly.pluginassemblyid,
|
|
383
|
+
name: assembly.name,
|
|
384
|
+
version: assembly.version,
|
|
385
|
+
isolationMode: assembly.isolationmode === 1 ? 'None' : assembly.isolationmode === 2 ? 'Sandbox' : 'External',
|
|
386
|
+
isManaged: assembly.ismanaged,
|
|
387
|
+
modifiedOn: assembly.modifiedon,
|
|
388
|
+
modifiedBy: assembly.modifiedby?.fullname,
|
|
389
|
+
major: assembly.major,
|
|
390
|
+
minor: assembly.minor
|
|
391
|
+
}));
|
|
392
|
+
return {
|
|
393
|
+
totalCount: formattedAssemblies.length,
|
|
394
|
+
assemblies: formattedAssemblies
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Get a plugin assembly by name with all related plugin types, steps, and images
|
|
399
|
+
* @param assemblyName The name of the plugin assembly
|
|
400
|
+
* @param includeDisabled Include disabled steps (default: false)
|
|
401
|
+
* @returns Complete plugin assembly information with validation
|
|
402
|
+
*/
|
|
403
|
+
async getPluginAssemblyComplete(assemblyName, includeDisabled = false) {
|
|
404
|
+
// Get the plugin assembly (excluding content_binary which is large and not useful for review)
|
|
405
|
+
const assemblies = await this.makeRequest(`api/data/v9.2/pluginassemblies?$filter=name eq '${assemblyName}'&$select=pluginassemblyid,name,version,culture,publickeytoken,isolationmode,sourcetype,major,minor,createdon,modifiedon,ismanaged,ishidden,description&$expand=modifiedby($select=fullname)`);
|
|
406
|
+
if (!assemblies.value || assemblies.value.length === 0) {
|
|
407
|
+
throw new Error(`Plugin assembly '${assemblyName}' not found`);
|
|
408
|
+
}
|
|
409
|
+
const assembly = assemblies.value[0];
|
|
410
|
+
const assemblyId = assembly.pluginassemblyid;
|
|
411
|
+
// Get plugin types for this assembly
|
|
412
|
+
const pluginTypes = await this.makeRequest(`api/data/v9.2/plugintypes?$filter=_pluginassemblyid_value eq ${assemblyId}&$select=plugintypeid,typename,friendlyname,name,assemblyname,description,workflowactivitygroupname`);
|
|
413
|
+
// Get all steps for each plugin type
|
|
414
|
+
const pluginTypeIds = pluginTypes.value.map((pt) => pt.plugintypeid);
|
|
415
|
+
let allSteps = [];
|
|
416
|
+
if (pluginTypeIds.length > 0) {
|
|
417
|
+
const statusFilter = includeDisabled ? '' : ' and statuscode eq 1';
|
|
418
|
+
// Build filter for all plugin type IDs
|
|
419
|
+
const typeFilter = pluginTypeIds.map((id) => `_plugintypeid_value eq ${id}`).join(' or ');
|
|
420
|
+
const steps = await this.makeRequest(`api/data/v9.2/sdkmessageprocessingsteps?$filter=(${typeFilter})${statusFilter}&$select=sdkmessageprocessingstepid,name,stage,mode,rank,statuscode,asyncautodelete,filteringattributes,supporteddeployment,configuration,description,invocationsource,_plugintypeid_value,_sdkmessagefilterid_value,_impersonatinguserid_value,_eventhandler_value&$expand=sdkmessageid($select=name),plugintypeid($select=typename),impersonatinguserid($select=fullname),modifiedby($select=fullname),sdkmessagefilterid($select=primaryobjecttypecode)&$orderby=stage,rank`);
|
|
421
|
+
allSteps = steps.value;
|
|
422
|
+
}
|
|
423
|
+
// Get all images for these steps
|
|
424
|
+
const stepIds = allSteps.map((s) => s.sdkmessageprocessingstepid);
|
|
425
|
+
let allImages = [];
|
|
426
|
+
if (stepIds.length > 0) {
|
|
427
|
+
// Build filter for all step IDs
|
|
428
|
+
const imageFilter = stepIds.map((id) => `_sdkmessageprocessingstepid_value eq ${id}`).join(' or ');
|
|
429
|
+
const images = await this.makeRequest(`api/data/v9.2/sdkmessageprocessingstepimages?$filter=${imageFilter}&$select=sdkmessageprocessingstepimageid,name,imagetype,messagepropertyname,entityalias,attributes,_sdkmessageprocessingstepid_value`);
|
|
430
|
+
allImages = images.value;
|
|
431
|
+
}
|
|
432
|
+
// Attach images to their respective steps
|
|
433
|
+
const stepsWithImages = allSteps.map((step) => ({
|
|
434
|
+
...step,
|
|
435
|
+
images: allImages.filter((img) => img._sdkmessageprocessingstepid_value === step.sdkmessageprocessingstepid)
|
|
436
|
+
}));
|
|
437
|
+
// Validation checks
|
|
438
|
+
const validation = {
|
|
439
|
+
hasDisabledSteps: allSteps.some((s) => s.statuscode !== 1),
|
|
440
|
+
hasAsyncSteps: allSteps.some((s) => s.mode === 1),
|
|
441
|
+
hasSyncSteps: allSteps.some((s) => s.mode === 0),
|
|
442
|
+
stepsWithoutFilteringAttributes: stepsWithImages
|
|
443
|
+
.filter((s) => (s.sdkmessageid?.name === 'Update' || s.sdkmessageid?.name === 'Delete') && !s.filteringattributes)
|
|
444
|
+
.map((s) => s.name),
|
|
445
|
+
stepsWithoutImages: stepsWithImages
|
|
446
|
+
.filter((s) => s.images.length === 0 && (s.sdkmessageid?.name === 'Update' || s.sdkmessageid?.name === 'Delete'))
|
|
447
|
+
.map((s) => s.name),
|
|
448
|
+
potentialIssues: []
|
|
449
|
+
};
|
|
450
|
+
// Add potential issues
|
|
451
|
+
if (validation.stepsWithoutFilteringAttributes.length > 0) {
|
|
452
|
+
validation.potentialIssues.push(`${validation.stepsWithoutFilteringAttributes.length} Update/Delete steps without filtering attributes (performance concern)`);
|
|
453
|
+
}
|
|
454
|
+
if (validation.stepsWithoutImages.length > 0) {
|
|
455
|
+
validation.potentialIssues.push(`${validation.stepsWithoutImages.length} Update/Delete steps without images (may need entity data)`);
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
assembly,
|
|
459
|
+
pluginTypes: pluginTypes.value,
|
|
460
|
+
steps: stepsWithImages,
|
|
461
|
+
validation
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Get all plugins that execute on a specific entity, organized by message and execution order
|
|
466
|
+
* @param entityName The logical name of the entity
|
|
467
|
+
* @param messageFilter Optional filter by message name (e.g., "Create", "Update")
|
|
468
|
+
* @param includeDisabled Include disabled steps (default: false)
|
|
469
|
+
* @returns Complete plugin pipeline for the entity
|
|
470
|
+
*/
|
|
471
|
+
async getEntityPluginPipeline(entityName, messageFilter, includeDisabled = false) {
|
|
472
|
+
const statusFilter = includeDisabled ? '' : ' and statuscode eq 1';
|
|
473
|
+
const msgFilter = messageFilter ? ` and sdkmessageid/name eq '${messageFilter}'` : '';
|
|
474
|
+
// Get all steps for this entity
|
|
475
|
+
const steps = await this.makeRequest(`api/data/v9.2/sdkmessageprocessingsteps?$filter=sdkmessagefilterid/primaryobjecttypecode eq '${entityName}'${statusFilter}${msgFilter}&$select=sdkmessageprocessingstepid,name,stage,mode,rank,statuscode,asyncautodelete,filteringattributes,supporteddeployment,configuration,description,_plugintypeid_value,_sdkmessagefilterid_value,_impersonatinguserid_value&$expand=sdkmessageid($select=name),plugintypeid($select=typename),impersonatinguserid($select=fullname),sdkmessagefilterid($select=primaryobjecttypecode)&$orderby=stage,rank`);
|
|
476
|
+
// Get assembly information for each plugin type (filter out nulls)
|
|
477
|
+
const pluginTypeIds = [...new Set(steps.value.map((s) => s._plugintypeid_value).filter((id) => id != null))];
|
|
478
|
+
const assemblyMap = new Map();
|
|
479
|
+
for (const typeId of pluginTypeIds) {
|
|
480
|
+
const pluginType = await this.makeRequest(`api/data/v9.2/plugintypes(${typeId})?$expand=pluginassemblyid($select=name,version)`);
|
|
481
|
+
assemblyMap.set(typeId, pluginType.pluginassemblyid);
|
|
482
|
+
}
|
|
483
|
+
// Get images for all steps
|
|
484
|
+
const stepIds = steps.value.map((s) => s.sdkmessageprocessingstepid);
|
|
485
|
+
let allImages = [];
|
|
486
|
+
if (stepIds.length > 0) {
|
|
487
|
+
const imageFilter = stepIds.map((id) => `_sdkmessageprocessingstepid_value eq ${id}`).join(' or ');
|
|
488
|
+
const images = await this.makeRequest(`api/data/v9.2/sdkmessageprocessingstepimages?$filter=${imageFilter}&$select=sdkmessageprocessingstepimageid,name,imagetype,messagepropertyname,entityalias,attributes,_sdkmessageprocessingstepid_value`);
|
|
489
|
+
allImages = images.value;
|
|
490
|
+
}
|
|
491
|
+
// Format steps with all information
|
|
492
|
+
const formattedSteps = steps.value.map((step) => {
|
|
493
|
+
const assembly = assemblyMap.get(step._plugintypeid_value);
|
|
494
|
+
const images = allImages.filter((img) => img._sdkmessageprocessingstepid_value === step.sdkmessageprocessingstepid);
|
|
495
|
+
return {
|
|
496
|
+
sdkmessageprocessingstepid: step.sdkmessageprocessingstepid,
|
|
497
|
+
name: step.name,
|
|
498
|
+
stage: step.stage,
|
|
499
|
+
stageName: step.stage === 10 ? 'PreValidation' : step.stage === 20 ? 'PreOperation' : 'PostOperation',
|
|
500
|
+
mode: step.mode,
|
|
501
|
+
modeName: step.mode === 0 ? 'Synchronous' : 'Asynchronous',
|
|
502
|
+
rank: step.rank,
|
|
503
|
+
message: step.sdkmessageid?.name,
|
|
504
|
+
pluginType: step.plugintypeid?.typename,
|
|
505
|
+
assemblyName: assembly?.name,
|
|
506
|
+
assemblyVersion: assembly?.version,
|
|
507
|
+
filteringAttributes: step.filteringattributes ? step.filteringattributes.split(',') : [],
|
|
508
|
+
statuscode: step.statuscode,
|
|
509
|
+
enabled: step.statuscode === 1,
|
|
510
|
+
deployment: step.supporteddeployment === 0 ? 'Server' : step.supporteddeployment === 1 ? 'Offline' : 'Both',
|
|
511
|
+
impersonatingUser: step.impersonatinguserid?.fullname,
|
|
512
|
+
hasPreImage: images.some((img) => img.imagetype === 0 || img.imagetype === 2),
|
|
513
|
+
hasPostImage: images.some((img) => img.imagetype === 1 || img.imagetype === 2),
|
|
514
|
+
images: images
|
|
515
|
+
};
|
|
516
|
+
});
|
|
517
|
+
// Organize by message
|
|
518
|
+
const messageGroups = new Map();
|
|
519
|
+
formattedSteps.forEach((step) => {
|
|
520
|
+
if (!messageGroups.has(step.message)) {
|
|
521
|
+
messageGroups.set(step.message, {
|
|
522
|
+
messageName: step.message,
|
|
523
|
+
stages: {
|
|
524
|
+
preValidation: [],
|
|
525
|
+
preOperation: [],
|
|
526
|
+
postOperation: []
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
const msg = messageGroups.get(step.message);
|
|
531
|
+
if (step.stage === 10)
|
|
532
|
+
msg.stages.preValidation.push(step);
|
|
533
|
+
else if (step.stage === 20)
|
|
534
|
+
msg.stages.preOperation.push(step);
|
|
535
|
+
else if (step.stage === 40)
|
|
536
|
+
msg.stages.postOperation.push(step);
|
|
537
|
+
});
|
|
538
|
+
return {
|
|
539
|
+
entity: entityName,
|
|
540
|
+
messages: Array.from(messageGroups.values()),
|
|
541
|
+
steps: formattedSteps,
|
|
542
|
+
executionOrder: formattedSteps.map((s) => s.name)
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Get plugin trace logs with filtering
|
|
547
|
+
* @param options Filtering options for trace logs
|
|
548
|
+
* @returns Filtered trace logs with parsed exception details
|
|
549
|
+
*/
|
|
550
|
+
async getPluginTraceLogs(options) {
|
|
551
|
+
const { entityName, messageName, correlationId, pluginStepId, exceptionOnly = false, hoursBack = 24, maxRecords = 50 } = options;
|
|
552
|
+
// Build filter
|
|
553
|
+
const filters = [];
|
|
554
|
+
// Date filter
|
|
555
|
+
const dateThreshold = new Date();
|
|
556
|
+
dateThreshold.setHours(dateThreshold.getHours() - hoursBack);
|
|
557
|
+
filters.push(`createdon gt ${dateThreshold.toISOString()}`);
|
|
558
|
+
if (entityName)
|
|
559
|
+
filters.push(`primaryentity eq '${entityName}'`);
|
|
560
|
+
if (messageName)
|
|
561
|
+
filters.push(`messagename eq '${messageName}'`);
|
|
562
|
+
if (correlationId)
|
|
563
|
+
filters.push(`correlationid eq '${correlationId}'`);
|
|
564
|
+
if (pluginStepId)
|
|
565
|
+
filters.push(`_sdkmessageprocessingstepid_value eq ${pluginStepId}`);
|
|
566
|
+
if (exceptionOnly)
|
|
567
|
+
filters.push(`exceptiondetails ne null`);
|
|
568
|
+
const filterString = filters.join(' and ');
|
|
569
|
+
const logs = await this.makeRequest(`api/data/v9.2/plugintracelogs?$filter=${filterString}&$orderby=createdon desc&$top=${maxRecords}`);
|
|
570
|
+
// Parse logs for better readability
|
|
571
|
+
const parsedLogs = logs.value.map((log) => ({
|
|
572
|
+
...log,
|
|
573
|
+
modeName: log.mode === 0 ? 'Synchronous' : 'Asynchronous',
|
|
574
|
+
operationTypeName: this.getOperationTypeName(log.operationtype),
|
|
575
|
+
parsed: {
|
|
576
|
+
hasException: !!log.exceptiondetails,
|
|
577
|
+
exceptionType: log.exceptiondetails ? this.extractExceptionType(log.exceptiondetails) : null,
|
|
578
|
+
exceptionMessage: log.exceptiondetails ? this.extractExceptionMessage(log.exceptiondetails) : null,
|
|
579
|
+
stackTrace: log.exceptiondetails
|
|
580
|
+
}
|
|
581
|
+
}));
|
|
582
|
+
return {
|
|
583
|
+
totalCount: parsedLogs.length,
|
|
584
|
+
logs: parsedLogs
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
// Helper methods for trace log parsing
|
|
588
|
+
getOperationTypeName(operationType) {
|
|
589
|
+
const types = {
|
|
590
|
+
0: 'None',
|
|
591
|
+
1: 'Create',
|
|
592
|
+
2: 'Update',
|
|
593
|
+
3: 'Delete',
|
|
594
|
+
4: 'Retrieve',
|
|
595
|
+
5: 'RetrieveMultiple',
|
|
596
|
+
6: 'Associate',
|
|
597
|
+
7: 'Disassociate'
|
|
598
|
+
};
|
|
599
|
+
return types[operationType] || 'Unknown';
|
|
600
|
+
}
|
|
601
|
+
extractExceptionType(exceptionDetails) {
|
|
602
|
+
const match = exceptionDetails.match(/^([^:]+):/);
|
|
603
|
+
return match ? match[1].trim() : null;
|
|
604
|
+
}
|
|
605
|
+
extractExceptionMessage(exceptionDetails) {
|
|
606
|
+
const lines = exceptionDetails.split('\n');
|
|
607
|
+
if (lines.length > 0) {
|
|
608
|
+
const firstLine = lines[0];
|
|
609
|
+
const colonIndex = firstLine.indexOf(':');
|
|
610
|
+
if (colonIndex > 0) {
|
|
611
|
+
return firstLine.substring(colonIndex + 1).trim();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Get all Power Automate flows (cloud flows) in the environment
|
|
618
|
+
* @param activeOnly Only return activated flows (default: false)
|
|
619
|
+
* @param maxRecords Maximum number of flows to return (default: 100)
|
|
620
|
+
* @returns List of Power Automate flows with basic information
|
|
621
|
+
*/
|
|
622
|
+
async getFlows(activeOnly = false, maxRecords = 100) {
|
|
623
|
+
// Category 5 = Modern Flow (Power Automate cloud flows)
|
|
624
|
+
// StateCode: 0=Draft, 1=Activated, 2=Suspended
|
|
625
|
+
// Type: 1=Definition, 2=Activation
|
|
626
|
+
const stateFilter = activeOnly ? ' and statecode eq 1' : '';
|
|
627
|
+
const flows = await this.makeRequest(`api/data/v9.2/workflows?$filter=category eq 5${stateFilter}&$select=workflowid,name,statecode,statuscode,description,createdon,modifiedon,type,ismanaged,iscrmuiworkflow,primaryentity,clientdata&$expand=modifiedby($select=fullname)&$orderby=modifiedon desc&$top=${maxRecords}`);
|
|
628
|
+
// Format the results for better readability
|
|
629
|
+
const formattedFlows = flows.value.map((flow) => ({
|
|
630
|
+
workflowid: flow.workflowid,
|
|
631
|
+
name: flow.name,
|
|
632
|
+
description: flow.description,
|
|
633
|
+
state: flow.statecode === 0 ? 'Draft' : flow.statecode === 1 ? 'Activated' : 'Suspended',
|
|
634
|
+
statecode: flow.statecode,
|
|
635
|
+
statuscode: flow.statuscode,
|
|
636
|
+
type: flow.type === 1 ? 'Definition' : flow.type === 2 ? 'Activation' : 'Template',
|
|
637
|
+
primaryEntity: flow.primaryentity,
|
|
638
|
+
isManaged: flow.ismanaged,
|
|
639
|
+
ownerId: flow._ownerid_value,
|
|
640
|
+
modifiedOn: flow.modifiedon,
|
|
641
|
+
modifiedBy: flow.modifiedby?.fullname,
|
|
642
|
+
createdOn: flow.createdon,
|
|
643
|
+
hasDefinition: !!flow.clientdata
|
|
644
|
+
}));
|
|
645
|
+
return {
|
|
646
|
+
totalCount: formattedFlows.length,
|
|
647
|
+
flows: formattedFlows
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Get a specific Power Automate flow with its complete definition
|
|
652
|
+
* @param flowId The GUID of the flow (workflowid)
|
|
653
|
+
* @returns Complete flow information including the flow definition JSON
|
|
654
|
+
*/
|
|
655
|
+
async getFlowDefinition(flowId) {
|
|
656
|
+
const flow = await this.makeRequest(`api/data/v9.2/workflows(${flowId})?$select=workflowid,name,statecode,statuscode,description,createdon,modifiedon,type,category,ismanaged,iscrmuiworkflow,primaryentity,clientdata,xaml&$expand=modifiedby($select=fullname),createdby($select=fullname)`);
|
|
657
|
+
// Parse the clientdata (flow definition) if it exists
|
|
658
|
+
let flowDefinition = null;
|
|
659
|
+
if (flow.clientdata) {
|
|
660
|
+
try {
|
|
661
|
+
flowDefinition = JSON.parse(flow.clientdata);
|
|
662
|
+
}
|
|
663
|
+
catch (error) {
|
|
664
|
+
console.error('Failed to parse flow definition JSON:', error);
|
|
665
|
+
flowDefinition = { parseError: 'Failed to parse flow definition', raw: flow.clientdata };
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
workflowid: flow.workflowid,
|
|
670
|
+
name: flow.name,
|
|
671
|
+
description: flow.description,
|
|
672
|
+
state: flow.statecode === 0 ? 'Draft' : flow.statecode === 1 ? 'Activated' : 'Suspended',
|
|
673
|
+
statecode: flow.statecode,
|
|
674
|
+
statuscode: flow.statuscode,
|
|
675
|
+
type: flow.type === 1 ? 'Definition' : flow.type === 2 ? 'Activation' : 'Template',
|
|
676
|
+
category: flow.category,
|
|
677
|
+
primaryEntity: flow.primaryentity,
|
|
678
|
+
isManaged: flow.ismanaged,
|
|
679
|
+
ownerId: flow._ownerid_value,
|
|
680
|
+
createdOn: flow.createdon,
|
|
681
|
+
createdBy: flow.createdby?.fullname,
|
|
682
|
+
modifiedOn: flow.modifiedon,
|
|
683
|
+
modifiedBy: flow.modifiedby?.fullname,
|
|
684
|
+
flowDefinition: flowDefinition
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Get flow run history for a specific Power Automate flow
|
|
689
|
+
* @param flowId The GUID of the flow (workflowid)
|
|
690
|
+
* @param maxRecords Maximum number of runs to return (default: 100)
|
|
691
|
+
* @returns List of flow runs with status, start time, duration, and error details
|
|
692
|
+
*/
|
|
693
|
+
async getFlowRuns(flowId, maxRecords = 100) {
|
|
694
|
+
// Flow runs are stored in the flowruns entity (not flowsession)
|
|
695
|
+
// Status: "Succeeded", "Failed", "Faulted", "TimedOut", "Cancelled", "Running", etc.
|
|
696
|
+
const flowRuns = await this.makeRequest(`api/data/v9.2/flowruns?$filter=_workflow_value eq ${flowId}&$select=flowrunid,name,status,starttime,endtime,duration,errormessage,errorcode,triggertype&$orderby=starttime desc&$top=${maxRecords}`);
|
|
697
|
+
// Format the results for better readability
|
|
698
|
+
const formattedRuns = flowRuns.value.map((run) => {
|
|
699
|
+
// Parse error message if it's JSON
|
|
700
|
+
let parsedError = run.errormessage;
|
|
701
|
+
if (run.errormessage) {
|
|
702
|
+
try {
|
|
703
|
+
parsedError = JSON.parse(run.errormessage);
|
|
704
|
+
}
|
|
705
|
+
catch (e) {
|
|
706
|
+
// Keep as string if not valid JSON
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return {
|
|
710
|
+
flowrunid: run.flowrunid,
|
|
711
|
+
name: run.name,
|
|
712
|
+
status: run.status,
|
|
713
|
+
startedOn: run.starttime,
|
|
714
|
+
completedOn: run.endtime,
|
|
715
|
+
duration: run.duration,
|
|
716
|
+
errorMessage: parsedError || null,
|
|
717
|
+
errorCode: run.errorcode || null,
|
|
718
|
+
triggerType: run.triggertype || null
|
|
719
|
+
};
|
|
720
|
+
});
|
|
721
|
+
return {
|
|
722
|
+
flowId: flowId,
|
|
723
|
+
totalCount: formattedRuns.length,
|
|
724
|
+
runs: formattedRuns
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Get all classic Dynamics workflows in the environment
|
|
729
|
+
* @param activeOnly Only return activated workflows (default: false)
|
|
730
|
+
* @param maxRecords Maximum number of workflows to return (default: 100)
|
|
731
|
+
* @returns List of classic workflows with basic information
|
|
732
|
+
*/
|
|
733
|
+
async getWorkflows(activeOnly = false, maxRecords = 100) {
|
|
734
|
+
// Category 0 = Classic Workflow
|
|
735
|
+
// StateCode: 0=Draft, 1=Activated, 2=Suspended
|
|
736
|
+
// Type: 1=Definition, 2=Activation
|
|
737
|
+
const stateFilter = activeOnly ? ' and statecode eq 1' : '';
|
|
738
|
+
const workflows = await this.makeRequest(`api/data/v9.2/workflows?$filter=category eq 0${stateFilter}&$select=workflowid,name,statecode,statuscode,description,createdon,modifiedon,type,ismanaged,iscrmuiworkflow,primaryentity,mode,subprocess,ondemand,triggeroncreate,triggerondelete,syncworkflowlogonfailure&$expand=ownerid($select=fullname),modifiedby($select=fullname)&$orderby=modifiedon desc&$top=${maxRecords}`);
|
|
739
|
+
// Format the results for better readability
|
|
740
|
+
const formattedWorkflows = workflows.value.map((workflow) => ({
|
|
741
|
+
workflowid: workflow.workflowid,
|
|
742
|
+
name: workflow.name,
|
|
743
|
+
description: workflow.description,
|
|
744
|
+
state: workflow.statecode === 0 ? 'Draft' : workflow.statecode === 1 ? 'Activated' : 'Suspended',
|
|
745
|
+
statecode: workflow.statecode,
|
|
746
|
+
statuscode: workflow.statuscode,
|
|
747
|
+
type: workflow.type === 1 ? 'Definition' : workflow.type === 2 ? 'Activation' : 'Template',
|
|
748
|
+
mode: workflow.mode === 0 ? 'Background' : 'Real-time',
|
|
749
|
+
primaryEntity: workflow.primaryentity,
|
|
750
|
+
isManaged: workflow.ismanaged,
|
|
751
|
+
isOnDemand: workflow.ondemand,
|
|
752
|
+
triggerOnCreate: workflow.triggeroncreate,
|
|
753
|
+
triggerOnDelete: workflow.triggerondelete,
|
|
754
|
+
isSubprocess: workflow.subprocess,
|
|
755
|
+
owner: workflow.ownerid?.fullname,
|
|
756
|
+
modifiedOn: workflow.modifiedon,
|
|
757
|
+
modifiedBy: workflow.modifiedby?.fullname,
|
|
758
|
+
createdOn: workflow.createdon
|
|
759
|
+
}));
|
|
760
|
+
return {
|
|
761
|
+
totalCount: formattedWorkflows.length,
|
|
762
|
+
workflows: formattedWorkflows
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Get a specific classic workflow with its complete XAML definition
|
|
767
|
+
* @param workflowId The GUID of the workflow (workflowid)
|
|
768
|
+
* @returns Complete workflow information including the XAML definition
|
|
769
|
+
*/
|
|
770
|
+
async getWorkflowDefinition(workflowId) {
|
|
771
|
+
const workflow = await this.makeRequest(`api/data/v9.2/workflows(${workflowId})?$select=workflowid,name,statecode,statuscode,description,createdon,modifiedon,type,category,ismanaged,iscrmuiworkflow,primaryentity,mode,subprocess,ondemand,triggeroncreate,triggerondelete,triggeronupdateattributelist,syncworkflowlogonfailure,xaml&$expand=ownerid($select=fullname),modifiedby($select=fullname),createdby($select=fullname)`);
|
|
772
|
+
return {
|
|
773
|
+
workflowid: workflow.workflowid,
|
|
774
|
+
name: workflow.name,
|
|
775
|
+
description: workflow.description,
|
|
776
|
+
state: workflow.statecode === 0 ? 'Draft' : workflow.statecode === 1 ? 'Activated' : 'Suspended',
|
|
777
|
+
statecode: workflow.statecode,
|
|
778
|
+
statuscode: workflow.statuscode,
|
|
779
|
+
type: workflow.type === 1 ? 'Definition' : workflow.type === 2 ? 'Activation' : 'Template',
|
|
780
|
+
category: workflow.category,
|
|
781
|
+
mode: workflow.mode === 0 ? 'Background' : 'Real-time',
|
|
782
|
+
primaryEntity: workflow.primaryentity,
|
|
783
|
+
isManaged: workflow.ismanaged,
|
|
784
|
+
isOnDemand: workflow.ondemand,
|
|
785
|
+
triggerOnCreate: workflow.triggeroncreate,
|
|
786
|
+
triggerOnDelete: workflow.triggerondelete,
|
|
787
|
+
triggerOnUpdateAttributes: workflow.triggeronupdateattributelist ? workflow.triggeronupdateattributelist.split(',') : [],
|
|
788
|
+
isSubprocess: workflow.subprocess,
|
|
789
|
+
syncWorkflowLogOnFailure: workflow.syncworkflowlogonfailure,
|
|
790
|
+
owner: workflow.ownerid?.fullname,
|
|
791
|
+
createdOn: workflow.createdon,
|
|
792
|
+
createdBy: workflow.createdby?.fullname,
|
|
793
|
+
modifiedOn: workflow.modifiedon,
|
|
794
|
+
modifiedBy: workflow.modifiedby?.fullname,
|
|
795
|
+
xaml: workflow.xaml
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Get all business rules in the environment
|
|
800
|
+
* @param activeOnly Only return activated business rules (default: false)
|
|
801
|
+
* @param maxRecords Maximum number of business rules to return (default: 100)
|
|
802
|
+
* @returns List of business rules with basic information
|
|
803
|
+
*/
|
|
804
|
+
async getBusinessRules(activeOnly = false, maxRecords = 100) {
|
|
805
|
+
// Category 2 = Business Rule
|
|
806
|
+
// StateCode: 0=Draft, 1=Activated, 2=Suspended
|
|
807
|
+
// Type: 1=Definition
|
|
808
|
+
const stateFilter = activeOnly ? ' and statecode eq 1' : '';
|
|
809
|
+
const businessRules = await this.makeRequest(`api/data/v9.2/workflows?$filter=category eq 2${stateFilter}&$select=workflowid,name,statecode,statuscode,description,createdon,modifiedon,type,ismanaged,primaryentity&$expand=ownerid($select=fullname),modifiedby($select=fullname)&$orderby=modifiedon desc&$top=${maxRecords}`);
|
|
810
|
+
// Format the results for better readability
|
|
811
|
+
const formattedBusinessRules = businessRules.value.map((rule) => ({
|
|
812
|
+
workflowid: rule.workflowid,
|
|
813
|
+
name: rule.name,
|
|
814
|
+
description: rule.description,
|
|
815
|
+
state: rule.statecode === 0 ? 'Draft' : rule.statecode === 1 ? 'Activated' : 'Suspended',
|
|
816
|
+
statecode: rule.statecode,
|
|
817
|
+
statuscode: rule.statuscode,
|
|
818
|
+
type: rule.type === 1 ? 'Definition' : rule.type === 2 ? 'Activation' : 'Template',
|
|
819
|
+
primaryEntity: rule.primaryentity,
|
|
820
|
+
isManaged: rule.ismanaged,
|
|
821
|
+
owner: rule.ownerid?.fullname,
|
|
822
|
+
modifiedOn: rule.modifiedon,
|
|
823
|
+
modifiedBy: rule.modifiedby?.fullname,
|
|
824
|
+
createdOn: rule.createdon
|
|
825
|
+
}));
|
|
826
|
+
return {
|
|
827
|
+
totalCount: formattedBusinessRules.length,
|
|
828
|
+
businessRules: formattedBusinessRules
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Get a specific business rule with its complete XAML definition
|
|
833
|
+
* @param workflowId The GUID of the business rule (workflowid)
|
|
834
|
+
* @returns Complete business rule information including the XAML definition
|
|
835
|
+
*/
|
|
836
|
+
async getBusinessRule(workflowId) {
|
|
837
|
+
const businessRule = await this.makeRequest(`api/data/v9.2/workflows(${workflowId})?$select=workflowid,name,statecode,statuscode,description,createdon,modifiedon,type,category,ismanaged,primaryentity,xaml&$expand=ownerid($select=fullname),modifiedby($select=fullname),createdby($select=fullname)`);
|
|
838
|
+
// Verify it's actually a business rule
|
|
839
|
+
if (businessRule.category !== 2) {
|
|
840
|
+
throw new Error(`Workflow ${workflowId} is not a business rule (category: ${businessRule.category})`);
|
|
841
|
+
}
|
|
842
|
+
return {
|
|
843
|
+
workflowid: businessRule.workflowid,
|
|
844
|
+
name: businessRule.name,
|
|
845
|
+
description: businessRule.description,
|
|
846
|
+
state: businessRule.statecode === 0 ? 'Draft' : businessRule.statecode === 1 ? 'Activated' : 'Suspended',
|
|
847
|
+
statecode: businessRule.statecode,
|
|
848
|
+
statuscode: businessRule.statuscode,
|
|
849
|
+
type: businessRule.type === 1 ? 'Definition' : businessRule.type === 2 ? 'Activation' : 'Template',
|
|
850
|
+
category: businessRule.category,
|
|
851
|
+
primaryEntity: businessRule.primaryentity,
|
|
852
|
+
isManaged: businessRule.ismanaged,
|
|
853
|
+
owner: businessRule.ownerid?.fullname,
|
|
854
|
+
createdOn: businessRule.createdon,
|
|
855
|
+
createdBy: businessRule.createdby?.fullname,
|
|
856
|
+
modifiedOn: businessRule.modifiedon,
|
|
857
|
+
modifiedBy: businessRule.modifiedby?.fullname,
|
|
858
|
+
xaml: businessRule.xaml
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
// ==================== MODEL-DRIVEN APP OPERATIONS ====================
|
|
862
|
+
/**
|
|
863
|
+
* Get all model-driven apps in the environment
|
|
864
|
+
* @param activeOnly Only return active apps (default: false)
|
|
865
|
+
* @param maxRecords Maximum number of apps to return (default: 100)
|
|
866
|
+
* @returns List of model-driven apps with basic information
|
|
867
|
+
*/
|
|
868
|
+
async getApps(activeOnly = false, maxRecords = 100, includeUnpublished = true, solutionUniqueName) {
|
|
869
|
+
// Build filter conditions
|
|
870
|
+
const filters = [];
|
|
871
|
+
// StateCode: 0=Active, 1=Inactive
|
|
872
|
+
if (activeOnly) {
|
|
873
|
+
filters.push('statecode eq 0');
|
|
874
|
+
}
|
|
875
|
+
// Published status: publishedon null = unpublished
|
|
876
|
+
if (!includeUnpublished) {
|
|
877
|
+
filters.push('publishedon ne null');
|
|
878
|
+
}
|
|
879
|
+
const filterString = filters.length > 0 ? `&$filter=${filters.join(' and ')}` : '';
|
|
880
|
+
const apps = await this.makeRequest(`api/data/v9.2/appmodules?$select=appmoduleid,name,uniquename,description,webresourceid,clienttype,formfactor,navigationtype,url,isfeatured,isdefault,publishedon,statecode,statuscode,_publisherid_value,createdon,modifiedon&$orderby=modifiedon desc&$top=${maxRecords}${filterString}`);
|
|
881
|
+
// If solution filter specified, filter results by solution
|
|
882
|
+
let filteredApps = apps.value;
|
|
883
|
+
if (solutionUniqueName) {
|
|
884
|
+
// Query solution components to find apps in the specified solution
|
|
885
|
+
const solution = await this.makeRequest(`api/data/v9.2/solutions?$filter=uniquename eq '${solutionUniqueName}'&$select=solutionid`);
|
|
886
|
+
if (solution.value.length > 0) {
|
|
887
|
+
const solutionId = solution.value[0].solutionid;
|
|
888
|
+
// Query solution components for app modules
|
|
889
|
+
const solutionComponents = await this.makeRequest(`api/data/v9.2/solutioncomponents?$filter=_solutionid_value eq ${solutionId} and componenttype eq 80&$select=objectid`);
|
|
890
|
+
const appIdsInSolution = new Set(solutionComponents.value.map((c) => c.objectid.toLowerCase()));
|
|
891
|
+
filteredApps = apps.value.filter((app) => appIdsInSolution.has(app.appmoduleid.toLowerCase()));
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
// Format the results for better readability
|
|
895
|
+
const formattedApps = filteredApps.map((app) => ({
|
|
896
|
+
appmoduleid: app.appmoduleid,
|
|
897
|
+
name: app.name,
|
|
898
|
+
uniquename: app.uniquename,
|
|
899
|
+
description: app.description,
|
|
900
|
+
webresourceid: app.webresourceid,
|
|
901
|
+
clienttype: app.clienttype,
|
|
902
|
+
formfactor: app.formfactor,
|
|
903
|
+
navigationtype: app.navigationtype,
|
|
904
|
+
url: app.url,
|
|
905
|
+
isfeatured: app.isfeatured,
|
|
906
|
+
isdefault: app.isdefault,
|
|
907
|
+
state: app.statecode === 0 ? 'Active' : 'Inactive',
|
|
908
|
+
statecode: app.statecode,
|
|
909
|
+
statuscode: app.statuscode,
|
|
910
|
+
publishedon: app.publishedon,
|
|
911
|
+
published: app.publishedon ? true : false,
|
|
912
|
+
publisherid: app._publisherid_value || null,
|
|
913
|
+
createdon: app.createdon,
|
|
914
|
+
modifiedon: app.modifiedon
|
|
915
|
+
}));
|
|
916
|
+
return {
|
|
917
|
+
totalCount: formattedApps.length,
|
|
918
|
+
apps: formattedApps,
|
|
919
|
+
filters: {
|
|
920
|
+
activeOnly,
|
|
921
|
+
includeUnpublished,
|
|
922
|
+
solutionUniqueName: solutionUniqueName || 'all'
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Get a specific model-driven app by ID
|
|
928
|
+
* @param appId The GUID of the app (appmoduleid)
|
|
929
|
+
* @returns Complete app information including publisher details
|
|
930
|
+
*/
|
|
931
|
+
async getApp(appId) {
|
|
932
|
+
const app = await this.makeRequest(`api/data/v9.2/appmodules(${appId})?$select=appmoduleid,name,uniquename,description,webresourceid,clienttype,formfactor,navigationtype,url,isfeatured,isdefault,publishedon,statecode,statuscode,configxml,createdon,modifiedon,_publisherid_value,_createdby_value,_modifiedby_value`);
|
|
933
|
+
return {
|
|
934
|
+
appmoduleid: app.appmoduleid,
|
|
935
|
+
name: app.name,
|
|
936
|
+
uniquename: app.uniquename,
|
|
937
|
+
description: app.description,
|
|
938
|
+
webresourceid: app.webresourceid,
|
|
939
|
+
clienttype: app.clienttype,
|
|
940
|
+
formfactor: app.formfactor,
|
|
941
|
+
navigationtype: app.navigationtype === 0 ? 'Single Session' : 'Multi Session',
|
|
942
|
+
url: app.url,
|
|
943
|
+
isfeatured: app.isfeatured,
|
|
944
|
+
isdefault: app.isdefault,
|
|
945
|
+
state: app.statecode === 0 ? 'Active' : 'Inactive',
|
|
946
|
+
statecode: app.statecode,
|
|
947
|
+
statuscode: app.statuscode,
|
|
948
|
+
publishedon: app.publishedon,
|
|
949
|
+
createdon: app.createdon,
|
|
950
|
+
modifiedon: app.modifiedon,
|
|
951
|
+
createdBy: app._createdby_value || null,
|
|
952
|
+
modifiedBy: app._modifiedby_value || null,
|
|
953
|
+
publisherid: app._publisherid_value || null
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Get all components (entities, forms, views, sitemaps) associated with an app
|
|
958
|
+
* @param appId The GUID of the app (appmoduleid)
|
|
959
|
+
* @returns List of app components with type information
|
|
960
|
+
*/
|
|
961
|
+
async getAppComponents(appId) {
|
|
962
|
+
const components = await this.makeRequest(`api/data/v9.2/appmodulecomponents?$filter=_appmoduleidunique_value eq ${appId}&$select=appmodulecomponentid,objectid,componenttype,rootappmodulecomponentid,createdon,modifiedon&$orderby=componenttype asc`);
|
|
963
|
+
// Map component type numbers to friendly names
|
|
964
|
+
const componentTypeMap = {
|
|
965
|
+
1: 'Entity',
|
|
966
|
+
24: 'Form',
|
|
967
|
+
26: 'View',
|
|
968
|
+
29: 'Business Process Flow',
|
|
969
|
+
48: 'Ribbon Command',
|
|
970
|
+
59: 'Chart/Dashboard',
|
|
971
|
+
60: 'System Form',
|
|
972
|
+
62: 'SiteMap'
|
|
973
|
+
};
|
|
974
|
+
const formattedComponents = components.value.map((component) => ({
|
|
975
|
+
appmodulecomponentid: component.appmodulecomponentid,
|
|
976
|
+
objectid: component.objectid,
|
|
977
|
+
componenttype: component.componenttype,
|
|
978
|
+
componenttypeName: componentTypeMap[component.componenttype] || `Unknown (${component.componenttype})`,
|
|
979
|
+
rootappmodulecomponentid: component.rootappmodulecomponentid,
|
|
980
|
+
createdon: component.createdon,
|
|
981
|
+
modifiedon: component.modifiedon
|
|
982
|
+
}));
|
|
983
|
+
// Group by component type for easier reading
|
|
984
|
+
const groupedByType = {};
|
|
985
|
+
formattedComponents.forEach((comp) => {
|
|
986
|
+
const typeName = comp.componenttypeName;
|
|
987
|
+
if (!groupedByType[typeName]) {
|
|
988
|
+
groupedByType[typeName] = [];
|
|
989
|
+
}
|
|
990
|
+
groupedByType[typeName].push(comp);
|
|
991
|
+
});
|
|
992
|
+
return {
|
|
993
|
+
totalCount: formattedComponents.length,
|
|
994
|
+
components: formattedComponents,
|
|
995
|
+
groupedByType
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Get the sitemap for a specific app
|
|
1000
|
+
* @param appId The GUID of the app (appmoduleid)
|
|
1001
|
+
* @returns Sitemap information including XML
|
|
1002
|
+
*/
|
|
1003
|
+
async getAppSitemap(appId) {
|
|
1004
|
+
// First get the app components to find the sitemap
|
|
1005
|
+
const components = await this.makeRequest(`api/data/v9.2/appmodulecomponents?$filter=_appmoduleidunique_value eq ${appId} and componenttype eq 62&$select=objectid`);
|
|
1006
|
+
if (components.value.length === 0) {
|
|
1007
|
+
return {
|
|
1008
|
+
hasSitemap: false,
|
|
1009
|
+
message: 'No sitemap found for this app'
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
// Get the sitemap details
|
|
1013
|
+
const sitemapId = components.value[0].objectid;
|
|
1014
|
+
const sitemap = await this.makeRequest(`api/data/v9.2/sitemaps(${sitemapId})?$select=sitemapid,sitemapname,sitemapnameunique,sitemapxml,isappaware,enablecollapsiblegroups,showhome,showpinned,showrecents,ismanaged,createdon,modifiedon`);
|
|
1015
|
+
return {
|
|
1016
|
+
hasSitemap: true,
|
|
1017
|
+
sitemapid: sitemap.sitemapid,
|
|
1018
|
+
sitemapname: sitemap.sitemapname,
|
|
1019
|
+
sitemapnameunique: sitemap.sitemapnameunique,
|
|
1020
|
+
sitemapxml: sitemap.sitemapxml,
|
|
1021
|
+
isappaware: sitemap.isappaware,
|
|
1022
|
+
enablecollapsiblegroups: sitemap.enablecollapsiblegroups,
|
|
1023
|
+
showhome: sitemap.showhome,
|
|
1024
|
+
showpinned: sitemap.showpinned,
|
|
1025
|
+
showrecents: sitemap.showrecents,
|
|
1026
|
+
ismanaged: sitemap.ismanaged,
|
|
1027
|
+
createdon: sitemap.createdon,
|
|
1028
|
+
modifiedon: sitemap.modifiedon
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Create a new model-driven app
|
|
1033
|
+
* @param appDefinition The app definition object
|
|
1034
|
+
* @param solutionUniqueName Optional solution to add the app to
|
|
1035
|
+
* @returns The created app information including ID
|
|
1036
|
+
*/
|
|
1037
|
+
async createApp(appDefinition, solutionUniqueName) {
|
|
1038
|
+
const startTime = Date.now();
|
|
1039
|
+
try {
|
|
1040
|
+
// Validate uniquename format (English chars/numbers only, no spaces)
|
|
1041
|
+
const uniquename = appDefinition.uniquename;
|
|
1042
|
+
if (!/^[a-zA-Z0-9_]+$/.test(uniquename)) {
|
|
1043
|
+
throw new Error('App uniquename must contain only English letters, numbers, and underscores (no spaces)');
|
|
1044
|
+
}
|
|
1045
|
+
// Set defaults
|
|
1046
|
+
const appRequest = {
|
|
1047
|
+
name: appDefinition.name,
|
|
1048
|
+
uniquename: appDefinition.uniquename,
|
|
1049
|
+
description: appDefinition.description || '',
|
|
1050
|
+
webresourceid: appDefinition.webresourceid || '953b9fac-1e5e-e611-80d6-00155ded156f', // Default icon
|
|
1051
|
+
welcomepageid: '00000000-0000-0000-0000-000000000000', // Required: empty GUID for no welcome page
|
|
1052
|
+
clienttype: appDefinition.clienttype || 4, // UCI
|
|
1053
|
+
formfactor: appDefinition.formfactor || 1, // Unknown/All
|
|
1054
|
+
navigationtype: appDefinition.navigationtype !== undefined ? appDefinition.navigationtype : 0, // Single session
|
|
1055
|
+
isfeatured: appDefinition.isfeatured || false,
|
|
1056
|
+
isdefault: appDefinition.isdefault || false,
|
|
1057
|
+
url: appDefinition.url || ''
|
|
1058
|
+
};
|
|
1059
|
+
// Headers with solution context and return representation
|
|
1060
|
+
const headers = {
|
|
1061
|
+
'Prefer': 'return=representation'
|
|
1062
|
+
};
|
|
1063
|
+
if (solutionUniqueName) {
|
|
1064
|
+
headers['MSCRM.SolutionUniqueName'] = solutionUniqueName;
|
|
1065
|
+
}
|
|
1066
|
+
// Execute with rate limiting
|
|
1067
|
+
const response = await rateLimiter.execute(async () => {
|
|
1068
|
+
return await this.makeRequest('api/data/v9.2/appmodules', 'POST', appRequest, headers);
|
|
1069
|
+
});
|
|
1070
|
+
// Extract app ID from response (now returned due to Prefer header)
|
|
1071
|
+
const appId = response.appmoduleid;
|
|
1072
|
+
if (!appId) {
|
|
1073
|
+
throw new Error('App creation response missing appmoduleid. Full response: ' + JSON.stringify(response));
|
|
1074
|
+
}
|
|
1075
|
+
// Verify the app is queryable (retry with delay if needed)
|
|
1076
|
+
let appVerified = false;
|
|
1077
|
+
let retryCount = 0;
|
|
1078
|
+
const maxRetries = 3;
|
|
1079
|
+
const retryDelayMs = 2000;
|
|
1080
|
+
while (!appVerified && retryCount < maxRetries) {
|
|
1081
|
+
try {
|
|
1082
|
+
await this.makeRequest(`api/data/v9.2/appmodules(${appId})?$select=appmoduleid,name,uniquename`);
|
|
1083
|
+
appVerified = true;
|
|
1084
|
+
}
|
|
1085
|
+
catch (error) {
|
|
1086
|
+
retryCount++;
|
|
1087
|
+
if (retryCount < maxRetries) {
|
|
1088
|
+
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
// Audit log success
|
|
1093
|
+
auditLogger.log({
|
|
1094
|
+
operation: 'createApp',
|
|
1095
|
+
operationType: 'CREATE',
|
|
1096
|
+
componentType: 'AppModule',
|
|
1097
|
+
componentName: appDefinition.name,
|
|
1098
|
+
componentId: appId,
|
|
1099
|
+
success: true,
|
|
1100
|
+
executionTimeMs: Date.now() - startTime
|
|
1101
|
+
});
|
|
1102
|
+
return {
|
|
1103
|
+
appId,
|
|
1104
|
+
name: appDefinition.name,
|
|
1105
|
+
uniquename: appDefinition.uniquename,
|
|
1106
|
+
verified: appVerified,
|
|
1107
|
+
message: appVerified
|
|
1108
|
+
? 'App created successfully and verified. Remember to add entities, validate, and publish.'
|
|
1109
|
+
: `App created successfully (ID: ${appId}) but verification timed out. The app may need time to propagate in the system. Use get-app with the returned appId to check status.`
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
catch (error) {
|
|
1113
|
+
// Audit log failure
|
|
1114
|
+
auditLogger.log({
|
|
1115
|
+
operation: 'createApp',
|
|
1116
|
+
operationType: 'CREATE',
|
|
1117
|
+
componentType: 'AppModule',
|
|
1118
|
+
componentName: appDefinition.name,
|
|
1119
|
+
success: false,
|
|
1120
|
+
error: error.message,
|
|
1121
|
+
executionTimeMs: Date.now() - startTime
|
|
1122
|
+
});
|
|
1123
|
+
throw new Error(`Failed to create app: ${error.message}`);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Create a sitemap from simplified configuration (no XML knowledge required)
|
|
1128
|
+
* @param config Simplified sitemap configuration
|
|
1129
|
+
* @param solutionUniqueName Optional solution to add the sitemap to
|
|
1130
|
+
* @returns The created sitemap information including ID and XML
|
|
1131
|
+
*/
|
|
1132
|
+
async createSimpleSitemap(config, solutionUniqueName) {
|
|
1133
|
+
const startTime = Date.now();
|
|
1134
|
+
try {
|
|
1135
|
+
// Generate sitemap XML from simplified configuration
|
|
1136
|
+
let xml = '<SiteMap>';
|
|
1137
|
+
config.areas.forEach((area) => {
|
|
1138
|
+
xml += `<Area Id="${area.id}"`;
|
|
1139
|
+
if (area.icon) {
|
|
1140
|
+
xml += ` Icon="${area.icon}"`;
|
|
1141
|
+
}
|
|
1142
|
+
if (area.showGroups !== undefined) {
|
|
1143
|
+
xml += ` ShowGroups="${area.showGroups}"`;
|
|
1144
|
+
}
|
|
1145
|
+
xml += '>';
|
|
1146
|
+
xml += `<Titles><Title LCID="1033" Title="${this.escapeXml(area.title)}" /></Titles>`;
|
|
1147
|
+
if (area.description) {
|
|
1148
|
+
xml += `<Descriptions><Description LCID="1033" Description="${this.escapeXml(area.description)}" /></Descriptions>`;
|
|
1149
|
+
}
|
|
1150
|
+
area.groups.forEach((group) => {
|
|
1151
|
+
xml += `<Group Id="${group.id}"`;
|
|
1152
|
+
if (group.isProfile !== undefined) {
|
|
1153
|
+
xml += ` IsProfile="${group.isProfile}"`;
|
|
1154
|
+
}
|
|
1155
|
+
xml += '>';
|
|
1156
|
+
xml += `<Titles><Title LCID="1033" Title="${this.escapeXml(group.title)}" /></Titles>`;
|
|
1157
|
+
if (group.description) {
|
|
1158
|
+
xml += `<Descriptions><Description LCID="1033" Description="${this.escapeXml(group.description)}" /></Descriptions>`;
|
|
1159
|
+
}
|
|
1160
|
+
group.subareas.forEach((subarea) => {
|
|
1161
|
+
xml += `<SubArea Id="${subarea.id}"`;
|
|
1162
|
+
if (subarea.entity) {
|
|
1163
|
+
xml += ` Entity="${subarea.entity}"`;
|
|
1164
|
+
}
|
|
1165
|
+
if (subarea.url) {
|
|
1166
|
+
xml += ` Url="${subarea.url}"`;
|
|
1167
|
+
}
|
|
1168
|
+
if (subarea.icon) {
|
|
1169
|
+
xml += ` Icon="${subarea.icon}"`;
|
|
1170
|
+
}
|
|
1171
|
+
if (subarea.availableOffline !== undefined) {
|
|
1172
|
+
xml += ` AvailableOffline="${subarea.availableOffline}"`;
|
|
1173
|
+
}
|
|
1174
|
+
if (subarea.passParams !== undefined) {
|
|
1175
|
+
xml += ` PassParams="${subarea.passParams}"`;
|
|
1176
|
+
}
|
|
1177
|
+
xml += '>';
|
|
1178
|
+
xml += `<Titles><Title LCID="1033" Title="${this.escapeXml(subarea.title)}" /></Titles>`;
|
|
1179
|
+
if (subarea.description) {
|
|
1180
|
+
xml += `<Descriptions><Description LCID="1033" Description="${this.escapeXml(subarea.description)}" /></Descriptions>`;
|
|
1181
|
+
}
|
|
1182
|
+
xml += '</SubArea>';
|
|
1183
|
+
});
|
|
1184
|
+
xml += '</Group>';
|
|
1185
|
+
});
|
|
1186
|
+
xml += '</Area>';
|
|
1187
|
+
});
|
|
1188
|
+
xml += '</SiteMap>';
|
|
1189
|
+
// Create sitemap entity
|
|
1190
|
+
const sitemapRequest = {
|
|
1191
|
+
sitemapname: config.name,
|
|
1192
|
+
sitemapxml: xml,
|
|
1193
|
+
isappaware: true,
|
|
1194
|
+
enablecollapsiblegroups: config.enableCollapsibleGroups !== undefined ? config.enableCollapsibleGroups : false,
|
|
1195
|
+
showhome: config.showHome !== undefined ? config.showHome : true,
|
|
1196
|
+
showpinned: config.showPinned !== undefined ? config.showPinned : true,
|
|
1197
|
+
showrecents: config.showRecents !== undefined ? config.showRecents : true
|
|
1198
|
+
};
|
|
1199
|
+
// Headers with solution context
|
|
1200
|
+
const headers = {};
|
|
1201
|
+
if (solutionUniqueName) {
|
|
1202
|
+
headers['MSCRM.SolutionUniqueName'] = solutionUniqueName;
|
|
1203
|
+
}
|
|
1204
|
+
// Execute with rate limiting
|
|
1205
|
+
const response = await rateLimiter.execute(async () => {
|
|
1206
|
+
return await this.makeRequest('api/data/v9.2/sitemaps', 'POST', sitemapRequest, headers);
|
|
1207
|
+
});
|
|
1208
|
+
// Extract sitemap ID from response
|
|
1209
|
+
const sitemapId = response.sitemapid;
|
|
1210
|
+
// Audit log success
|
|
1211
|
+
auditLogger.log({
|
|
1212
|
+
operation: 'createSimpleSitemap',
|
|
1213
|
+
operationType: 'CREATE',
|
|
1214
|
+
componentType: 'SiteMap',
|
|
1215
|
+
componentName: config.name,
|
|
1216
|
+
componentId: sitemapId,
|
|
1217
|
+
success: true,
|
|
1218
|
+
executionTimeMs: Date.now() - startTime
|
|
1219
|
+
});
|
|
1220
|
+
return {
|
|
1221
|
+
sitemapId,
|
|
1222
|
+
sitemapName: config.name,
|
|
1223
|
+
sitemapXml: xml,
|
|
1224
|
+
message: 'Sitemap created successfully. Add it to your app using add-entities-to-app or add specific components.'
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
catch (error) {
|
|
1228
|
+
// Audit log failure
|
|
1229
|
+
auditLogger.log({
|
|
1230
|
+
operation: 'createSimpleSitemap',
|
|
1231
|
+
operationType: 'CREATE',
|
|
1232
|
+
componentType: 'SiteMap',
|
|
1233
|
+
componentName: config.name,
|
|
1234
|
+
success: false,
|
|
1235
|
+
error: error.message,
|
|
1236
|
+
executionTimeMs: Date.now() - startTime
|
|
1237
|
+
});
|
|
1238
|
+
throw new Error(`Failed to create sitemap: ${error.message}`);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Add entities to an app by modifying the sitemap XML
|
|
1243
|
+
* @param appId The GUID of the app
|
|
1244
|
+
* @param entityNames Array of entity logical names to add
|
|
1245
|
+
* @returns Result of the operation
|
|
1246
|
+
*/
|
|
1247
|
+
async addEntitiesToApp(appId, entityNames) {
|
|
1248
|
+
const startTime = Date.now();
|
|
1249
|
+
try {
|
|
1250
|
+
// Get app details
|
|
1251
|
+
const app = await this.makeRequest(`api/data/v9.2/appmodules(${appId})?$select=appmoduleid,name,uniquename`);
|
|
1252
|
+
// Validate entities exist and get their display names
|
|
1253
|
+
const entityPromises = entityNames.map(name => this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${name}')?$select=LogicalName,DisplayName,MetadataId`));
|
|
1254
|
+
const entities = await Promise.all(entityPromises);
|
|
1255
|
+
// Try to get the app's sitemap via components first
|
|
1256
|
+
let sitemapInfo = await this.getAppSitemap(appId);
|
|
1257
|
+
// If not found via components, try to find by matching name
|
|
1258
|
+
if (!sitemapInfo.hasSitemap) {
|
|
1259
|
+
const sitemapQuery = await this.makeRequest(`api/data/v9.2/sitemaps?$filter=sitemapnameunique eq '${app.uniquename}'&$select=sitemapid,sitemapname,sitemapnameunique,sitemapxml`);
|
|
1260
|
+
if (sitemapQuery.value.length > 0) {
|
|
1261
|
+
const sitemap = sitemapQuery.value[0];
|
|
1262
|
+
sitemapInfo = {
|
|
1263
|
+
hasSitemap: true,
|
|
1264
|
+
sitemapid: sitemap.sitemapid,
|
|
1265
|
+
sitemapname: sitemap.sitemapname,
|
|
1266
|
+
sitemapnameunique: sitemap.sitemapnameunique,
|
|
1267
|
+
sitemapxml: sitemap.sitemapxml
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
else {
|
|
1271
|
+
throw new Error(`App '${app.name}' does not have a sitemap. Cannot add entities without a sitemap.`);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
// Parse sitemap XML
|
|
1275
|
+
let sitemapXml = sitemapInfo.sitemapxml;
|
|
1276
|
+
// Find or create a "Tables" area and group
|
|
1277
|
+
// Check if <Area> with Id="Area_Tables" exists
|
|
1278
|
+
const areaRegex = /<Area[^>]+Id="Area_Tables"[^>]*>/;
|
|
1279
|
+
const hasTablesArea = areaRegex.test(sitemapXml);
|
|
1280
|
+
if (!hasTablesArea) {
|
|
1281
|
+
// Add a new Area for tables before the closing </SiteMap>
|
|
1282
|
+
const newArea = `
|
|
1283
|
+
<Area Id="Area_Tables" Title="Tables" ShowGroups="true">
|
|
1284
|
+
<Group Id="Group_Tables" Title="Custom Tables">
|
|
1285
|
+
</Group>
|
|
1286
|
+
</Area>`;
|
|
1287
|
+
sitemapXml = sitemapXml.replace('</SiteMap>', newArea + '\n</SiteMap>');
|
|
1288
|
+
}
|
|
1289
|
+
// Add SubArea elements for each entity within Group_Tables
|
|
1290
|
+
for (const entity of entities) {
|
|
1291
|
+
const displayName = entity.DisplayName?.UserLocalizedLabel?.Label || entity.LogicalName;
|
|
1292
|
+
const subAreaId = `SubArea_${entity.LogicalName}`;
|
|
1293
|
+
// Check if SubArea already exists
|
|
1294
|
+
const subAreaRegex = new RegExp(`<SubArea[^>]+Id="${subAreaId}"[^>]*>`);
|
|
1295
|
+
if (subAreaRegex.test(sitemapXml)) {
|
|
1296
|
+
continue; // Skip if already exists
|
|
1297
|
+
}
|
|
1298
|
+
// Add SubArea within Group_Tables
|
|
1299
|
+
const newSubArea = `
|
|
1300
|
+
<SubArea Id="${subAreaId}" Entity="${entity.LogicalName}" Title="${displayName}" />`;
|
|
1301
|
+
// Find the Group_Tables closing tag and add before it
|
|
1302
|
+
sitemapXml = sitemapXml.replace(/<\/Group>/, newSubArea + '\n </Group>');
|
|
1303
|
+
}
|
|
1304
|
+
// Update the sitemap
|
|
1305
|
+
await rateLimiter.execute(async () => {
|
|
1306
|
+
return await this.makeRequest(`api/data/v9.2/sitemaps(${sitemapInfo.sitemapid})`, 'PATCH', {
|
|
1307
|
+
sitemapxml: sitemapXml
|
|
1308
|
+
});
|
|
1309
|
+
});
|
|
1310
|
+
// CRITICAL: Also add entity components to app for Advanced Find/Search
|
|
1311
|
+
// Use deep insert via appmodule_appmodulecomponent collection navigation property
|
|
1312
|
+
for (const entity of entities) {
|
|
1313
|
+
try {
|
|
1314
|
+
await rateLimiter.execute(async () => {
|
|
1315
|
+
return await this.makeRequest(`api/data/v9.2/appmodules(${appId})/appmodule_appmodulecomponent`, 'POST', {
|
|
1316
|
+
componenttype: 1, // Entity
|
|
1317
|
+
objectid: entity.MetadataId
|
|
1318
|
+
});
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
catch (componentError) {
|
|
1322
|
+
// If deep insert fails, try to continue with other entities
|
|
1323
|
+
auditLogger.log({
|
|
1324
|
+
operation: 'addEntitiesToApp',
|
|
1325
|
+
operationType: 'CREATE',
|
|
1326
|
+
componentType: 'AppModuleComponent',
|
|
1327
|
+
componentName: entity.LogicalName,
|
|
1328
|
+
success: false,
|
|
1329
|
+
error: `Failed to add ${entity.LogicalName} as app component: ${componentError.message}`,
|
|
1330
|
+
executionTimeMs: Date.now() - startTime
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
// Audit log success
|
|
1335
|
+
auditLogger.log({
|
|
1336
|
+
operation: 'addEntitiesToApp',
|
|
1337
|
+
operationType: 'UPDATE',
|
|
1338
|
+
componentType: 'AppModule',
|
|
1339
|
+
componentId: appId,
|
|
1340
|
+
success: true,
|
|
1341
|
+
executionTimeMs: Date.now() - startTime
|
|
1342
|
+
});
|
|
1343
|
+
return {
|
|
1344
|
+
appId,
|
|
1345
|
+
sitemapId: sitemapInfo.sitemapid,
|
|
1346
|
+
entitiesAdded: entityNames,
|
|
1347
|
+
message: `Successfully added ${entityNames.length} entities to app sitemap. Remember to publish the app.`
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
catch (error) {
|
|
1351
|
+
// Audit log failure
|
|
1352
|
+
auditLogger.log({
|
|
1353
|
+
operation: 'addEntitiesToApp',
|
|
1354
|
+
operationType: 'UPDATE',
|
|
1355
|
+
componentType: 'AppModule',
|
|
1356
|
+
componentId: appId,
|
|
1357
|
+
success: false,
|
|
1358
|
+
error: error.message,
|
|
1359
|
+
executionTimeMs: Date.now() - startTime
|
|
1360
|
+
});
|
|
1361
|
+
throw new Error(`Failed to add entities to app: ${error.message}`);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Validate an app before publishing
|
|
1366
|
+
* @param appId The GUID of the app
|
|
1367
|
+
* @returns Validation result with any issues found
|
|
1368
|
+
*/
|
|
1369
|
+
async validateApp(appId) {
|
|
1370
|
+
try {
|
|
1371
|
+
const response = await this.makeRequest(`api/data/v9.2/ValidateApp(AppModuleId=${appId})`);
|
|
1372
|
+
const validationResponse = response.AppValidationResponse;
|
|
1373
|
+
const isValid = validationResponse.ValidationSuccess;
|
|
1374
|
+
const issues = validationResponse.ValidationIssueList || [];
|
|
1375
|
+
return {
|
|
1376
|
+
appId,
|
|
1377
|
+
isValid,
|
|
1378
|
+
issueCount: issues.length,
|
|
1379
|
+
issues: issues.map((issue) => ({
|
|
1380
|
+
errorType: issue.ErrorType,
|
|
1381
|
+
message: issue.Message,
|
|
1382
|
+
componentId: issue.ComponentId,
|
|
1383
|
+
componentType: issue.ComponentType
|
|
1384
|
+
})),
|
|
1385
|
+
message: isValid
|
|
1386
|
+
? 'App validation passed. Ready to publish.'
|
|
1387
|
+
: `App validation found ${issues.length} issue(s). Fix them before publishing.`
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
catch (error) {
|
|
1391
|
+
throw new Error(`Failed to validate app: ${error.message}`);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Publish an app to make it available to users
|
|
1396
|
+
* @param appId The GUID of the app
|
|
1397
|
+
* @returns Result of the publish operation
|
|
1398
|
+
*/
|
|
1399
|
+
async publishApp(appId) {
|
|
1400
|
+
const startTime = Date.now();
|
|
1401
|
+
try {
|
|
1402
|
+
// First validate the app
|
|
1403
|
+
const validation = await this.validateApp(appId);
|
|
1404
|
+
if (!validation.isValid) {
|
|
1405
|
+
throw new Error(`Cannot publish app with validation errors: ${JSON.stringify(validation.issues)}`);
|
|
1406
|
+
}
|
|
1407
|
+
// Publish using PublishXml with app parameter
|
|
1408
|
+
const parameterXml = `<importexportxml><appmodules><appmodule>${appId}</appmodule></appmodules></importexportxml>`;
|
|
1409
|
+
await rateLimiter.execute(async () => {
|
|
1410
|
+
return await this.publishXml(parameterXml);
|
|
1411
|
+
});
|
|
1412
|
+
// Audit log success
|
|
1413
|
+
auditLogger.log({
|
|
1414
|
+
operation: 'publishApp',
|
|
1415
|
+
operationType: 'PUBLISH',
|
|
1416
|
+
componentType: 'AppModule',
|
|
1417
|
+
componentId: appId,
|
|
1418
|
+
success: true,
|
|
1419
|
+
executionTimeMs: Date.now() - startTime
|
|
1420
|
+
});
|
|
1421
|
+
return {
|
|
1422
|
+
appId,
|
|
1423
|
+
message: 'App published successfully. It is now available to users with appropriate security roles.'
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
catch (error) {
|
|
1427
|
+
// Audit log failure
|
|
1428
|
+
auditLogger.log({
|
|
1429
|
+
operation: 'publishApp',
|
|
1430
|
+
operationType: 'PUBLISH',
|
|
1431
|
+
componentType: 'AppModule',
|
|
1432
|
+
componentId: appId,
|
|
1433
|
+
success: false,
|
|
1434
|
+
error: error.message,
|
|
1435
|
+
executionTimeMs: Date.now() - startTime
|
|
1436
|
+
});
|
|
1437
|
+
throw new Error(`Failed to publish app: ${error.message}`);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Helper to escape XML special characters
|
|
1442
|
+
*/
|
|
1443
|
+
escapeXml(unsafe) {
|
|
1444
|
+
return unsafe.replace(/[<>&'"]/g, (c) => {
|
|
1445
|
+
switch (c) {
|
|
1446
|
+
case '<': return '<';
|
|
1447
|
+
case '>': return '>';
|
|
1448
|
+
case '&': return '&';
|
|
1449
|
+
case '\'': return ''';
|
|
1450
|
+
case '"': return '"';
|
|
1451
|
+
default: return c;
|
|
1452
|
+
}
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
// ==================== CUSTOMIZATION WRITE OPERATIONS ====================
|
|
1456
|
+
/**
|
|
1457
|
+
* Create a new custom entity (table)
|
|
1458
|
+
* @param entityDefinition The entity definition object
|
|
1459
|
+
* @param solutionUniqueName Optional solution to add the entity to
|
|
1460
|
+
* @returns The created entity metadata
|
|
1461
|
+
*/
|
|
1462
|
+
async createEntity(entityDefinition, solutionUniqueName) {
|
|
1463
|
+
const startTime = Date.now();
|
|
1464
|
+
try {
|
|
1465
|
+
// Validate entity name against best practices
|
|
1466
|
+
const schemaName = entityDefinition.SchemaName || entityDefinition.LogicalName;
|
|
1467
|
+
const isRefData = schemaName?.toLowerCase().includes('ref_') || false;
|
|
1468
|
+
const nameValidation = bestPracticesValidator.validateEntityName(schemaName, isRefData);
|
|
1469
|
+
if (!nameValidation.isValid) {
|
|
1470
|
+
const error = `Entity name validation failed: ${nameValidation.issues.join(', ')}`;
|
|
1471
|
+
auditLogger.log({
|
|
1472
|
+
operation: 'createEntity',
|
|
1473
|
+
operationType: 'CREATE',
|
|
1474
|
+
componentType: 'Entity',
|
|
1475
|
+
componentName: schemaName,
|
|
1476
|
+
success: false,
|
|
1477
|
+
error,
|
|
1478
|
+
executionTimeMs: Date.now() - startTime
|
|
1479
|
+
});
|
|
1480
|
+
throw new Error(error);
|
|
1481
|
+
}
|
|
1482
|
+
// Log warnings if any
|
|
1483
|
+
if (nameValidation.warnings.length > 0) {
|
|
1484
|
+
console.error(`[WARNING] Entity name warnings: ${nameValidation.warnings.join(', ')}`);
|
|
1485
|
+
}
|
|
1486
|
+
// Validate ownership type
|
|
1487
|
+
const ownershipType = entityDefinition.OwnershipType;
|
|
1488
|
+
if (ownershipType) {
|
|
1489
|
+
const ownershipValidation = bestPracticesValidator.validateOwnershipType(ownershipType);
|
|
1490
|
+
if (!ownershipValidation.isValid) {
|
|
1491
|
+
console.error(`[WARNING] ${ownershipValidation.issues.join(', ')}`);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
// Check for required columns
|
|
1495
|
+
const requiredColumnsValidation = bestPracticesValidator.validateRequiredColumns([], isRefData);
|
|
1496
|
+
if (requiredColumnsValidation.missingColumns && requiredColumnsValidation.missingColumns.length > 0) {
|
|
1497
|
+
console.error('[WARNING] Entity will need required columns added after creation:', requiredColumnsValidation.missingColumns.map(c => c.schemaName).join(', '));
|
|
1498
|
+
}
|
|
1499
|
+
const headers = {};
|
|
1500
|
+
if (solutionUniqueName) {
|
|
1501
|
+
headers['MSCRM.SolutionUniqueName'] = solutionUniqueName;
|
|
1502
|
+
}
|
|
1503
|
+
// Execute with rate limiting
|
|
1504
|
+
const response = await rateLimiter.execute(async () => {
|
|
1505
|
+
return await this.makeRequest('api/data/v9.2/EntityDefinitions', 'POST', entityDefinition, headers);
|
|
1506
|
+
});
|
|
1507
|
+
// Log success
|
|
1508
|
+
auditLogger.log({
|
|
1509
|
+
operation: 'createEntity',
|
|
1510
|
+
operationType: 'CREATE',
|
|
1511
|
+
componentType: 'Entity',
|
|
1512
|
+
componentName: schemaName,
|
|
1513
|
+
success: true,
|
|
1514
|
+
executionTimeMs: Date.now() - startTime
|
|
1515
|
+
});
|
|
1516
|
+
return response;
|
|
1517
|
+
}
|
|
1518
|
+
catch (error) {
|
|
1519
|
+
// Log failure
|
|
1520
|
+
auditLogger.log({
|
|
1521
|
+
operation: 'createEntity',
|
|
1522
|
+
operationType: 'CREATE',
|
|
1523
|
+
componentType: 'Entity',
|
|
1524
|
+
componentName: entityDefinition.SchemaName || entityDefinition.LogicalName,
|
|
1525
|
+
success: false,
|
|
1526
|
+
error: error.message,
|
|
1527
|
+
executionTimeMs: Date.now() - startTime
|
|
1528
|
+
});
|
|
1529
|
+
throw error;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Update an existing entity
|
|
1534
|
+
* @param metadataId The MetadataId of the entity
|
|
1535
|
+
* @param updates The properties to update
|
|
1536
|
+
* @param solutionUniqueName Optional solution context
|
|
1537
|
+
*/
|
|
1538
|
+
async updateEntity(metadataId, updates, solutionUniqueName) {
|
|
1539
|
+
const headers = {
|
|
1540
|
+
'MSCRM.MergeLabels': 'true'
|
|
1541
|
+
};
|
|
1542
|
+
if (solutionUniqueName) {
|
|
1543
|
+
headers['MSCRM.SolutionUniqueName'] = solutionUniqueName;
|
|
1544
|
+
}
|
|
1545
|
+
await this.makeRequest(`api/data/v9.2/EntityDefinitions(${metadataId})`, 'PUT', updates, headers);
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Delete a custom entity
|
|
1549
|
+
* @param metadataId The MetadataId of the entity to delete
|
|
1550
|
+
*/
|
|
1551
|
+
async deleteEntity(metadataId) {
|
|
1552
|
+
await this.makeRequest(`api/data/v9.2/EntityDefinitions(${metadataId})`, 'DELETE');
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* Update entity icon using Fluent UI System Icon
|
|
1556
|
+
* @param entityLogicalName The logical name of the entity
|
|
1557
|
+
* @param iconFileName The Fluent UI icon file name (e.g., 'people_community_24_filled.svg')
|
|
1558
|
+
* @param solutionUniqueName Optional solution to add the web resource to
|
|
1559
|
+
* @returns Result with web resource ID and icon vector name
|
|
1560
|
+
*/
|
|
1561
|
+
async updateEntityIcon(entityLogicalName, iconFileName, solutionUniqueName) {
|
|
1562
|
+
const startTime = Date.now();
|
|
1563
|
+
try {
|
|
1564
|
+
// Step 1: Get entity metadata to retrieve schema name and metadata ID
|
|
1565
|
+
const entityMetadata = await this.getEntityMetadata(entityLogicalName);
|
|
1566
|
+
const entitySchemaName = entityMetadata.SchemaName;
|
|
1567
|
+
const metadataId = entityMetadata.MetadataId;
|
|
1568
|
+
if (!metadataId) {
|
|
1569
|
+
throw new Error(`Could not find MetadataId for entity '${entityLogicalName}'`);
|
|
1570
|
+
}
|
|
1571
|
+
// Note: No need to clear existing IconVectorName - PowerPlatform will override it
|
|
1572
|
+
// when we set the new icon. This avoids potential API errors from setting null values.
|
|
1573
|
+
// Step 2: Fetch the icon SVG from Fluent UI GitHub
|
|
1574
|
+
const svgContent = await iconManager.fetchIcon(iconFileName);
|
|
1575
|
+
// Step 3: Validate the SVG
|
|
1576
|
+
const validation = iconManager.validateIconSvg(svgContent);
|
|
1577
|
+
if (!validation.valid) {
|
|
1578
|
+
throw new Error(`Invalid SVG: ${validation.error}`);
|
|
1579
|
+
}
|
|
1580
|
+
// Step 4: Convert SVG to base64
|
|
1581
|
+
const base64Content = Buffer.from(svgContent).toString('base64');
|
|
1582
|
+
// Step 5: Generate web resource name
|
|
1583
|
+
const webResourceName = iconManager.generateWebResourceName(entitySchemaName, iconFileName.replace('.svg', ''));
|
|
1584
|
+
// Step 6: Check if web resource already exists (use exact name match)
|
|
1585
|
+
const existingResourcesResponse = await this.makeRequest(`api/data/v9.2/webresourceset?$filter=name eq '${webResourceName}'&$select=webresourceid,name`);
|
|
1586
|
+
let webResourceId;
|
|
1587
|
+
if (existingResourcesResponse.value && existingResourcesResponse.value.length > 0) {
|
|
1588
|
+
// Web resource exists, update it
|
|
1589
|
+
const existing = existingResourcesResponse.value[0];
|
|
1590
|
+
webResourceId = existing.webresourceid;
|
|
1591
|
+
const webResourceUpdates = {
|
|
1592
|
+
displayname: `Icon for ${entityMetadata.DisplayName?.UserLocalizedLabel?.Label || entityLogicalName}`,
|
|
1593
|
+
content: base64Content,
|
|
1594
|
+
description: `Fluent UI icon (${iconFileName}) for ${entityLogicalName} entity`
|
|
1595
|
+
};
|
|
1596
|
+
await this.updateWebResource(webResourceId, webResourceUpdates, solutionUniqueName);
|
|
1597
|
+
}
|
|
1598
|
+
else {
|
|
1599
|
+
// Web resource doesn't exist, create new
|
|
1600
|
+
const webResource = {
|
|
1601
|
+
name: webResourceName,
|
|
1602
|
+
displayname: `Icon for ${entityMetadata.DisplayName?.UserLocalizedLabel?.Label || entityLogicalName}`,
|
|
1603
|
+
webresourcetype: 11, // SVG
|
|
1604
|
+
content: base64Content,
|
|
1605
|
+
description: `Fluent UI icon (${iconFileName}) for ${entityLogicalName} entity`
|
|
1606
|
+
};
|
|
1607
|
+
const webResourceResult = await this.createWebResource(webResource, solutionUniqueName);
|
|
1608
|
+
webResourceId = webResourceResult.webresourceid;
|
|
1609
|
+
}
|
|
1610
|
+
// Step 7: Generate icon vector name
|
|
1611
|
+
const iconVectorName = iconManager.generateIconVectorName(webResourceName);
|
|
1612
|
+
// Step 8: Update entity metadata with icon reference
|
|
1613
|
+
const entityUpdates = {
|
|
1614
|
+
'@odata.type': 'Microsoft.Dynamics.CRM.EntityMetadata',
|
|
1615
|
+
IconVectorName: iconVectorName
|
|
1616
|
+
};
|
|
1617
|
+
await this.updateEntity(metadataId, entityUpdates, solutionUniqueName);
|
|
1618
|
+
// Step 9: Publish the web resource (component type 61)
|
|
1619
|
+
await this.publishComponent(webResourceId, 61);
|
|
1620
|
+
// Step 10: Publish the entity (component type 1)
|
|
1621
|
+
await this.publishComponent(metadataId, 1);
|
|
1622
|
+
// Log success
|
|
1623
|
+
auditLogger.log({
|
|
1624
|
+
operation: 'updateEntityIcon',
|
|
1625
|
+
operationType: 'UPDATE',
|
|
1626
|
+
componentType: 'Entity',
|
|
1627
|
+
componentName: entityLogicalName,
|
|
1628
|
+
success: true,
|
|
1629
|
+
parameters: {
|
|
1630
|
+
iconFileName,
|
|
1631
|
+
webResourceName,
|
|
1632
|
+
webResourceId,
|
|
1633
|
+
iconVectorName
|
|
1634
|
+
},
|
|
1635
|
+
executionTimeMs: Date.now() - startTime
|
|
1636
|
+
});
|
|
1637
|
+
return {
|
|
1638
|
+
success: true,
|
|
1639
|
+
entityLogicalName,
|
|
1640
|
+
entitySchemaName,
|
|
1641
|
+
iconFileName,
|
|
1642
|
+
webResourceId,
|
|
1643
|
+
webResourceName,
|
|
1644
|
+
iconVectorName,
|
|
1645
|
+
message: 'Entity icon updated and published successfully. The icon should now be visible in the UI.'
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
catch (error) {
|
|
1649
|
+
// Log failure
|
|
1650
|
+
auditLogger.log({
|
|
1651
|
+
operation: 'updateEntityIcon',
|
|
1652
|
+
operationType: 'UPDATE',
|
|
1653
|
+
componentType: 'Entity',
|
|
1654
|
+
componentName: entityLogicalName,
|
|
1655
|
+
success: false,
|
|
1656
|
+
error: error.message,
|
|
1657
|
+
executionTimeMs: Date.now() - startTime
|
|
1658
|
+
});
|
|
1659
|
+
throw error;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Create a new attribute on an entity
|
|
1664
|
+
* @param entityLogicalName The logical name of the entity
|
|
1665
|
+
* @param attributeDefinition The attribute definition object
|
|
1666
|
+
* @param solutionUniqueName Optional solution to add the attribute to
|
|
1667
|
+
* @returns The created attribute metadata
|
|
1668
|
+
*/
|
|
1669
|
+
async createAttribute(entityLogicalName, attributeDefinition, solutionUniqueName) {
|
|
1670
|
+
const startTime = Date.now();
|
|
1671
|
+
try {
|
|
1672
|
+
// Validate attribute name against best practices
|
|
1673
|
+
const schemaName = attributeDefinition.SchemaName || attributeDefinition.LogicalName;
|
|
1674
|
+
const isLookup = attributeDefinition['@odata.type'] === 'Microsoft.Dynamics.CRM.LookupAttributeMetadata';
|
|
1675
|
+
const nameValidation = bestPracticesValidator.validateAttributeName(schemaName, isLookup);
|
|
1676
|
+
if (!nameValidation.isValid) {
|
|
1677
|
+
const error = `Attribute name validation failed: ${nameValidation.issues.join(', ')}`;
|
|
1678
|
+
auditLogger.log({
|
|
1679
|
+
operation: 'createAttribute',
|
|
1680
|
+
operationType: 'CREATE',
|
|
1681
|
+
componentType: 'Attribute',
|
|
1682
|
+
componentName: `${entityLogicalName}.${schemaName}`,
|
|
1683
|
+
success: false,
|
|
1684
|
+
error,
|
|
1685
|
+
executionTimeMs: Date.now() - startTime
|
|
1686
|
+
});
|
|
1687
|
+
throw new Error(error);
|
|
1688
|
+
}
|
|
1689
|
+
// Log warnings if any
|
|
1690
|
+
if (nameValidation.warnings.length > 0) {
|
|
1691
|
+
console.error(`[WARNING] Attribute name warnings: ${nameValidation.warnings.join(', ')}`);
|
|
1692
|
+
}
|
|
1693
|
+
// Validate boolean usage (best practice is to avoid booleans)
|
|
1694
|
+
const isBoolean = attributeDefinition['@odata.type'] === 'Microsoft.Dynamics.CRM.BooleanAttributeMetadata';
|
|
1695
|
+
if (isBoolean) {
|
|
1696
|
+
const booleanValidation = bestPracticesValidator.validateBooleanUsage('Boolean', schemaName);
|
|
1697
|
+
if (!booleanValidation.isValid) {
|
|
1698
|
+
console.error(`[WARNING] ${booleanValidation.warnings.join(', ')}`);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
const headers = {};
|
|
1702
|
+
if (solutionUniqueName) {
|
|
1703
|
+
headers['MSCRM.SolutionUniqueName'] = solutionUniqueName;
|
|
1704
|
+
}
|
|
1705
|
+
// Execute with rate limiting
|
|
1706
|
+
const response = await rateLimiter.execute(async () => {
|
|
1707
|
+
return await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityLogicalName}')/Attributes`, 'POST', attributeDefinition, headers);
|
|
1708
|
+
});
|
|
1709
|
+
// Log success
|
|
1710
|
+
auditLogger.log({
|
|
1711
|
+
operation: 'createAttribute',
|
|
1712
|
+
operationType: 'CREATE',
|
|
1713
|
+
componentType: 'Attribute',
|
|
1714
|
+
componentName: `${entityLogicalName}.${schemaName}`,
|
|
1715
|
+
success: true,
|
|
1716
|
+
executionTimeMs: Date.now() - startTime
|
|
1717
|
+
});
|
|
1718
|
+
return response;
|
|
1719
|
+
}
|
|
1720
|
+
catch (error) {
|
|
1721
|
+
// Log failure
|
|
1722
|
+
const schemaName = attributeDefinition.SchemaName || attributeDefinition.LogicalName;
|
|
1723
|
+
auditLogger.log({
|
|
1724
|
+
operation: 'createAttribute',
|
|
1725
|
+
operationType: 'CREATE',
|
|
1726
|
+
componentType: 'Attribute',
|
|
1727
|
+
componentName: `${entityLogicalName}.${schemaName}`,
|
|
1728
|
+
success: false,
|
|
1729
|
+
error: error.message,
|
|
1730
|
+
executionTimeMs: Date.now() - startTime
|
|
1731
|
+
});
|
|
1732
|
+
throw error;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Update an existing attribute
|
|
1737
|
+
* @param entityLogicalName The logical name of the entity
|
|
1738
|
+
* @param attributeLogicalName The logical name of the attribute
|
|
1739
|
+
* @param updates The properties to update
|
|
1740
|
+
* @param solutionUniqueName Optional solution context
|
|
1741
|
+
*/
|
|
1742
|
+
async updateAttribute(entityLogicalName, attributeLogicalName, updates, solutionUniqueName) {
|
|
1743
|
+
const headers = {
|
|
1744
|
+
'MSCRM.MergeLabels': 'true'
|
|
1745
|
+
};
|
|
1746
|
+
if (solutionUniqueName) {
|
|
1747
|
+
headers['MSCRM.SolutionUniqueName'] = solutionUniqueName;
|
|
1748
|
+
}
|
|
1749
|
+
// First, get the existing attribute to retrieve its @odata.type and merge updates
|
|
1750
|
+
const existingAttribute = await this.getEntityAttribute(entityLogicalName, attributeLogicalName);
|
|
1751
|
+
// Merge the updates with required fields
|
|
1752
|
+
const payload = {
|
|
1753
|
+
...updates,
|
|
1754
|
+
'@odata.type': existingAttribute['@odata.type'],
|
|
1755
|
+
LogicalName: attributeLogicalName,
|
|
1756
|
+
AttributeType: existingAttribute.AttributeType
|
|
1757
|
+
};
|
|
1758
|
+
await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityLogicalName}')/Attributes(LogicalName='${attributeLogicalName}')`, 'PUT', payload, headers);
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Delete an attribute
|
|
1762
|
+
* @param entityLogicalName The logical name of the entity
|
|
1763
|
+
* @param attributeMetadataId The MetadataId of the attribute to delete
|
|
1764
|
+
*/
|
|
1765
|
+
async deleteAttribute(entityLogicalName, attributeMetadataId) {
|
|
1766
|
+
await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityLogicalName}')/Attributes(${attributeMetadataId})`, 'DELETE');
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Create a picklist attribute using a global option set
|
|
1770
|
+
* @param entityLogicalName The logical name of the entity
|
|
1771
|
+
* @param attributeDefinition The attribute definition (must reference a global option set)
|
|
1772
|
+
* @param solutionUniqueName Optional solution to add the attribute to
|
|
1773
|
+
* @returns The created attribute metadata
|
|
1774
|
+
*/
|
|
1775
|
+
async createGlobalOptionSetAttribute(entityLogicalName, attributeDefinition, solutionUniqueName) {
|
|
1776
|
+
const headers = {};
|
|
1777
|
+
if (solutionUniqueName) {
|
|
1778
|
+
headers['MSCRM.SolutionUniqueName'] = solutionUniqueName;
|
|
1779
|
+
}
|
|
1780
|
+
// Ensure the attribute is of type PicklistAttributeMetadata with GlobalOptionSet
|
|
1781
|
+
if (!attributeDefinition['@odata.type']) {
|
|
1782
|
+
attributeDefinition['@odata.type'] = 'Microsoft.Dynamics.CRM.PicklistAttributeMetadata';
|
|
1783
|
+
}
|
|
1784
|
+
return await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityLogicalName}')/Attributes`, 'POST', attributeDefinition, headers);
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Create a one-to-many relationship
|
|
1788
|
+
* @param relationshipDefinition The relationship definition
|
|
1789
|
+
* @param solutionUniqueName Optional solution to add the relationship to
|
|
1790
|
+
*/
|
|
1791
|
+
async createOneToManyRelationship(relationshipDefinition, solutionUniqueName) {
|
|
1792
|
+
const headers = {};
|
|
1793
|
+
if (solutionUniqueName) {
|
|
1794
|
+
headers['MSCRM.SolutionUniqueName'] = solutionUniqueName;
|
|
1795
|
+
}
|
|
1796
|
+
const response = await this.makeRequest('api/data/v9.2/RelationshipDefinitions', 'POST', relationshipDefinition, headers);
|
|
1797
|
+
return response;
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Create a many-to-many relationship
|
|
1801
|
+
* @param relationshipDefinition The relationship definition
|
|
1802
|
+
* @param solutionUniqueName Optional solution to add the relationship to
|
|
1803
|
+
*/
|
|
1804
|
+
async createManyToManyRelationship(relationshipDefinition, solutionUniqueName) {
|
|
1805
|
+
const headers = {};
|
|
1806
|
+
if (solutionUniqueName) {
|
|
1807
|
+
headers['MSCRM.SolutionUniqueName'] = solutionUniqueName;
|
|
1808
|
+
}
|
|
1809
|
+
const response = await this.makeRequest('api/data/v9.2/RelationshipDefinitions', 'POST', relationshipDefinition, headers);
|
|
1810
|
+
return response;
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Delete a relationship
|
|
1814
|
+
* @param metadataId The MetadataId of the relationship to delete
|
|
1815
|
+
*/
|
|
1816
|
+
async deleteRelationship(metadataId) {
|
|
1817
|
+
await this.makeRequest(`api/data/v9.2/RelationshipDefinitions(${metadataId})`, 'DELETE');
|
|
1818
|
+
}
|
|
1819
|
+
/**
|
|
1820
|
+
* Update a relationship
|
|
1821
|
+
* Note: Most relationship properties are immutable, only labels can be updated
|
|
1822
|
+
* @param metadataId The MetadataId of the relationship
|
|
1823
|
+
* @param updates The properties to update (typically labels)
|
|
1824
|
+
*/
|
|
1825
|
+
async updateRelationship(metadataId, updates) {
|
|
1826
|
+
await this.makeRequest(`api/data/v9.2/RelationshipDefinitions(${metadataId})`, 'PUT', updates, { 'MSCRM.MergeLabels': 'true' });
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Get detailed information about a relationship
|
|
1830
|
+
* @param metadataId The MetadataId of the relationship
|
|
1831
|
+
* @returns The relationship metadata
|
|
1832
|
+
*/
|
|
1833
|
+
async getRelationshipDetails(metadataId) {
|
|
1834
|
+
return await this.makeRequest(`api/data/v9.2/RelationshipDefinitions(${metadataId})`);
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* Publish all customizations
|
|
1838
|
+
*/
|
|
1839
|
+
async publishAllCustomizations() {
|
|
1840
|
+
await this.makeRequest('api/data/v9.2/PublishAllXml', 'POST', {});
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* Publish specific customizations
|
|
1844
|
+
* @param parameterXml The ParameterXml specifying what to publish
|
|
1845
|
+
*/
|
|
1846
|
+
async publishXml(parameterXml) {
|
|
1847
|
+
await this.makeRequest('api/data/v9.2/PublishXml', 'POST', { ParameterXml: parameterXml });
|
|
1848
|
+
}
|
|
1849
|
+
/**
|
|
1850
|
+
* Create a global option set
|
|
1851
|
+
* @param optionSetDefinition The option set definition
|
|
1852
|
+
* @param solutionUniqueName Optional solution to add the option set to
|
|
1853
|
+
*/
|
|
1854
|
+
async createGlobalOptionSet(optionSetDefinition, solutionUniqueName) {
|
|
1855
|
+
const headers = {};
|
|
1856
|
+
if (solutionUniqueName) {
|
|
1857
|
+
headers['MSCRM.SolutionUniqueName'] = solutionUniqueName;
|
|
1858
|
+
}
|
|
1859
|
+
const response = await this.makeRequest('api/data/v9.2/GlobalOptionSetDefinitions', 'POST', optionSetDefinition, headers);
|
|
1860
|
+
return response;
|
|
1861
|
+
}
|
|
1862
|
+
/**
|
|
1863
|
+
* Delete a global option set
|
|
1864
|
+
* @param metadataId The MetadataId of the option set to delete
|
|
1865
|
+
*/
|
|
1866
|
+
async deleteGlobalOptionSet(metadataId) {
|
|
1867
|
+
await this.makeRequest(`api/data/v9.2/GlobalOptionSetDefinitions(${metadataId})`, 'DELETE');
|
|
1868
|
+
}
|
|
1869
|
+
// ===== Phase 2: UI Components (Forms, Views, Option Sets) =====
|
|
1870
|
+
/**
|
|
1871
|
+
* Update a global option set
|
|
1872
|
+
*/
|
|
1873
|
+
async updateGlobalOptionSet(metadataId, updates, solutionUniqueName) {
|
|
1874
|
+
const headers = solutionUniqueName ? { 'MSCRM.SolutionUniqueName': solutionUniqueName } : undefined;
|
|
1875
|
+
await this.makeRequest(`api/data/v9.2/GlobalOptionSetDefinitions(${metadataId})`, 'PUT', updates, headers);
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* Add a value to a global option set
|
|
1879
|
+
*/
|
|
1880
|
+
async addOptionSetValue(optionSetName, value, label, solutionUniqueName) {
|
|
1881
|
+
const headers = solutionUniqueName ? { 'MSCRM.SolutionUniqueName': solutionUniqueName } : undefined;
|
|
1882
|
+
return await this.makeRequest(`api/data/v9.2/InsertOptionValue`, 'POST', {
|
|
1883
|
+
OptionSetName: optionSetName,
|
|
1884
|
+
Value: value,
|
|
1885
|
+
Label: {
|
|
1886
|
+
LocalizedLabels: [{ Label: label, LanguageCode: 1033 }]
|
|
1887
|
+
}
|
|
1888
|
+
}, headers);
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* Update an option set value
|
|
1892
|
+
*/
|
|
1893
|
+
async updateOptionSetValue(optionSetName, value, label, solutionUniqueName) {
|
|
1894
|
+
const headers = { 'MSCRM.MergeLabels': 'true' };
|
|
1895
|
+
if (solutionUniqueName) {
|
|
1896
|
+
headers['MSCRM.SolutionUniqueName'] = solutionUniqueName;
|
|
1897
|
+
}
|
|
1898
|
+
await this.makeRequest(`api/data/v9.2/UpdateOptionValue`, 'POST', {
|
|
1899
|
+
OptionSetName: optionSetName,
|
|
1900
|
+
Value: value,
|
|
1901
|
+
Label: {
|
|
1902
|
+
LocalizedLabels: [{ Label: label, LanguageCode: 1033 }]
|
|
1903
|
+
},
|
|
1904
|
+
MergeLabels: true
|
|
1905
|
+
}, headers);
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* Delete an option set value
|
|
1909
|
+
*/
|
|
1910
|
+
async deleteOptionSetValue(optionSetName, value) {
|
|
1911
|
+
await this.makeRequest(`api/data/v9.2/DeleteOptionValue`, 'POST', {
|
|
1912
|
+
OptionSetName: optionSetName,
|
|
1913
|
+
Value: value
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* Reorder option set values
|
|
1918
|
+
*/
|
|
1919
|
+
async reorderOptionSetValues(optionSetName, values, solutionUniqueName) {
|
|
1920
|
+
const headers = solutionUniqueName ? { 'MSCRM.SolutionUniqueName': solutionUniqueName } : undefined;
|
|
1921
|
+
await this.makeRequest(`api/data/v9.2/OrderOption`, 'POST', {
|
|
1922
|
+
OptionSetName: optionSetName,
|
|
1923
|
+
Values: values
|
|
1924
|
+
}, headers);
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Create a form (systemform)
|
|
1928
|
+
*/
|
|
1929
|
+
async createForm(form, solutionUniqueName) {
|
|
1930
|
+
const headers = solutionUniqueName ? { 'MSCRM.SolutionUniqueName': solutionUniqueName } : undefined;
|
|
1931
|
+
return await this.makeRequest('api/data/v9.2/systemforms', 'POST', form, headers);
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* Update a form
|
|
1935
|
+
*/
|
|
1936
|
+
async updateForm(formId, updates, solutionUniqueName) {
|
|
1937
|
+
const headers = solutionUniqueName ? { 'MSCRM.SolutionUniqueName': solutionUniqueName } : undefined;
|
|
1938
|
+
await this.makeRequest(`api/data/v9.2/systemforms(${formId})`, 'PATCH', updates, headers);
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* Delete a form
|
|
1942
|
+
*/
|
|
1943
|
+
async deleteForm(formId) {
|
|
1944
|
+
await this.makeRequest(`api/data/v9.2/systemforms(${formId})`, 'DELETE');
|
|
1945
|
+
}
|
|
1946
|
+
/**
|
|
1947
|
+
* Get forms for an entity
|
|
1948
|
+
*/
|
|
1949
|
+
async getForms(entityLogicalName) {
|
|
1950
|
+
return await this.makeRequest(`api/data/v9.2/systemforms?$filter=objecttypecode eq '${entityLogicalName}'&$orderby=type`);
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* Create a view (savedquery)
|
|
1954
|
+
*/
|
|
1955
|
+
async createView(view, solutionUniqueName) {
|
|
1956
|
+
const headers = solutionUniqueName ? { 'MSCRM.SolutionUniqueName': solutionUniqueName } : undefined;
|
|
1957
|
+
return await this.makeRequest('api/data/v9.2/savedqueries', 'POST', view, headers);
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* Update a view
|
|
1961
|
+
*/
|
|
1962
|
+
async updateView(viewId, updates, solutionUniqueName) {
|
|
1963
|
+
const headers = solutionUniqueName ? { 'MSCRM.SolutionUniqueName': solutionUniqueName } : undefined;
|
|
1964
|
+
await this.makeRequest(`api/data/v9.2/savedqueries(${viewId})`, 'PATCH', updates, headers);
|
|
1965
|
+
}
|
|
1966
|
+
/**
|
|
1967
|
+
* Delete a view
|
|
1968
|
+
*/
|
|
1969
|
+
async deleteView(viewId) {
|
|
1970
|
+
await this.makeRequest(`api/data/v9.2/savedqueries(${viewId})`, 'DELETE');
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Get views for an entity
|
|
1974
|
+
*/
|
|
1975
|
+
async getViews(entityLogicalName) {
|
|
1976
|
+
return await this.makeRequest(`api/data/v9.2/savedqueries?$filter=returnedtypecode eq '${entityLogicalName}'&$orderby=querytype`);
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* Activate a form (set statecode=1)
|
|
1980
|
+
* @param formId The systemformid (GUID)
|
|
1981
|
+
*/
|
|
1982
|
+
async activateForm(formId) {
|
|
1983
|
+
await this.makeRequest(`api/data/v9.2/systemforms(${formId})`, 'PATCH', { statecode: 1, statuscode: 1 });
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Deactivate a form (set statecode=0)
|
|
1987
|
+
* @param formId The systemformid (GUID)
|
|
1988
|
+
*/
|
|
1989
|
+
async deactivateForm(formId) {
|
|
1990
|
+
await this.makeRequest(`api/data/v9.2/systemforms(${formId})`, 'PATCH', { statecode: 0, statuscode: 2 });
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* Set a view as the default view for its entity
|
|
1994
|
+
* @param viewId The savedqueryid (GUID)
|
|
1995
|
+
*/
|
|
1996
|
+
async setDefaultView(viewId) {
|
|
1997
|
+
await this.makeRequest(`api/data/v9.2/savedqueries(${viewId})`, 'PATCH', { isdefault: true });
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Get the FetchXML from a view
|
|
2001
|
+
* @param viewId The savedqueryid (GUID)
|
|
2002
|
+
* @returns The view with FetchXML
|
|
2003
|
+
*/
|
|
2004
|
+
async getViewFetchXml(viewId) {
|
|
2005
|
+
return await this.makeRequest(`api/data/v9.2/savedqueries(${viewId})?$select=fetchxml,name,returnedtypecode,querytype`);
|
|
2006
|
+
}
|
|
2007
|
+
// ===== Phase 3: Advanced Customizations (Web Resources) =====
|
|
2008
|
+
/**
|
|
2009
|
+
* Create a web resource
|
|
2010
|
+
*/
|
|
2011
|
+
async createWebResource(webResource, solutionUniqueName) {
|
|
2012
|
+
const headers = solutionUniqueName ? { 'MSCRM.SolutionUniqueName': solutionUniqueName } : undefined;
|
|
2013
|
+
return await this.makeRequest('api/data/v9.2/webresourceset', 'POST', webResource, headers);
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* Update a web resource
|
|
2017
|
+
*/
|
|
2018
|
+
async updateWebResource(webResourceId, updates, solutionUniqueName) {
|
|
2019
|
+
const headers = solutionUniqueName ? { 'MSCRM.SolutionUniqueName': solutionUniqueName } : undefined;
|
|
2020
|
+
await this.makeRequest(`api/data/v9.2/webresourceset(${webResourceId})`, 'PATCH', updates, headers);
|
|
2021
|
+
}
|
|
2022
|
+
/**
|
|
2023
|
+
* Delete a web resource
|
|
2024
|
+
*/
|
|
2025
|
+
async deleteWebResource(webResourceId) {
|
|
2026
|
+
await this.makeRequest(`api/data/v9.2/webresourceset(${webResourceId})`, 'DELETE');
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Get web resource
|
|
2030
|
+
*/
|
|
2031
|
+
async getWebResource(webResourceId) {
|
|
2032
|
+
return await this.makeRequest(`api/data/v9.2/webresourceset(${webResourceId})`);
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Get web resources by name pattern
|
|
2036
|
+
*/
|
|
2037
|
+
async getWebResources(nameFilter) {
|
|
2038
|
+
const filter = nameFilter ? `?$filter=contains(name,'${nameFilter}')` : '';
|
|
2039
|
+
return await this.makeRequest(`api/data/v9.2/webresourceset${filter}`);
|
|
2040
|
+
}
|
|
2041
|
+
/**
|
|
2042
|
+
* Get web resource content (base64 encoded)
|
|
2043
|
+
* @param webResourceId The webresourceid (GUID)
|
|
2044
|
+
* @returns The web resource with content field
|
|
2045
|
+
*/
|
|
2046
|
+
async getWebResourceContent(webResourceId) {
|
|
2047
|
+
return await this.makeRequest(`api/data/v9.2/webresourceset(${webResourceId})?$select=content,name,webresourcetype`);
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Get web resource dependencies
|
|
2051
|
+
* @param webResourceId The webresourceid (GUID)
|
|
2052
|
+
* @returns List of dependencies
|
|
2053
|
+
*/
|
|
2054
|
+
async getWebResourceDependencies(webResourceId) {
|
|
2055
|
+
return await this.makeRequest(`api/data/v9.2/webresourceset(${webResourceId})/dependencies`);
|
|
2056
|
+
}
|
|
2057
|
+
// ===== Phase 4: Solution Management =====
|
|
2058
|
+
/**
|
|
2059
|
+
* Create a publisher
|
|
2060
|
+
*/
|
|
2061
|
+
async createPublisher(publisher) {
|
|
2062
|
+
return await this.makeRequest('api/data/v9.2/publishers', 'POST', publisher);
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Get publishers
|
|
2066
|
+
*/
|
|
2067
|
+
async getPublishers() {
|
|
2068
|
+
return await this.makeRequest('api/data/v9.2/publishers?$filter=isreadonly eq false');
|
|
2069
|
+
}
|
|
2070
|
+
/**
|
|
2071
|
+
* Create a solution
|
|
2072
|
+
*/
|
|
2073
|
+
async createSolution(solution) {
|
|
2074
|
+
return await this.makeRequest('api/data/v9.2/solutions', 'POST', solution);
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
2077
|
+
* Get solutions
|
|
2078
|
+
*/
|
|
2079
|
+
async getSolutions() {
|
|
2080
|
+
return await this.makeRequest('api/data/v9.2/solutions?$filter=isvisible eq true&$orderby=createdon desc');
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* Get solution by unique name
|
|
2084
|
+
*/
|
|
2085
|
+
async getSolution(uniqueName) {
|
|
2086
|
+
const result = await this.makeRequest(`api/data/v9.2/solutions?$filter=uniquename eq '${uniqueName}'&$top=1`);
|
|
2087
|
+
return result.value && result.value.length > 0 ? result.value[0] : null;
|
|
2088
|
+
}
|
|
2089
|
+
/**
|
|
2090
|
+
* Add component to solution
|
|
2091
|
+
*/
|
|
2092
|
+
async addComponentToSolution(solutionUniqueName, componentId, componentType, addRequiredComponents = true, includedComponentSettingsValues) {
|
|
2093
|
+
await this.makeRequest('api/data/v9.2/AddSolutionComponent', 'POST', {
|
|
2094
|
+
SolutionUniqueName: solutionUniqueName,
|
|
2095
|
+
ComponentId: componentId,
|
|
2096
|
+
ComponentType: componentType,
|
|
2097
|
+
AddRequiredComponents: addRequiredComponents,
|
|
2098
|
+
IncludedComponentSettingsValues: includedComponentSettingsValues
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* Remove component from solution
|
|
2103
|
+
*/
|
|
2104
|
+
async removeComponentFromSolution(solutionUniqueName, componentId, componentType) {
|
|
2105
|
+
await this.makeRequest('api/data/v9.2/RemoveSolutionComponent', 'POST', {
|
|
2106
|
+
SolutionUniqueName: solutionUniqueName,
|
|
2107
|
+
ComponentId: componentId,
|
|
2108
|
+
ComponentType: componentType
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
/**
|
|
2112
|
+
* Get solution components
|
|
2113
|
+
*/
|
|
2114
|
+
async getSolutionComponents(solutionUniqueName) {
|
|
2115
|
+
const solution = await this.getSolution(solutionUniqueName);
|
|
2116
|
+
if (!solution) {
|
|
2117
|
+
throw new Error(`Solution '${solutionUniqueName}' not found`);
|
|
2118
|
+
}
|
|
2119
|
+
return await this.makeRequest(`api/data/v9.2/solutioncomponents?$filter=_solutionid_value eq ${solution.solutionid}&$orderby=componenttype`);
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* Export solution
|
|
2123
|
+
*/
|
|
2124
|
+
async exportSolution(solutionName, managed = false) {
|
|
2125
|
+
return await this.makeRequest('api/data/v9.2/ExportSolution', 'POST', {
|
|
2126
|
+
SolutionName: solutionName,
|
|
2127
|
+
Managed: managed,
|
|
2128
|
+
ExportAutoNumberingSettings: true,
|
|
2129
|
+
ExportCalendarSettings: true,
|
|
2130
|
+
ExportCustomizationSettings: true,
|
|
2131
|
+
ExportEmailTrackingSettings: true,
|
|
2132
|
+
ExportGeneralSettings: true,
|
|
2133
|
+
ExportMarketingSettings: true,
|
|
2134
|
+
ExportOutlookSynchronizationSettings: true,
|
|
2135
|
+
ExportRelationshipRoles: true,
|
|
2136
|
+
ExportIsvConfig: true,
|
|
2137
|
+
ExportSales: true,
|
|
2138
|
+
ExportExternalApplications: true
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Import solution
|
|
2143
|
+
*/
|
|
2144
|
+
async importSolution(customizationFile, publishWorkflows = true, overwriteUnmanagedCustomizations = false) {
|
|
2145
|
+
return await this.makeRequest('api/data/v9.2/ImportSolution', 'POST', {
|
|
2146
|
+
CustomizationFile: customizationFile,
|
|
2147
|
+
PublishWorkflows: publishWorkflows,
|
|
2148
|
+
OverwriteUnmanagedCustomizations: overwriteUnmanagedCustomizations,
|
|
2149
|
+
SkipProductUpdateDependencies: false,
|
|
2150
|
+
HoldingSolution: false,
|
|
2151
|
+
ImportJobId: this.generateGuid()
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
/**
|
|
2155
|
+
* Delete a solution
|
|
2156
|
+
*/
|
|
2157
|
+
async deleteSolution(solutionId) {
|
|
2158
|
+
await this.makeRequest(`api/data/v9.2/solutions(${solutionId})`, 'DELETE');
|
|
2159
|
+
}
|
|
2160
|
+
// ===== Phase 5: Publishing & Validation =====
|
|
2161
|
+
/**
|
|
2162
|
+
* Publish specific entity
|
|
2163
|
+
*/
|
|
2164
|
+
async publishEntity(entityLogicalName) {
|
|
2165
|
+
const parameterXml = `<importexportxml><entities><entity>${entityLogicalName}</entity></entities></importexportxml>`;
|
|
2166
|
+
await this.publishXml(parameterXml);
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* Publish specific component
|
|
2170
|
+
*/
|
|
2171
|
+
async publishComponent(componentId, componentType) {
|
|
2172
|
+
const typeMap = {
|
|
2173
|
+
1: 'entity',
|
|
2174
|
+
2: 'attribute',
|
|
2175
|
+
9: 'optionset',
|
|
2176
|
+
24: 'form',
|
|
2177
|
+
26: 'savedquery',
|
|
2178
|
+
29: 'workflow',
|
|
2179
|
+
60: 'systemform',
|
|
2180
|
+
61: 'webresource'
|
|
2181
|
+
};
|
|
2182
|
+
const componentTypeName = typeMap[componentType] || 'component';
|
|
2183
|
+
const parameterXml = `<importexportxml><${componentTypeName}s><${componentTypeName}>${componentId}</${componentTypeName}></${componentTypeName}s></importexportxml>`;
|
|
2184
|
+
await this.publishXml(parameterXml);
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Check for unpublished customizations
|
|
2188
|
+
*/
|
|
2189
|
+
async checkUnpublishedChanges() {
|
|
2190
|
+
// Query for unpublished customizations using RetrieveUnpublished
|
|
2191
|
+
return await this.makeRequest('api/data/v9.2/RetrieveUnpublished', 'POST', {});
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* Check component dependencies
|
|
2195
|
+
*/
|
|
2196
|
+
async checkDependencies(componentId, componentType) {
|
|
2197
|
+
return await this.makeRequest('api/data/v9.2/RetrieveDependenciesForDelete', 'POST', {
|
|
2198
|
+
ObjectId: componentId,
|
|
2199
|
+
ComponentType: componentType
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
/**
|
|
2203
|
+
* Check if component can be deleted
|
|
2204
|
+
*/
|
|
2205
|
+
async checkDeleteEligibility(componentId, componentType) {
|
|
2206
|
+
try {
|
|
2207
|
+
const result = await this.checkDependencies(componentId, componentType);
|
|
2208
|
+
const dependencies = result.EntityCollection?.Entities || [];
|
|
2209
|
+
return {
|
|
2210
|
+
canDelete: dependencies.length === 0,
|
|
2211
|
+
dependencies: dependencies
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
catch (error) {
|
|
2215
|
+
return {
|
|
2216
|
+
canDelete: false,
|
|
2217
|
+
dependencies: []
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Preview unpublished changes
|
|
2223
|
+
* Returns all components that have unpublished customizations
|
|
2224
|
+
*/
|
|
2225
|
+
async previewUnpublishedChanges() {
|
|
2226
|
+
// Use RetrieveUnpublished action to get unpublished changes
|
|
2227
|
+
return await this.makeRequest('api/data/v9.2/RetrieveUnpublished', 'POST', {});
|
|
2228
|
+
}
|
|
2229
|
+
/**
|
|
2230
|
+
* Check dependencies for a specific component
|
|
2231
|
+
* @param componentId The component ID (GUID)
|
|
2232
|
+
* @param componentType The component type code
|
|
2233
|
+
* @returns Dependency information
|
|
2234
|
+
*/
|
|
2235
|
+
async checkComponentDependencies(componentId, componentType) {
|
|
2236
|
+
// This is an alias for checkDependencies for consistency
|
|
2237
|
+
return await this.checkDependencies(componentId, componentType);
|
|
2238
|
+
}
|
|
2239
|
+
/**
|
|
2240
|
+
* Validate solution integrity
|
|
2241
|
+
* Checks for missing dependencies and other issues
|
|
2242
|
+
* @param solutionUniqueName The unique name of the solution
|
|
2243
|
+
* @returns Validation results
|
|
2244
|
+
*/
|
|
2245
|
+
async validateSolutionIntegrity(solutionUniqueName) {
|
|
2246
|
+
// Get solution components
|
|
2247
|
+
const components = await this.getSolutionComponents(solutionUniqueName);
|
|
2248
|
+
const issues = [];
|
|
2249
|
+
const warnings = [];
|
|
2250
|
+
// Check each component for dependencies
|
|
2251
|
+
for (const component of components.value || []) {
|
|
2252
|
+
try {
|
|
2253
|
+
const deps = await this.checkDependencies(component.objectid, component.componenttype);
|
|
2254
|
+
const dependencies = deps.EntityCollection?.Entities || [];
|
|
2255
|
+
const missingDeps = dependencies.filter((d) => d.Attributes?.ismissing === true);
|
|
2256
|
+
if (missingDeps.length > 0) {
|
|
2257
|
+
issues.push({
|
|
2258
|
+
componentId: component.objectid,
|
|
2259
|
+
componentType: component.componenttype,
|
|
2260
|
+
missingDependencies: missingDeps
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
catch (error) {
|
|
2265
|
+
warnings.push({
|
|
2266
|
+
componentId: component.objectid,
|
|
2267
|
+
componentType: component.componenttype,
|
|
2268
|
+
error: 'Could not check dependencies'
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
return {
|
|
2273
|
+
isValid: issues.length === 0,
|
|
2274
|
+
issues,
|
|
2275
|
+
warnings
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2278
|
+
/**
|
|
2279
|
+
* Validate schema name
|
|
2280
|
+
*/
|
|
2281
|
+
validateSchemaName(schemaName, prefix) {
|
|
2282
|
+
const errors = [];
|
|
2283
|
+
// Check if starts with prefix
|
|
2284
|
+
if (!schemaName.startsWith(prefix)) {
|
|
2285
|
+
errors.push(`Schema name must start with prefix '${prefix}'`);
|
|
2286
|
+
}
|
|
2287
|
+
// Check for invalid characters
|
|
2288
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(schemaName)) {
|
|
2289
|
+
errors.push('Schema name must start with a letter or underscore and contain only letters, numbers, and underscores');
|
|
2290
|
+
}
|
|
2291
|
+
// Check length (max 64 characters for most components)
|
|
2292
|
+
if (schemaName.length > 64) {
|
|
2293
|
+
errors.push('Schema name must be 64 characters or less');
|
|
2294
|
+
}
|
|
2295
|
+
return {
|
|
2296
|
+
valid: errors.length === 0,
|
|
2297
|
+
errors
|
|
2298
|
+
};
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* Get entity customization information
|
|
2302
|
+
*/
|
|
2303
|
+
async getEntityCustomizationInfo(entityLogicalName) {
|
|
2304
|
+
return await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityLogicalName}')?$select=IsCustomizable,IsManaged,IsCustomEntity`);
|
|
2305
|
+
}
|
|
2306
|
+
/**
|
|
2307
|
+
* Check if entity has dependencies
|
|
2308
|
+
*/
|
|
2309
|
+
async checkEntityDependencies(entityLogicalName) {
|
|
2310
|
+
// First get the metadata ID
|
|
2311
|
+
const entityMetadata = await this.getEntityMetadata(entityLogicalName);
|
|
2312
|
+
if (!entityMetadata.MetadataId) {
|
|
2313
|
+
throw new Error(`Could not find MetadataId for entity '${entityLogicalName}'`);
|
|
2314
|
+
}
|
|
2315
|
+
// Component type 1 = Entity
|
|
2316
|
+
return await this.checkDependencies(entityMetadata.MetadataId, 1);
|
|
2317
|
+
}
|
|
2318
|
+
/**
|
|
2319
|
+
* Helper to generate GUID
|
|
2320
|
+
*/
|
|
2321
|
+
generateGuid() {
|
|
2322
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
2323
|
+
const r = Math.random() * 16 | 0;
|
|
2324
|
+
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
2325
|
+
return v.toString(16);
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
//# sourceMappingURL=PowerPlatformService.js.map
|