mcp-consultant-tools 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +968 -0
- package/build/AzureDevOpsService.js +394 -0
- package/build/PowerPlatformService.js +655 -0
- package/build/index.js +2010 -0
- package/package.json +48 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { ConfidentialClientApplication } from '@azure/msal-node';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
export class PowerPlatformService {
|
|
4
|
+
config;
|
|
5
|
+
msalClient;
|
|
6
|
+
accessToken = null;
|
|
7
|
+
tokenExpirationTime = 0;
|
|
8
|
+
constructor(config) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
// Initialize MSAL client
|
|
11
|
+
this.msalClient = new ConfidentialClientApplication({
|
|
12
|
+
auth: {
|
|
13
|
+
clientId: this.config.clientId,
|
|
14
|
+
clientSecret: this.config.clientSecret,
|
|
15
|
+
authority: `https://login.microsoftonline.com/${this.config.tenantId}`,
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Get an access token for the PowerPlatform API
|
|
21
|
+
*/
|
|
22
|
+
async getAccessToken() {
|
|
23
|
+
const currentTime = Date.now();
|
|
24
|
+
// If we have a token that isn't expired, return it
|
|
25
|
+
if (this.accessToken && this.tokenExpirationTime > currentTime) {
|
|
26
|
+
return this.accessToken;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
// Get a new token
|
|
30
|
+
const result = await this.msalClient.acquireTokenByClientCredential({
|
|
31
|
+
scopes: [`${this.config.organizationUrl}/.default`],
|
|
32
|
+
});
|
|
33
|
+
if (!result || !result.accessToken) {
|
|
34
|
+
throw new Error('Failed to acquire access token');
|
|
35
|
+
}
|
|
36
|
+
this.accessToken = result.accessToken;
|
|
37
|
+
// Set expiration time (subtract 5 minutes to refresh early)
|
|
38
|
+
if (result.expiresOn) {
|
|
39
|
+
this.tokenExpirationTime = result.expiresOn.getTime() - (5 * 60 * 1000);
|
|
40
|
+
}
|
|
41
|
+
return this.accessToken;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
console.error('Error acquiring access token:', error);
|
|
45
|
+
throw new Error('Authentication failed');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Make an authenticated request to the PowerPlatform API
|
|
50
|
+
*/
|
|
51
|
+
async makeRequest(endpoint) {
|
|
52
|
+
try {
|
|
53
|
+
const token = await this.getAccessToken();
|
|
54
|
+
const response = await axios({
|
|
55
|
+
method: 'GET',
|
|
56
|
+
url: `${this.config.organizationUrl}/${endpoint}`,
|
|
57
|
+
headers: {
|
|
58
|
+
'Authorization': `Bearer ${token}`,
|
|
59
|
+
'Accept': 'application/json',
|
|
60
|
+
'OData-MaxVersion': '4.0',
|
|
61
|
+
'OData-Version': '4.0'
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return response.data;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
const errorDetails = error.response?.data?.error || error.response?.data || error.message;
|
|
68
|
+
console.error('PowerPlatform API request failed:', {
|
|
69
|
+
endpoint,
|
|
70
|
+
status: error.response?.status,
|
|
71
|
+
statusText: error.response?.statusText,
|
|
72
|
+
error: errorDetails
|
|
73
|
+
});
|
|
74
|
+
throw new Error(`PowerPlatform API request failed: ${error.message} - ${JSON.stringify(errorDetails)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Get metadata about an entity
|
|
79
|
+
* @param entityName The logical name of the entity
|
|
80
|
+
*/
|
|
81
|
+
async getEntityMetadata(entityName) {
|
|
82
|
+
const response = await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')`);
|
|
83
|
+
// Remove Privileges property if it exists
|
|
84
|
+
if (response && typeof response === 'object' && 'Privileges' in response) {
|
|
85
|
+
delete response.Privileges;
|
|
86
|
+
}
|
|
87
|
+
return response;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Get metadata about entity attributes/fields
|
|
91
|
+
* @param entityName The logical name of the entity
|
|
92
|
+
*/
|
|
93
|
+
async getEntityAttributes(entityName) {
|
|
94
|
+
const selectProperties = [
|
|
95
|
+
'LogicalName',
|
|
96
|
+
].join(',');
|
|
97
|
+
// Make the request to get attributes
|
|
98
|
+
const response = await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/Attributes?$select=${selectProperties}&$filter=AttributeType ne 'Virtual'`);
|
|
99
|
+
if (response && response.value) {
|
|
100
|
+
// First pass: Filter out attributes that end with 'yominame'
|
|
101
|
+
response.value = response.value.filter((attribute) => {
|
|
102
|
+
const logicalName = attribute.LogicalName || '';
|
|
103
|
+
return !logicalName.endsWith('yominame');
|
|
104
|
+
});
|
|
105
|
+
// Filter out attributes that end with 'name' if there is another attribute with the same name without the 'name' suffix
|
|
106
|
+
const baseNames = new Set();
|
|
107
|
+
const namesAttributes = new Map();
|
|
108
|
+
for (const attribute of response.value) {
|
|
109
|
+
const logicalName = attribute.LogicalName || '';
|
|
110
|
+
if (logicalName.endsWith('name') && logicalName.length > 4) {
|
|
111
|
+
const baseName = logicalName.slice(0, -4); // Remove 'name' suffix
|
|
112
|
+
namesAttributes.set(baseName, attribute);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// This is a potential base attribute
|
|
116
|
+
baseNames.add(logicalName);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Find attributes to remove that match the pattern
|
|
120
|
+
const attributesToRemove = new Set();
|
|
121
|
+
for (const [baseName, nameAttribute] of namesAttributes.entries()) {
|
|
122
|
+
if (baseNames.has(baseName)) {
|
|
123
|
+
attributesToRemove.add(nameAttribute);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
response.value = response.value.filter(attribute => !attributesToRemove.has(attribute));
|
|
127
|
+
}
|
|
128
|
+
return response;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Get metadata about a specific entity attribute/field
|
|
132
|
+
* @param entityName The logical name of the entity
|
|
133
|
+
* @param attributeName The logical name of the attribute
|
|
134
|
+
*/
|
|
135
|
+
async getEntityAttribute(entityName, attributeName) {
|
|
136
|
+
return this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/Attributes(LogicalName='${attributeName}')`);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Get one-to-many relationships for an entity
|
|
140
|
+
* @param entityName The logical name of the entity
|
|
141
|
+
*/
|
|
142
|
+
async getEntityOneToManyRelationships(entityName) {
|
|
143
|
+
const selectProperties = [
|
|
144
|
+
'SchemaName',
|
|
145
|
+
'RelationshipType',
|
|
146
|
+
'ReferencedAttribute',
|
|
147
|
+
'ReferencedEntity',
|
|
148
|
+
'ReferencingAttribute',
|
|
149
|
+
'ReferencingEntity',
|
|
150
|
+
'ReferencedEntityNavigationPropertyName',
|
|
151
|
+
'ReferencingEntityNavigationPropertyName'
|
|
152
|
+
].join(',');
|
|
153
|
+
// Only filter by ReferencingAttribute in the OData query since startswith isn't supported
|
|
154
|
+
const response = await this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/OneToManyRelationships?$select=${selectProperties}&$filter=ReferencingAttribute ne 'regardingobjectid'`);
|
|
155
|
+
// Filter the response to exclude relationships with ReferencingEntity starting with 'msdyn_' or 'adx_'
|
|
156
|
+
if (response && response.value) {
|
|
157
|
+
response.value = response.value.filter((relationship) => {
|
|
158
|
+
const referencingEntity = relationship.ReferencingEntity || '';
|
|
159
|
+
return !(referencingEntity.startsWith('msdyn_') || referencingEntity.startsWith('adx_'));
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return response;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Get many-to-many relationships for an entity
|
|
166
|
+
* @param entityName The logical name of the entity
|
|
167
|
+
*/
|
|
168
|
+
async getEntityManyToManyRelationships(entityName) {
|
|
169
|
+
const selectProperties = [
|
|
170
|
+
'SchemaName',
|
|
171
|
+
'RelationshipType',
|
|
172
|
+
'Entity1LogicalName',
|
|
173
|
+
'Entity2LogicalName',
|
|
174
|
+
'Entity1IntersectAttribute',
|
|
175
|
+
'Entity2IntersectAttribute',
|
|
176
|
+
'Entity1NavigationPropertyName',
|
|
177
|
+
'Entity2NavigationPropertyName'
|
|
178
|
+
].join(',');
|
|
179
|
+
return this.makeRequest(`api/data/v9.2/EntityDefinitions(LogicalName='${entityName}')/ManyToManyRelationships?$select=${selectProperties}`);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Get all relationships (one-to-many and many-to-many) for an entity
|
|
183
|
+
* @param entityName The logical name of the entity
|
|
184
|
+
*/
|
|
185
|
+
async getEntityRelationships(entityName) {
|
|
186
|
+
const [oneToMany, manyToMany] = await Promise.all([
|
|
187
|
+
this.getEntityOneToManyRelationships(entityName),
|
|
188
|
+
this.getEntityManyToManyRelationships(entityName)
|
|
189
|
+
]);
|
|
190
|
+
return {
|
|
191
|
+
oneToMany,
|
|
192
|
+
manyToMany
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Get a global option set definition by name
|
|
197
|
+
* @param optionSetName The name of the global option set
|
|
198
|
+
* @returns The global option set definition
|
|
199
|
+
*/
|
|
200
|
+
async getGlobalOptionSet(optionSetName) {
|
|
201
|
+
return this.makeRequest(`api/data/v9.2/GlobalOptionSetDefinitions(Name='${optionSetName}')`);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Get a specific record by entity name (plural) and ID
|
|
205
|
+
* @param entityNamePlural The plural name of the entity (e.g., 'accounts', 'contacts')
|
|
206
|
+
* @param recordId The GUID of the record
|
|
207
|
+
* @returns The record data
|
|
208
|
+
*/
|
|
209
|
+
async getRecord(entityNamePlural, recordId) {
|
|
210
|
+
return this.makeRequest(`api/data/v9.2/${entityNamePlural}(${recordId})`);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Query records using entity name (plural) and a filter expression
|
|
214
|
+
* @param entityNamePlural The plural name of the entity (e.g., 'accounts', 'contacts')
|
|
215
|
+
* @param filter OData filter expression (e.g., "name eq 'test'")
|
|
216
|
+
* @param maxRecords Maximum number of records to retrieve (default: 50)
|
|
217
|
+
* @returns Filtered list of records
|
|
218
|
+
*/
|
|
219
|
+
async queryRecords(entityNamePlural, filter, maxRecords = 50) {
|
|
220
|
+
return this.makeRequest(`api/data/v9.2/${entityNamePlural}?$filter=${encodeURIComponent(filter)}&$top=${maxRecords}`);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Get all plugin assemblies in the environment
|
|
224
|
+
* @param includeManaged Include managed assemblies (default: false)
|
|
225
|
+
* @param maxRecords Maximum number of assemblies to return (default: 100)
|
|
226
|
+
* @returns List of plugin assemblies with basic information
|
|
227
|
+
*/
|
|
228
|
+
async getPluginAssemblies(includeManaged = false, maxRecords = 100) {
|
|
229
|
+
const managedFilter = includeManaged ? '' : '$filter=ismanaged eq false&';
|
|
230
|
+
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}`);
|
|
231
|
+
// Filter out hidden assemblies and format the results with more readable properties
|
|
232
|
+
// Note: ishidden is a ManagedProperty object with a Value property
|
|
233
|
+
const formattedAssemblies = assemblies.value
|
|
234
|
+
.filter((assembly) => {
|
|
235
|
+
const isHidden = assembly.ishidden?.Value !== undefined ? assembly.ishidden.Value : assembly.ishidden;
|
|
236
|
+
return !isHidden;
|
|
237
|
+
})
|
|
238
|
+
.map((assembly) => ({
|
|
239
|
+
pluginassemblyid: assembly.pluginassemblyid,
|
|
240
|
+
name: assembly.name,
|
|
241
|
+
version: assembly.version,
|
|
242
|
+
isolationMode: assembly.isolationmode === 1 ? 'None' : assembly.isolationmode === 2 ? 'Sandbox' : 'External',
|
|
243
|
+
isManaged: assembly.ismanaged,
|
|
244
|
+
modifiedOn: assembly.modifiedon,
|
|
245
|
+
modifiedBy: assembly.modifiedby?.fullname,
|
|
246
|
+
major: assembly.major,
|
|
247
|
+
minor: assembly.minor
|
|
248
|
+
}));
|
|
249
|
+
return {
|
|
250
|
+
totalCount: formattedAssemblies.length,
|
|
251
|
+
assemblies: formattedAssemblies
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Get a plugin assembly by name with all related plugin types, steps, and images
|
|
256
|
+
* @param assemblyName The name of the plugin assembly
|
|
257
|
+
* @param includeDisabled Include disabled steps (default: false)
|
|
258
|
+
* @returns Complete plugin assembly information with validation
|
|
259
|
+
*/
|
|
260
|
+
async getPluginAssemblyComplete(assemblyName, includeDisabled = false) {
|
|
261
|
+
// Get the plugin assembly (excluding content_binary which is large and not useful for review)
|
|
262
|
+
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)`);
|
|
263
|
+
if (!assemblies.value || assemblies.value.length === 0) {
|
|
264
|
+
throw new Error(`Plugin assembly '${assemblyName}' not found`);
|
|
265
|
+
}
|
|
266
|
+
const assembly = assemblies.value[0];
|
|
267
|
+
const assemblyId = assembly.pluginassemblyid;
|
|
268
|
+
// Get plugin types for this assembly
|
|
269
|
+
const pluginTypes = await this.makeRequest(`api/data/v9.2/plugintypes?$filter=_pluginassemblyid_value eq ${assemblyId}&$select=plugintypeid,typename,friendlyname,name,assemblyname,description,workflowactivitygroupname`);
|
|
270
|
+
// Get all steps for each plugin type
|
|
271
|
+
const pluginTypeIds = pluginTypes.value.map((pt) => pt.plugintypeid);
|
|
272
|
+
let allSteps = [];
|
|
273
|
+
if (pluginTypeIds.length > 0) {
|
|
274
|
+
const statusFilter = includeDisabled ? '' : ' and statuscode eq 1';
|
|
275
|
+
// Build filter for all plugin type IDs
|
|
276
|
+
const typeFilter = pluginTypeIds.map((id) => `_plugintypeid_value eq ${id}`).join(' or ');
|
|
277
|
+
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`);
|
|
278
|
+
allSteps = steps.value;
|
|
279
|
+
}
|
|
280
|
+
// Get all images for these steps
|
|
281
|
+
const stepIds = allSteps.map((s) => s.sdkmessageprocessingstepid);
|
|
282
|
+
let allImages = [];
|
|
283
|
+
if (stepIds.length > 0) {
|
|
284
|
+
// Build filter for all step IDs
|
|
285
|
+
const imageFilter = stepIds.map((id) => `_sdkmessageprocessingstepid_value eq ${id}`).join(' or ');
|
|
286
|
+
const images = await this.makeRequest(`api/data/v9.2/sdkmessageprocessingstepimages?$filter=${imageFilter}&$select=sdkmessageprocessingstepimageid,name,imagetype,messagepropertyname,entityalias,attributes,_sdkmessageprocessingstepid_value`);
|
|
287
|
+
allImages = images.value;
|
|
288
|
+
}
|
|
289
|
+
// Attach images to their respective steps
|
|
290
|
+
const stepsWithImages = allSteps.map((step) => ({
|
|
291
|
+
...step,
|
|
292
|
+
images: allImages.filter((img) => img._sdkmessageprocessingstepid_value === step.sdkmessageprocessingstepid)
|
|
293
|
+
}));
|
|
294
|
+
// Validation checks
|
|
295
|
+
const validation = {
|
|
296
|
+
hasDisabledSteps: allSteps.some((s) => s.statuscode !== 1),
|
|
297
|
+
hasAsyncSteps: allSteps.some((s) => s.mode === 1),
|
|
298
|
+
hasSyncSteps: allSteps.some((s) => s.mode === 0),
|
|
299
|
+
stepsWithoutFilteringAttributes: stepsWithImages
|
|
300
|
+
.filter((s) => (s.sdkmessageid?.name === 'Update' || s.sdkmessageid?.name === 'Delete') && !s.filteringattributes)
|
|
301
|
+
.map((s) => s.name),
|
|
302
|
+
stepsWithoutImages: stepsWithImages
|
|
303
|
+
.filter((s) => s.images.length === 0 && (s.sdkmessageid?.name === 'Update' || s.sdkmessageid?.name === 'Delete'))
|
|
304
|
+
.map((s) => s.name),
|
|
305
|
+
potentialIssues: []
|
|
306
|
+
};
|
|
307
|
+
// Add potential issues
|
|
308
|
+
if (validation.stepsWithoutFilteringAttributes.length > 0) {
|
|
309
|
+
validation.potentialIssues.push(`${validation.stepsWithoutFilteringAttributes.length} Update/Delete steps without filtering attributes (performance concern)`);
|
|
310
|
+
}
|
|
311
|
+
if (validation.stepsWithoutImages.length > 0) {
|
|
312
|
+
validation.potentialIssues.push(`${validation.stepsWithoutImages.length} Update/Delete steps without images (may need entity data)`);
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
assembly,
|
|
316
|
+
pluginTypes: pluginTypes.value,
|
|
317
|
+
steps: stepsWithImages,
|
|
318
|
+
validation
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Get all plugins that execute on a specific entity, organized by message and execution order
|
|
323
|
+
* @param entityName The logical name of the entity
|
|
324
|
+
* @param messageFilter Optional filter by message name (e.g., "Create", "Update")
|
|
325
|
+
* @param includeDisabled Include disabled steps (default: false)
|
|
326
|
+
* @returns Complete plugin pipeline for the entity
|
|
327
|
+
*/
|
|
328
|
+
async getEntityPluginPipeline(entityName, messageFilter, includeDisabled = false) {
|
|
329
|
+
const statusFilter = includeDisabled ? '' : ' and statuscode eq 1';
|
|
330
|
+
const msgFilter = messageFilter ? ` and sdkmessageid/name eq '${messageFilter}'` : '';
|
|
331
|
+
// Get all steps for this entity
|
|
332
|
+
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`);
|
|
333
|
+
// Get assembly information for each plugin type (filter out nulls)
|
|
334
|
+
const pluginTypeIds = [...new Set(steps.value.map((s) => s._plugintypeid_value).filter((id) => id != null))];
|
|
335
|
+
const assemblyMap = new Map();
|
|
336
|
+
for (const typeId of pluginTypeIds) {
|
|
337
|
+
const pluginType = await this.makeRequest(`api/data/v9.2/plugintypes(${typeId})?$expand=pluginassemblyid($select=name,version)`);
|
|
338
|
+
assemblyMap.set(typeId, pluginType.pluginassemblyid);
|
|
339
|
+
}
|
|
340
|
+
// Get images for all steps
|
|
341
|
+
const stepIds = steps.value.map((s) => s.sdkmessageprocessingstepid);
|
|
342
|
+
let allImages = [];
|
|
343
|
+
if (stepIds.length > 0) {
|
|
344
|
+
const imageFilter = stepIds.map((id) => `_sdkmessageprocessingstepid_value eq ${id}`).join(' or ');
|
|
345
|
+
const images = await this.makeRequest(`api/data/v9.2/sdkmessageprocessingstepimages?$filter=${imageFilter}&$select=sdkmessageprocessingstepimageid,name,imagetype,messagepropertyname,entityalias,attributes,_sdkmessageprocessingstepid_value`);
|
|
346
|
+
allImages = images.value;
|
|
347
|
+
}
|
|
348
|
+
// Format steps with all information
|
|
349
|
+
const formattedSteps = steps.value.map((step) => {
|
|
350
|
+
const assembly = assemblyMap.get(step._plugintypeid_value);
|
|
351
|
+
const images = allImages.filter((img) => img._sdkmessageprocessingstepid_value === step.sdkmessageprocessingstepid);
|
|
352
|
+
return {
|
|
353
|
+
sdkmessageprocessingstepid: step.sdkmessageprocessingstepid,
|
|
354
|
+
name: step.name,
|
|
355
|
+
stage: step.stage,
|
|
356
|
+
stageName: step.stage === 10 ? 'PreValidation' : step.stage === 20 ? 'PreOperation' : 'PostOperation',
|
|
357
|
+
mode: step.mode,
|
|
358
|
+
modeName: step.mode === 0 ? 'Synchronous' : 'Asynchronous',
|
|
359
|
+
rank: step.rank,
|
|
360
|
+
message: step.sdkmessageid?.name,
|
|
361
|
+
pluginType: step.plugintypeid?.typename,
|
|
362
|
+
assemblyName: assembly?.name,
|
|
363
|
+
assemblyVersion: assembly?.version,
|
|
364
|
+
filteringAttributes: step.filteringattributes ? step.filteringattributes.split(',') : [],
|
|
365
|
+
statuscode: step.statuscode,
|
|
366
|
+
enabled: step.statuscode === 1,
|
|
367
|
+
deployment: step.supporteddeployment === 0 ? 'Server' : step.supporteddeployment === 1 ? 'Offline' : 'Both',
|
|
368
|
+
impersonatingUser: step.impersonatinguserid?.fullname,
|
|
369
|
+
hasPreImage: images.some((img) => img.imagetype === 0 || img.imagetype === 2),
|
|
370
|
+
hasPostImage: images.some((img) => img.imagetype === 1 || img.imagetype === 2),
|
|
371
|
+
images: images
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
// Organize by message
|
|
375
|
+
const messageGroups = new Map();
|
|
376
|
+
formattedSteps.forEach((step) => {
|
|
377
|
+
if (!messageGroups.has(step.message)) {
|
|
378
|
+
messageGroups.set(step.message, {
|
|
379
|
+
messageName: step.message,
|
|
380
|
+
stages: {
|
|
381
|
+
preValidation: [],
|
|
382
|
+
preOperation: [],
|
|
383
|
+
postOperation: []
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const msg = messageGroups.get(step.message);
|
|
388
|
+
if (step.stage === 10)
|
|
389
|
+
msg.stages.preValidation.push(step);
|
|
390
|
+
else if (step.stage === 20)
|
|
391
|
+
msg.stages.preOperation.push(step);
|
|
392
|
+
else if (step.stage === 40)
|
|
393
|
+
msg.stages.postOperation.push(step);
|
|
394
|
+
});
|
|
395
|
+
return {
|
|
396
|
+
entity: entityName,
|
|
397
|
+
messages: Array.from(messageGroups.values()),
|
|
398
|
+
steps: formattedSteps,
|
|
399
|
+
executionOrder: formattedSteps.map((s) => s.name)
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Get plugin trace logs with filtering
|
|
404
|
+
* @param options Filtering options for trace logs
|
|
405
|
+
* @returns Filtered trace logs with parsed exception details
|
|
406
|
+
*/
|
|
407
|
+
async getPluginTraceLogs(options) {
|
|
408
|
+
const { entityName, messageName, correlationId, pluginStepId, exceptionOnly = false, hoursBack = 24, maxRecords = 50 } = options;
|
|
409
|
+
// Build filter
|
|
410
|
+
const filters = [];
|
|
411
|
+
// Date filter
|
|
412
|
+
const dateThreshold = new Date();
|
|
413
|
+
dateThreshold.setHours(dateThreshold.getHours() - hoursBack);
|
|
414
|
+
filters.push(`createdon gt ${dateThreshold.toISOString()}`);
|
|
415
|
+
if (entityName)
|
|
416
|
+
filters.push(`primaryentity eq '${entityName}'`);
|
|
417
|
+
if (messageName)
|
|
418
|
+
filters.push(`messagename eq '${messageName}'`);
|
|
419
|
+
if (correlationId)
|
|
420
|
+
filters.push(`correlationid eq '${correlationId}'`);
|
|
421
|
+
if (pluginStepId)
|
|
422
|
+
filters.push(`_sdkmessageprocessingstepid_value eq ${pluginStepId}`);
|
|
423
|
+
if (exceptionOnly)
|
|
424
|
+
filters.push(`exceptiondetails ne null`);
|
|
425
|
+
const filterString = filters.join(' and ');
|
|
426
|
+
const logs = await this.makeRequest(`api/data/v9.2/plugintracelogs?$filter=${filterString}&$orderby=createdon desc&$top=${maxRecords}`);
|
|
427
|
+
// Parse logs for better readability
|
|
428
|
+
const parsedLogs = logs.value.map((log) => ({
|
|
429
|
+
...log,
|
|
430
|
+
modeName: log.mode === 0 ? 'Synchronous' : 'Asynchronous',
|
|
431
|
+
operationTypeName: this.getOperationTypeName(log.operationtype),
|
|
432
|
+
parsed: {
|
|
433
|
+
hasException: !!log.exceptiondetails,
|
|
434
|
+
exceptionType: log.exceptiondetails ? this.extractExceptionType(log.exceptiondetails) : null,
|
|
435
|
+
exceptionMessage: log.exceptiondetails ? this.extractExceptionMessage(log.exceptiondetails) : null,
|
|
436
|
+
stackTrace: log.exceptiondetails
|
|
437
|
+
}
|
|
438
|
+
}));
|
|
439
|
+
return {
|
|
440
|
+
totalCount: parsedLogs.length,
|
|
441
|
+
logs: parsedLogs
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
// Helper methods for trace log parsing
|
|
445
|
+
getOperationTypeName(operationType) {
|
|
446
|
+
const types = {
|
|
447
|
+
0: 'None',
|
|
448
|
+
1: 'Create',
|
|
449
|
+
2: 'Update',
|
|
450
|
+
3: 'Delete',
|
|
451
|
+
4: 'Retrieve',
|
|
452
|
+
5: 'RetrieveMultiple',
|
|
453
|
+
6: 'Associate',
|
|
454
|
+
7: 'Disassociate'
|
|
455
|
+
};
|
|
456
|
+
return types[operationType] || 'Unknown';
|
|
457
|
+
}
|
|
458
|
+
extractExceptionType(exceptionDetails) {
|
|
459
|
+
const match = exceptionDetails.match(/^([^:]+):/);
|
|
460
|
+
return match ? match[1].trim() : null;
|
|
461
|
+
}
|
|
462
|
+
extractExceptionMessage(exceptionDetails) {
|
|
463
|
+
const lines = exceptionDetails.split('\n');
|
|
464
|
+
if (lines.length > 0) {
|
|
465
|
+
const firstLine = lines[0];
|
|
466
|
+
const colonIndex = firstLine.indexOf(':');
|
|
467
|
+
if (colonIndex > 0) {
|
|
468
|
+
return firstLine.substring(colonIndex + 1).trim();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Get all Power Automate flows (cloud flows) in the environment
|
|
475
|
+
* @param activeOnly Only return activated flows (default: false)
|
|
476
|
+
* @param maxRecords Maximum number of flows to return (default: 100)
|
|
477
|
+
* @returns List of Power Automate flows with basic information
|
|
478
|
+
*/
|
|
479
|
+
async getFlows(activeOnly = false, maxRecords = 100) {
|
|
480
|
+
// Category 5 = Modern Flow (Power Automate cloud flows)
|
|
481
|
+
// StateCode: 0=Draft, 1=Activated, 2=Suspended
|
|
482
|
+
// Type: 1=Definition, 2=Activation
|
|
483
|
+
const stateFilter = activeOnly ? ' and statecode eq 1' : '';
|
|
484
|
+
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}`);
|
|
485
|
+
// Format the results for better readability
|
|
486
|
+
const formattedFlows = flows.value.map((flow) => ({
|
|
487
|
+
workflowid: flow.workflowid,
|
|
488
|
+
name: flow.name,
|
|
489
|
+
description: flow.description,
|
|
490
|
+
state: flow.statecode === 0 ? 'Draft' : flow.statecode === 1 ? 'Activated' : 'Suspended',
|
|
491
|
+
statecode: flow.statecode,
|
|
492
|
+
statuscode: flow.statuscode,
|
|
493
|
+
type: flow.type === 1 ? 'Definition' : flow.type === 2 ? 'Activation' : 'Template',
|
|
494
|
+
primaryEntity: flow.primaryentity,
|
|
495
|
+
isManaged: flow.ismanaged,
|
|
496
|
+
ownerId: flow._ownerid_value,
|
|
497
|
+
modifiedOn: flow.modifiedon,
|
|
498
|
+
modifiedBy: flow.modifiedby?.fullname,
|
|
499
|
+
createdOn: flow.createdon,
|
|
500
|
+
hasDefinition: !!flow.clientdata
|
|
501
|
+
}));
|
|
502
|
+
return {
|
|
503
|
+
totalCount: formattedFlows.length,
|
|
504
|
+
flows: formattedFlows
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Get a specific Power Automate flow with its complete definition
|
|
509
|
+
* @param flowId The GUID of the flow (workflowid)
|
|
510
|
+
* @returns Complete flow information including the flow definition JSON
|
|
511
|
+
*/
|
|
512
|
+
async getFlowDefinition(flowId) {
|
|
513
|
+
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)`);
|
|
514
|
+
// Parse the clientdata (flow definition) if it exists
|
|
515
|
+
let flowDefinition = null;
|
|
516
|
+
if (flow.clientdata) {
|
|
517
|
+
try {
|
|
518
|
+
flowDefinition = JSON.parse(flow.clientdata);
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
console.error('Failed to parse flow definition JSON:', error);
|
|
522
|
+
flowDefinition = { parseError: 'Failed to parse flow definition', raw: flow.clientdata };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
workflowid: flow.workflowid,
|
|
527
|
+
name: flow.name,
|
|
528
|
+
description: flow.description,
|
|
529
|
+
state: flow.statecode === 0 ? 'Draft' : flow.statecode === 1 ? 'Activated' : 'Suspended',
|
|
530
|
+
statecode: flow.statecode,
|
|
531
|
+
statuscode: flow.statuscode,
|
|
532
|
+
type: flow.type === 1 ? 'Definition' : flow.type === 2 ? 'Activation' : 'Template',
|
|
533
|
+
category: flow.category,
|
|
534
|
+
primaryEntity: flow.primaryentity,
|
|
535
|
+
isManaged: flow.ismanaged,
|
|
536
|
+
ownerId: flow._ownerid_value,
|
|
537
|
+
createdOn: flow.createdon,
|
|
538
|
+
createdBy: flow.createdby?.fullname,
|
|
539
|
+
modifiedOn: flow.modifiedon,
|
|
540
|
+
modifiedBy: flow.modifiedby?.fullname,
|
|
541
|
+
flowDefinition: flowDefinition
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Get flow run history for a specific Power Automate flow
|
|
546
|
+
* @param flowId The GUID of the flow (workflowid)
|
|
547
|
+
* @param maxRecords Maximum number of runs to return (default: 100)
|
|
548
|
+
* @returns List of flow runs with status, start time, duration, and error details
|
|
549
|
+
*/
|
|
550
|
+
async getFlowRuns(flowId, maxRecords = 100) {
|
|
551
|
+
// Flow runs are stored in the flowruns entity (not flowsession)
|
|
552
|
+
// Status: "Succeeded", "Failed", "Faulted", "TimedOut", "Cancelled", "Running", etc.
|
|
553
|
+
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}`);
|
|
554
|
+
// Format the results for better readability
|
|
555
|
+
const formattedRuns = flowRuns.value.map((run) => {
|
|
556
|
+
// Parse error message if it's JSON
|
|
557
|
+
let parsedError = run.errormessage;
|
|
558
|
+
if (run.errormessage) {
|
|
559
|
+
try {
|
|
560
|
+
parsedError = JSON.parse(run.errormessage);
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
// Keep as string if not valid JSON
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
flowrunid: run.flowrunid,
|
|
568
|
+
name: run.name,
|
|
569
|
+
status: run.status,
|
|
570
|
+
startedOn: run.starttime,
|
|
571
|
+
completedOn: run.endtime,
|
|
572
|
+
duration: run.duration,
|
|
573
|
+
errorMessage: parsedError || null,
|
|
574
|
+
errorCode: run.errorcode || null,
|
|
575
|
+
triggerType: run.triggertype || null
|
|
576
|
+
};
|
|
577
|
+
});
|
|
578
|
+
return {
|
|
579
|
+
flowId: flowId,
|
|
580
|
+
totalCount: formattedRuns.length,
|
|
581
|
+
runs: formattedRuns
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Get all classic Dynamics workflows in the environment
|
|
586
|
+
* @param activeOnly Only return activated workflows (default: false)
|
|
587
|
+
* @param maxRecords Maximum number of workflows to return (default: 100)
|
|
588
|
+
* @returns List of classic workflows with basic information
|
|
589
|
+
*/
|
|
590
|
+
async getWorkflows(activeOnly = false, maxRecords = 100) {
|
|
591
|
+
// Category 0 = Classic Workflow
|
|
592
|
+
// StateCode: 0=Draft, 1=Activated, 2=Suspended
|
|
593
|
+
// Type: 1=Definition, 2=Activation
|
|
594
|
+
const stateFilter = activeOnly ? ' and statecode eq 1' : '';
|
|
595
|
+
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}`);
|
|
596
|
+
// Format the results for better readability
|
|
597
|
+
const formattedWorkflows = workflows.value.map((workflow) => ({
|
|
598
|
+
workflowid: workflow.workflowid,
|
|
599
|
+
name: workflow.name,
|
|
600
|
+
description: workflow.description,
|
|
601
|
+
state: workflow.statecode === 0 ? 'Draft' : workflow.statecode === 1 ? 'Activated' : 'Suspended',
|
|
602
|
+
statecode: workflow.statecode,
|
|
603
|
+
statuscode: workflow.statuscode,
|
|
604
|
+
type: workflow.type === 1 ? 'Definition' : workflow.type === 2 ? 'Activation' : 'Template',
|
|
605
|
+
mode: workflow.mode === 0 ? 'Background' : 'Real-time',
|
|
606
|
+
primaryEntity: workflow.primaryentity,
|
|
607
|
+
isManaged: workflow.ismanaged,
|
|
608
|
+
isOnDemand: workflow.ondemand,
|
|
609
|
+
triggerOnCreate: workflow.triggeroncreate,
|
|
610
|
+
triggerOnDelete: workflow.triggerondelete,
|
|
611
|
+
isSubprocess: workflow.subprocess,
|
|
612
|
+
owner: workflow.ownerid?.fullname,
|
|
613
|
+
modifiedOn: workflow.modifiedon,
|
|
614
|
+
modifiedBy: workflow.modifiedby?.fullname,
|
|
615
|
+
createdOn: workflow.createdon
|
|
616
|
+
}));
|
|
617
|
+
return {
|
|
618
|
+
totalCount: formattedWorkflows.length,
|
|
619
|
+
workflows: formattedWorkflows
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Get a specific classic workflow with its complete XAML definition
|
|
624
|
+
* @param workflowId The GUID of the workflow (workflowid)
|
|
625
|
+
* @returns Complete workflow information including the XAML definition
|
|
626
|
+
*/
|
|
627
|
+
async getWorkflowDefinition(workflowId) {
|
|
628
|
+
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)`);
|
|
629
|
+
return {
|
|
630
|
+
workflowid: workflow.workflowid,
|
|
631
|
+
name: workflow.name,
|
|
632
|
+
description: workflow.description,
|
|
633
|
+
state: workflow.statecode === 0 ? 'Draft' : workflow.statecode === 1 ? 'Activated' : 'Suspended',
|
|
634
|
+
statecode: workflow.statecode,
|
|
635
|
+
statuscode: workflow.statuscode,
|
|
636
|
+
type: workflow.type === 1 ? 'Definition' : workflow.type === 2 ? 'Activation' : 'Template',
|
|
637
|
+
category: workflow.category,
|
|
638
|
+
mode: workflow.mode === 0 ? 'Background' : 'Real-time',
|
|
639
|
+
primaryEntity: workflow.primaryentity,
|
|
640
|
+
isManaged: workflow.ismanaged,
|
|
641
|
+
isOnDemand: workflow.ondemand,
|
|
642
|
+
triggerOnCreate: workflow.triggeroncreate,
|
|
643
|
+
triggerOnDelete: workflow.triggerondelete,
|
|
644
|
+
triggerOnUpdateAttributes: workflow.triggeronupdateattributelist ? workflow.triggeronupdateattributelist.split(',') : [],
|
|
645
|
+
isSubprocess: workflow.subprocess,
|
|
646
|
+
syncWorkflowLogOnFailure: workflow.syncworkflowlogonfailure,
|
|
647
|
+
owner: workflow.ownerid?.fullname,
|
|
648
|
+
createdOn: workflow.createdon,
|
|
649
|
+
createdBy: workflow.createdby?.fullname,
|
|
650
|
+
modifiedOn: workflow.modifiedon,
|
|
651
|
+
modifiedBy: workflow.modifiedby?.fullname,
|
|
652
|
+
xaml: workflow.xaml
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
}
|