@mcp-consultant-tools/powerplatform-data 1.0.0

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