@mcp-consultant-tools/powerplatform-data 25.0.0 → 26.0.0-beta.4

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