@mcp-consultant-tools/sharepoint 33.0.0 → 35.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1277 +0,0 @@
1
- /**
2
- * SharePoint Online Service
3
- *
4
- * Provides read-only access to SharePoint Online sites, document libraries, and files
5
- * through Microsoft Graph API with comprehensive PowerPlatform validation capabilities.
6
- *
7
- * Authentication: Microsoft Entra ID (OAuth 2.0) via MSAL
8
- * API: Microsoft Graph API v1.0
9
- * Primary Use Case: PowerPlatform-SharePoint integration validation
10
- */
11
- import { ConfidentialClientApplication } from '@azure/msal-node';
12
- import { Client } from '@microsoft/microsoft-graph-client';
13
- import { auditLogger } from '@mcp-consultant-tools/core';
14
- /**
15
- * SharePoint Online Service
16
- *
17
- * Manages authentication, caching, and API requests to SharePoint Online via Graph API.
18
- * Follows established patterns from ApplicationInsightsService and LogAnalyticsService.
19
- */
20
- export class SharePointService {
21
- config;
22
- msalClient = null;
23
- accessToken = null;
24
- tokenExpirationTime = 0;
25
- graphClient = null;
26
- // Cache management (site info, drives, resolved site IDs)
27
- cache = new Map();
28
- siteIdCache = new Map(); // siteUrl → siteId
29
- graphBaseUrl = 'https://graph.microsoft.com/v1.0';
30
- constructor(config) {
31
- this.config = config;
32
- // Validate configuration
33
- if (!config.tenantId || !config.clientId || !config.clientSecret) {
34
- throw new Error('SharePoint Entra ID authentication requires tenantId, clientId, and clientSecret');
35
- }
36
- // Initialize MSAL client for Entra ID authentication
37
- this.msalClient = new ConfidentialClientApplication({
38
- auth: {
39
- clientId: config.clientId,
40
- clientSecret: config.clientSecret,
41
- authority: `https://login.microsoftonline.com/${config.tenantId}`,
42
- },
43
- });
44
- console.error('SharePoint service created (authentication not initialized until first use)');
45
- }
46
- /**
47
- * Get an access token for Microsoft Graph API (Entra ID auth)
48
- * Implements 5-minute token buffer before expiry (follows ApplicationInsightsService pattern)
49
- */
50
- async getAccessToken() {
51
- if (!this.msalClient) {
52
- throw new Error('MSAL client not initialized');
53
- }
54
- const currentTime = Date.now();
55
- // Return cached token if still valid (with 5 minute buffer)
56
- if (this.accessToken && this.tokenExpirationTime > currentTime) {
57
- return this.accessToken;
58
- }
59
- try {
60
- console.error(`[SharePoint] Acquiring token for tenant: ${this.config.tenantId}, client: ${this.config.clientId}`);
61
- const result = await this.msalClient.acquireTokenByClientCredential({
62
- scopes: ['https://graph.microsoft.com/.default'],
63
- });
64
- if (!result || !result.accessToken) {
65
- throw new Error('Failed to acquire access token');
66
- }
67
- this.accessToken = result.accessToken;
68
- console.error(`[SharePoint] Token acquired successfully, expires: ${result.expiresOn}`);
69
- // Set expiration time (subtract 5 minutes to refresh early)
70
- if (result.expiresOn) {
71
- this.tokenExpirationTime = result.expiresOn.getTime() - (5 * 60 * 1000);
72
- }
73
- else {
74
- // Default to 1 hour expiry if not provided
75
- this.tokenExpirationTime = Date.now() + (55 * 60 * 1000);
76
- }
77
- return this.accessToken;
78
- }
79
- catch (error) {
80
- console.error('[SharePoint] Token acquisition failed:', error.message);
81
- console.error('[SharePoint] Error details:', JSON.stringify({
82
- errorCode: error.errorCode,
83
- errorMessage: error.errorMessage,
84
- subError: error.subError,
85
- }));
86
- throw new Error('SharePoint authentication failed: ' + error.message);
87
- }
88
- }
89
- /**
90
- * Get an authenticated Graph client for use by external services (e.g., FileOperationsService)
91
- */
92
- async getAuthenticatedGraphClient() {
93
- return this.getGraphClient();
94
- }
95
- /**
96
- * Resolve a user-friendly site ID to a Graph API site ID for use by external services
97
- */
98
- async getGraphSiteId(siteId) {
99
- const site = this.getSiteById(siteId);
100
- return this.resolveSiteId(site.siteUrl);
101
- }
102
- /**
103
- * Initialize Graph Client with MSAL token provider
104
- */
105
- async getGraphClient() {
106
- const token = await this.getAccessToken();
107
- // Client.init() expects authProvider as a callback function
108
- this.graphClient = Client.init({
109
- authProvider: (done) => {
110
- done(null, token);
111
- },
112
- });
113
- return this.graphClient;
114
- }
115
- /**
116
- * Get active sites
117
- */
118
- getActiveSites() {
119
- return this.config.sites.filter(s => s.active);
120
- }
121
- /**
122
- * Get all sites (including inactive)
123
- */
124
- getAllSites() {
125
- return this.config.sites;
126
- }
127
- /**
128
- * Get site configuration by ID
129
- */
130
- getSiteById(siteId) {
131
- const site = this.config.sites.find(s => s.id === siteId);
132
- if (!site) {
133
- const availableSites = this.getActiveSites().map(s => s.id).join(', ');
134
- throw new Error(`SharePoint site '${siteId}' not found. Available sites: ${availableSites}`);
135
- }
136
- if (!site.active) {
137
- throw new Error(`SharePoint site '${siteId}' is inactive. Set active=true in configuration to enable.`);
138
- }
139
- return site;
140
- }
141
- /**
142
- * Generate cache key for caching API responses
143
- * Format: {method}:{siteId}:{resource}:{params}
144
- */
145
- getCacheKey(method, siteId, resource, params) {
146
- const paramStr = params ? JSON.stringify(params) : '';
147
- return `${method}:${siteId}:${resource}:${paramStr}`;
148
- }
149
- /**
150
- * Get cached value if not expired
151
- */
152
- getCached(key) {
153
- const entry = this.cache.get(key);
154
- if (!entry) {
155
- return null;
156
- }
157
- const now = Date.now();
158
- if (now > entry.expires) {
159
- this.cache.delete(key);
160
- return null;
161
- }
162
- return entry.data;
163
- }
164
- /**
165
- * Set cached value with TTL
166
- */
167
- setCached(key, data) {
168
- const ttl = (this.config.cacheTTL || 300) * 1000; // Default: 5 minutes
169
- const expires = Date.now() + ttl;
170
- this.cache.set(key, { data, expires });
171
- }
172
- /**
173
- * Clear cache (all entries or by pattern/siteId)
174
- */
175
- clearCache(pattern, siteId) {
176
- let clearedCount = 0;
177
- if (!pattern && !siteId) {
178
- // Clear all cache
179
- clearedCount = this.cache.size + this.siteIdCache.size;
180
- this.cache.clear();
181
- this.siteIdCache.clear();
182
- }
183
- else {
184
- // Clear matching entries
185
- const keysToDelete = [];
186
- for (const key of this.cache.keys()) {
187
- let shouldDelete = false;
188
- if (siteId && key.includes(`:${siteId}:`)) {
189
- shouldDelete = true;
190
- }
191
- if (pattern && key.includes(pattern)) {
192
- shouldDelete = true;
193
- }
194
- if (shouldDelete) {
195
- keysToDelete.push(key);
196
- }
197
- }
198
- keysToDelete.forEach(key => this.cache.delete(key));
199
- clearedCount = keysToDelete.length;
200
- // Clear site ID cache if siteId specified
201
- if (siteId) {
202
- for (const [url, id] of this.siteIdCache.entries()) {
203
- if (id === siteId) {
204
- this.siteIdCache.delete(url);
205
- clearedCount++;
206
- }
207
- }
208
- }
209
- }
210
- console.error(`Cleared ${clearedCount} cache entries`);
211
- return clearedCount;
212
- }
213
- /**
214
- * Resolve SharePoint site URL to Graph API site ID
215
- * Format: {hostname},{site-collection-id},{web-id}
216
- * Example: contoso.sharepoint.com,00000000-0000-0000-0000-000000000000,11111111-1111-1111-1111-111111111111
217
- */
218
- async resolveSiteId(siteUrl) {
219
- const timer = auditLogger.startTimer();
220
- // Check cache first
221
- const cached = this.siteIdCache.get(siteUrl);
222
- if (cached) {
223
- return cached;
224
- }
225
- try {
226
- // Parse site URL: https://contoso.sharepoint.com/sites/sitename
227
- const url = new URL(siteUrl);
228
- const hostname = url.hostname;
229
- // Remove trailing slash from pathname
230
- const pathname = url.pathname.replace(/\/+$/, '');
231
- // Construct Graph API path: /sites/{hostname}:{path}
232
- const graphPath = `/sites/${hostname}:${pathname}`;
233
- console.error(`[SharePoint] Resolving site: ${graphPath}`);
234
- const client = await this.getGraphClient();
235
- const response = await client.api(graphPath).get();
236
- if (!response || !response.id) {
237
- throw new Error('Site ID not found in response');
238
- }
239
- const siteId = response.id;
240
- // Cache for future use
241
- this.siteIdCache.set(siteUrl, siteId);
242
- auditLogger.log({
243
- operation: 'resolve-site-id',
244
- operationType: 'READ',
245
- componentType: 'Site',
246
- success: true,
247
- parameters: { siteUrl },
248
- executionTimeMs: timer(),
249
- });
250
- return siteId;
251
- }
252
- catch (error) {
253
- // Extract detailed error info from Graph API - check multiple error formats
254
- let errorDetail = error.message || 'Unknown error';
255
- // Graph SDK error format
256
- if (error.code) {
257
- errorDetail = `${error.code}: ${errorDetail}`;
258
- }
259
- // Check for body (Graph API response)
260
- if (error.body) {
261
- try {
262
- const body = typeof error.body === 'string' ? JSON.parse(error.body) : error.body;
263
- if (body.error) {
264
- errorDetail = `${body.error.code}: ${body.error.message}`;
265
- }
266
- }
267
- catch {
268
- // ignore parse errors
269
- }
270
- }
271
- // Check for statusCode
272
- if (error.statusCode) {
273
- errorDetail = `HTTP ${error.statusCode}: ${errorDetail}`;
274
- }
275
- // Log full error object for debugging
276
- console.error(`[SharePoint] resolveSiteId error details:`, JSON.stringify({
277
- message: error.message,
278
- code: error.code,
279
- statusCode: error.statusCode,
280
- body: error.body,
281
- stack: error.stack?.split('\n').slice(0, 3).join('\n')
282
- }, null, 2));
283
- auditLogger.log({
284
- operation: 'resolve-site-id',
285
- operationType: 'READ',
286
- componentType: 'Site',
287
- success: false,
288
- error: errorDetail,
289
- parameters: { siteUrl },
290
- executionTimeMs: timer(),
291
- });
292
- throw new Error(`Failed to resolve site ID for ${siteUrl}: ${errorDetail}`);
293
- }
294
- }
295
- /**
296
- * Sanitize error messages (remove sensitive information like tokens)
297
- */
298
- sanitizeErrorMessage(error) {
299
- let message = error.message || error.toString();
300
- // Remove tokens
301
- message = message.replace(/Bearer\s+[A-Za-z0-9\-_.]+/gi, 'Bearer ***');
302
- message = message.replace(/\b[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}\b/g, '***');
303
- // Handle Graph API errors
304
- if (error.response?.data?.error) {
305
- const graphError = error.response.data.error;
306
- message = `${graphError.code}: ${graphError.message}`;
307
- }
308
- return message;
309
- }
310
- /**
311
- * Handle errors with user-friendly messages
312
- */
313
- handleError(error, context) {
314
- let errorMessage = `SharePoint ${context} failed`;
315
- if (error.response) {
316
- const status = error.response.status;
317
- const data = error.response.data;
318
- switch (status) {
319
- case 401:
320
- errorMessage = 'Authentication failed. Check credentials and permissions.';
321
- break;
322
- case 403:
323
- errorMessage = 'Access denied. Ensure service principal has Sites.Read.All and Files.Read.All permissions.';
324
- break;
325
- case 404:
326
- errorMessage = 'Resource not found. Check site URL or item path.';
327
- break;
328
- case 429:
329
- errorMessage = 'Rate limit exceeded. Reduce request frequency.';
330
- if (error.response.headers['retry-after']) {
331
- errorMessage += ` Retry after ${error.response.headers['retry-after']} seconds.`;
332
- }
333
- break;
334
- default:
335
- if (data?.error) {
336
- errorMessage = `${data.error.code}: ${data.error.message}`;
337
- }
338
- else {
339
- errorMessage = `HTTP ${status}: ${error.message}`;
340
- }
341
- }
342
- }
343
- else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
344
- errorMessage = 'Network error: Unable to reach SharePoint/Graph API.';
345
- }
346
- else if (error.code === 'ETIMEDOUT') {
347
- errorMessage = 'Request timeout. Try again later.';
348
- }
349
- else {
350
- errorMessage = this.sanitizeErrorMessage(error);
351
- }
352
- return new Error(errorMessage);
353
- }
354
- /**
355
- * Test connection to a SharePoint site
356
- */
357
- async testConnection(siteId) {
358
- const timer = auditLogger.startTimer();
359
- try {
360
- const site = this.getSiteById(siteId);
361
- const siteInfo = await this.getSiteInfo(siteId);
362
- auditLogger.log({
363
- operation: 'test-connection',
364
- operationType: 'READ',
365
- componentType: 'Site',
366
- componentName: site.name,
367
- success: true,
368
- parameters: { siteId },
369
- executionTimeMs: timer(),
370
- });
371
- return {
372
- success: true,
373
- siteInfo,
374
- timestamp: new Date().toISOString(),
375
- };
376
- }
377
- catch (error) {
378
- auditLogger.log({
379
- operation: 'test-connection',
380
- operationType: 'READ',
381
- componentType: 'Site',
382
- success: false,
383
- error: this.sanitizeErrorMessage(error),
384
- parameters: { siteId },
385
- executionTimeMs: timer(),
386
- });
387
- return {
388
- success: false,
389
- error: this.sanitizeErrorMessage(error),
390
- timestamp: new Date().toISOString(),
391
- };
392
- }
393
- }
394
- /**
395
- * Get site information
396
- */
397
- async getSiteInfo(siteId) {
398
- const timer = auditLogger.startTimer();
399
- const site = this.getSiteById(siteId);
400
- // Check cache
401
- const cacheKey = this.getCacheKey('GET', siteId, 'site-info');
402
- const cached = this.getCached(cacheKey);
403
- if (cached) {
404
- return cached;
405
- }
406
- try {
407
- const graphSiteId = await this.resolveSiteId(site.siteUrl);
408
- const client = await this.getGraphClient();
409
- const response = await client
410
- .api(`/sites/${graphSiteId}`)
411
- .select('id,webUrl,displayName,name,description,createdDateTime,lastModifiedDateTime,siteCollection')
412
- .get();
413
- const siteInfo = {
414
- id: response.id,
415
- webUrl: response.webUrl,
416
- displayName: response.displayName,
417
- name: response.name,
418
- description: response.description,
419
- createdDateTime: response.createdDateTime,
420
- lastModifiedDateTime: response.lastModifiedDateTime,
421
- siteCollection: response.siteCollection,
422
- };
423
- // Cache result
424
- this.setCached(cacheKey, siteInfo);
425
- auditLogger.log({
426
- operation: 'get-site-info',
427
- operationType: 'READ',
428
- componentType: 'Site',
429
- componentName: site.name,
430
- success: true,
431
- parameters: { siteId },
432
- executionTimeMs: timer(),
433
- });
434
- return siteInfo;
435
- }
436
- catch (error) {
437
- auditLogger.log({
438
- operation: 'get-site-info',
439
- operationType: 'READ',
440
- componentType: 'Site',
441
- componentName: site.name,
442
- success: false,
443
- error: this.sanitizeErrorMessage(error),
444
- parameters: { siteId },
445
- executionTimeMs: timer(),
446
- });
447
- throw this.handleError(error, 'get site info');
448
- }
449
- }
450
- /**
451
- * List drives (document libraries) in a site
452
- */
453
- async listDrives(siteId) {
454
- const timer = auditLogger.startTimer();
455
- const site = this.getSiteById(siteId);
456
- // Check cache
457
- const cacheKey = this.getCacheKey('GET', siteId, 'drives');
458
- const cached = this.getCached(cacheKey);
459
- if (cached) {
460
- return cached;
461
- }
462
- try {
463
- const graphSiteId = await this.resolveSiteId(site.siteUrl);
464
- const client = await this.getGraphClient();
465
- const response = await client
466
- .api(`/sites/${graphSiteId}/drives`)
467
- .select('id,name,description,webUrl,driveType,createdDateTime,lastModifiedDateTime,quota,owner')
468
- .get();
469
- const drives = response.value || [];
470
- // Cache result
471
- this.setCached(cacheKey, drives);
472
- auditLogger.log({
473
- operation: 'list-drives',
474
- operationType: 'READ',
475
- componentType: 'Site',
476
- componentName: site.name,
477
- success: true,
478
- parameters: { siteId, driveCount: drives.length },
479
- executionTimeMs: timer(),
480
- });
481
- return drives;
482
- }
483
- catch (error) {
484
- auditLogger.log({
485
- operation: 'list-drives',
486
- operationType: 'READ',
487
- componentType: 'Site',
488
- componentName: site.name,
489
- success: false,
490
- error: this.sanitizeErrorMessage(error),
491
- parameters: { siteId },
492
- executionTimeMs: timer(),
493
- });
494
- throw this.handleError(error, 'list drives');
495
- }
496
- }
497
- /**
498
- * Get drive (document library) information
499
- */
500
- async getDriveInfo(siteId, driveId) {
501
- const timer = auditLogger.startTimer();
502
- const site = this.getSiteById(siteId);
503
- // Check cache
504
- const cacheKey = this.getCacheKey('GET', siteId, `drive:${driveId}`);
505
- const cached = this.getCached(cacheKey);
506
- if (cached) {
507
- return cached;
508
- }
509
- try {
510
- const client = await this.getGraphClient();
511
- const response = await client
512
- .api(`/drives/${driveId}`)
513
- .select('id,name,description,webUrl,driveType,createdDateTime,lastModifiedDateTime,quota,owner')
514
- .get();
515
- const driveInfo = response;
516
- // Cache result
517
- this.setCached(cacheKey, driveInfo);
518
- auditLogger.log({
519
- operation: 'get-drive-info',
520
- operationType: 'READ',
521
- componentType: 'Drive',
522
- componentName: site.name,
523
- success: true,
524
- parameters: { siteId, driveId },
525
- executionTimeMs: timer(),
526
- });
527
- return driveInfo;
528
- }
529
- catch (error) {
530
- auditLogger.log({
531
- operation: 'get-drive-info',
532
- operationType: 'READ',
533
- componentType: 'Drive',
534
- componentName: site.name,
535
- success: false,
536
- error: this.sanitizeErrorMessage(error),
537
- parameters: { siteId, driveId },
538
- executionTimeMs: timer(),
539
- });
540
- throw this.handleError(error, 'get drive info');
541
- }
542
- }
543
- /**
544
- * List items (files/folders) in a drive or folder
545
- */
546
- async listItems(siteId, driveId, folderId) {
547
- const timer = auditLogger.startTimer();
548
- const site = this.getSiteById(siteId);
549
- try {
550
- const client = await this.getGraphClient();
551
- const path = folderId
552
- ? `/drives/${driveId}/items/${folderId}/children`
553
- : `/drives/${driveId}/root/children`;
554
- const response = await client
555
- .api(path)
556
- .select('id,name,webUrl,size,createdDateTime,lastModifiedDateTime,createdBy,lastModifiedBy,file,folder,parentReference')
557
- .get();
558
- const items = response.value || [];
559
- auditLogger.log({
560
- operation: 'list-items',
561
- operationType: 'READ',
562
- componentType: 'Drive',
563
- componentName: site.name,
564
- success: true,
565
- parameters: { siteId, driveId, folderId, itemCount: items.length },
566
- executionTimeMs: timer(),
567
- });
568
- return items;
569
- }
570
- catch (error) {
571
- auditLogger.log({
572
- operation: 'list-items',
573
- operationType: 'READ',
574
- componentType: 'Drive',
575
- componentName: site.name,
576
- success: false,
577
- error: this.sanitizeErrorMessage(error),
578
- parameters: { siteId, driveId, folderId },
579
- executionTimeMs: timer(),
580
- });
581
- throw this.handleError(error, 'list items');
582
- }
583
- }
584
- /**
585
- * Get item (file/folder) by ID
586
- */
587
- async getItem(siteId, driveId, itemId) {
588
- const timer = auditLogger.startTimer();
589
- const site = this.getSiteById(siteId);
590
- try {
591
- const client = await this.getGraphClient();
592
- const response = await client
593
- .api(`/drives/${driveId}/items/${itemId}`)
594
- .select('id,name,webUrl,size,createdDateTime,lastModifiedDateTime,createdBy,lastModifiedBy,file,folder,parentReference')
595
- .get();
596
- const item = response;
597
- auditLogger.log({
598
- operation: 'get-item',
599
- operationType: 'READ',
600
- componentType: 'Item',
601
- componentName: site.name,
602
- success: true,
603
- parameters: { siteId, driveId, itemId },
604
- executionTimeMs: timer(),
605
- });
606
- return item;
607
- }
608
- catch (error) {
609
- auditLogger.log({
610
- operation: 'get-item',
611
- operationType: 'READ',
612
- componentType: 'Item',
613
- componentName: site.name,
614
- success: false,
615
- error: this.sanitizeErrorMessage(error),
616
- parameters: { siteId, driveId, itemId },
617
- executionTimeMs: timer(),
618
- });
619
- throw this.handleError(error, 'get item');
620
- }
621
- }
622
- /**
623
- * Get item by path (relative to drive root)
624
- */
625
- async getItemByPath(siteId, driveId, path) {
626
- const timer = auditLogger.startTimer();
627
- const site = this.getSiteById(siteId);
628
- try {
629
- const client = await this.getGraphClient();
630
- // Ensure path starts with /
631
- const normalizedPath = path.startsWith('/') ? path : `/${path}`;
632
- const response = await client
633
- .api(`/drives/${driveId}/root:${normalizedPath}`)
634
- .select('id,name,webUrl,size,createdDateTime,lastModifiedDateTime,createdBy,lastModifiedBy,file,folder,parentReference')
635
- .get();
636
- const item = response;
637
- auditLogger.log({
638
- operation: 'get-item-by-path',
639
- operationType: 'READ',
640
- componentType: 'Item',
641
- componentName: site.name,
642
- success: true,
643
- parameters: { siteId, driveId, path },
644
- executionTimeMs: timer(),
645
- });
646
- return item;
647
- }
648
- catch (error) {
649
- auditLogger.log({
650
- operation: 'get-item-by-path',
651
- operationType: 'READ',
652
- componentType: 'Item',
653
- componentName: site.name,
654
- success: false,
655
- error: this.sanitizeErrorMessage(error),
656
- parameters: { siteId, driveId, path },
657
- executionTimeMs: timer(),
658
- });
659
- throw this.handleError(error, 'get item by path');
660
- }
661
- }
662
- /**
663
- * Search items in a drive or site
664
- */
665
- async searchItems(siteId, query, driveId, limit) {
666
- const timer = auditLogger.startTimer();
667
- const site = this.getSiteById(siteId);
668
- const maxResults = Math.min(limit || 100, this.config.maxSearchResults || 100);
669
- try {
670
- const graphSiteId = await this.resolveSiteId(site.siteUrl);
671
- const client = await this.getGraphClient();
672
- const path = driveId
673
- ? `/drives/${driveId}/root/search(q='${encodeURIComponent(query)}')`
674
- : `/sites/${graphSiteId}/drive/root/search(q='${encodeURIComponent(query)}')`;
675
- const response = await client
676
- .api(path)
677
- .top(maxResults)
678
- .select('id,name,webUrl,size,createdDateTime,lastModifiedDateTime,createdBy,lastModifiedBy,file,folder,parentReference')
679
- .get();
680
- const items = response.value || [];
681
- auditLogger.log({
682
- operation: 'search-items',
683
- operationType: 'READ',
684
- componentType: 'Search',
685
- componentName: site.name,
686
- success: true,
687
- parameters: { siteId, query, driveId, resultCount: items.length },
688
- executionTimeMs: timer(),
689
- });
690
- return {
691
- items,
692
- totalCount: items.length,
693
- };
694
- }
695
- catch (error) {
696
- auditLogger.log({
697
- operation: 'search-items',
698
- operationType: 'READ',
699
- componentType: 'Search',
700
- componentName: site.name,
701
- success: false,
702
- error: this.sanitizeErrorMessage(error),
703
- parameters: { siteId, query, driveId },
704
- executionTimeMs: timer(),
705
- });
706
- throw this.handleError(error, 'search items');
707
- }
708
- }
709
- /**
710
- * Get recent items in a drive
711
- */
712
- async getRecentItems(siteId, driveId, limit, days) {
713
- const timer = auditLogger.startTimer();
714
- const site = this.getSiteById(siteId);
715
- const maxResults = Math.min(limit || 20, 100);
716
- const daysBack = days || 30;
717
- try {
718
- const client = await this.getGraphClient();
719
- // Calculate date threshold
720
- const dateThreshold = new Date();
721
- dateThreshold.setDate(dateThreshold.getDate() - daysBack);
722
- const dateFilter = dateThreshold.toISOString();
723
- const response = await client
724
- .api(`/drives/${driveId}/root/children`)
725
- .select('id,name,webUrl,size,createdDateTime,lastModifiedDateTime,createdBy,lastModifiedBy,file,folder,parentReference')
726
- .filter(`lastModifiedDateTime gt ${dateFilter}`)
727
- .orderby('lastModifiedDateTime desc')
728
- .top(maxResults)
729
- .get();
730
- const items = response.value || [];
731
- auditLogger.log({
732
- operation: 'get-recent-items',
733
- operationType: 'READ',
734
- componentType: 'Drive',
735
- componentName: site.name,
736
- success: true,
737
- parameters: { siteId, driveId, limit: maxResults, days: daysBack, resultCount: items.length },
738
- executionTimeMs: timer(),
739
- });
740
- return items;
741
- }
742
- catch (error) {
743
- auditLogger.log({
744
- operation: 'get-recent-items',
745
- operationType: 'READ',
746
- componentType: 'Drive',
747
- componentName: site.name,
748
- success: false,
749
- error: this.sanitizeErrorMessage(error),
750
- parameters: { siteId, driveId, limit: maxResults, days: daysBack },
751
- executionTimeMs: timer(),
752
- });
753
- throw this.handleError(error, 'get recent items');
754
- }
755
- }
756
- /**
757
- * Get folder structure (recursive)
758
- */
759
- async getFolderStructure(siteId, driveId, folderId, depth) {
760
- const timer = auditLogger.startTimer();
761
- const site = this.getSiteById(siteId);
762
- const maxDepth = Math.min(depth || 3, 10); // Limit to 10 levels max
763
- try {
764
- // Get root folder or specified folder
765
- const rootItem = folderId
766
- ? await this.getItem(siteId, driveId, folderId)
767
- : await this.getItemByPath(siteId, driveId, '/');
768
- // Build tree recursively
769
- const tree = await this.buildFolderTree(siteId, driveId, rootItem, 0, maxDepth);
770
- auditLogger.log({
771
- operation: 'get-folder-structure',
772
- operationType: 'READ',
773
- componentType: 'Drive',
774
- componentName: site.name,
775
- success: true,
776
- parameters: { siteId, driveId, folderId, depth: maxDepth },
777
- executionTimeMs: timer(),
778
- });
779
- return tree;
780
- }
781
- catch (error) {
782
- auditLogger.log({
783
- operation: 'get-folder-structure',
784
- operationType: 'READ',
785
- componentType: 'Drive',
786
- componentName: site.name,
787
- success: false,
788
- error: this.sanitizeErrorMessage(error),
789
- parameters: { siteId, driveId, folderId, depth: maxDepth },
790
- executionTimeMs: timer(),
791
- });
792
- throw this.handleError(error, 'get folder structure');
793
- }
794
- }
795
- /**
796
- * Build folder tree recursively
797
- */
798
- async buildFolderTree(siteId, driveId, item, currentDepth, maxDepth) {
799
- const tree = { item };
800
- // Stop if max depth reached or item is a file
801
- if (currentDepth >= maxDepth || !item.folder) {
802
- return tree;
803
- }
804
- // Get children
805
- const children = await this.listItems(siteId, driveId, item.id);
806
- // Recursively build tree for folders
807
- tree.children = await Promise.all(children
808
- .filter(child => child.folder)
809
- .map(child => this.buildFolderTree(siteId, driveId, child, currentDepth + 1, maxDepth)));
810
- return tree;
811
- }
812
- // ============================================================================
813
- // POWERPLATFORM VALIDATION METHODS (HIGH PRIORITY)
814
- // ============================================================================
815
- // These methods integrate SharePoint with PowerPlatform Dataverse to validate
816
- // document location configurations and migrations.
817
- // ============================================================================
818
- /**
819
- * Get CRM document locations from PowerPlatform
820
- *
821
- * Queries the sharepointdocumentlocation entity in Dataverse to retrieve
822
- * document location configurations for validation.
823
- *
824
- * @param powerPlatformService PowerPlatformService instance for querying Dataverse
825
- * @param entityName Optional entity logical name to filter by (e.g., 'account', 'contact')
826
- * @param recordId Optional record ID to filter by specific record
827
- * @returns Array of SharePoint document location records
828
- */
829
- async getCrmDocumentLocations(powerPlatformService, entityName, recordId) {
830
- const timer = auditLogger.startTimer();
831
- try {
832
- // Build OData filter
833
- let filter = 'statecode eq 0'; // Active only
834
- if (entityName && recordId) {
835
- // Filter by specific record
836
- filter += ` and _regardingobjectid_value eq ${recordId}`;
837
- }
838
- else if (entityName) {
839
- // Filter by entity type (need to query by logical name)
840
- // This requires a join which is complex in OData, so we'll fetch all and filter client-side
841
- }
842
- // Query sharepointdocumentlocation entity
843
- const response = await powerPlatformService.queryRecords('sharepointdocumentlocations', filter, 1000 // Max records
844
- );
845
- // Parse response
846
- const locations = [];
847
- for (const record of response.value || []) {
848
- const location = {
849
- sharepointdocumentlocationid: record.sharepointdocumentlocationid,
850
- name: record.name || '',
851
- absoluteurl: record.absoluteurl || '',
852
- relativeurl: record.relativeurl || '',
853
- statecode: record.statecode || 0,
854
- statuscode: record.statuscode || 1,
855
- };
856
- // Parse regarding object (related entity)
857
- if (record._regardingobjectid_value) {
858
- location.regardingobjectid = {
859
- id: record._regardingobjectid_value,
860
- logicalName: record['_regardingobjectid_value@Microsoft.Dynamics.CRM.lookuplogicalname'] || '',
861
- };
862
- }
863
- // Parse parent site or location
864
- if (record._parentsiteorlocation_value) {
865
- location.parentsiteorlocation = {
866
- id: record._parentsiteorlocation_value,
867
- logicalName: record['_parentsiteorlocation_value@Microsoft.Dynamics.CRM.lookuplogicalname'] || '',
868
- };
869
- }
870
- // Site collection ID
871
- if (record.sitecollectionid) {
872
- location.sitecollectionid = record.sitecollectionid;
873
- }
874
- locations.push(location);
875
- }
876
- // Client-side filtering by entity name if needed
877
- let filtered = locations;
878
- if (entityName && !recordId) {
879
- filtered = locations.filter(loc => loc.regardingobjectid?.logicalName === entityName);
880
- }
881
- auditLogger.log({
882
- operation: 'get-crm-document-locations',
883
- operationType: 'READ',
884
- componentType: 'DocumentLocation',
885
- success: true,
886
- parameters: { entityName, recordId, resultCount: filtered.length },
887
- executionTimeMs: timer(),
888
- });
889
- return filtered;
890
- }
891
- catch (error) {
892
- auditLogger.log({
893
- operation: 'get-crm-document-locations',
894
- operationType: 'READ',
895
- componentType: 'DocumentLocation',
896
- success: false,
897
- error: this.sanitizeErrorMessage(error),
898
- parameters: { entityName, recordId },
899
- executionTimeMs: timer(),
900
- });
901
- throw this.handleError(error, 'get CRM document locations');
902
- }
903
- }
904
- /**
905
- * Validate a document location configuration
906
- *
907
- * Validates that a PowerPlatform document location configuration matches
908
- * the actual SharePoint site structure. Checks:
909
- * - Site exists and is accessible
910
- * - Folder exists at the specified path
911
- * - Folder is accessible
912
- * - File count and empty folder detection
913
- *
914
- * @param powerPlatformService PowerPlatformService instance
915
- * @param documentLocationId GUID of the sharepointdocumentlocation record
916
- * @returns Validation result with status, issues, and recommendations
917
- */
918
- async validateDocumentLocation(powerPlatformService, documentLocationId) {
919
- const timer = auditLogger.startTimer();
920
- try {
921
- // Get document location from CRM
922
- const record = await powerPlatformService.getRecord('sharepointdocumentlocations', documentLocationId);
923
- if (!record) {
924
- throw new Error(`Document location ${documentLocationId} not found`);
925
- }
926
- // Parse CRM configuration
927
- const absoluteUrl = record.absoluteurl || '';
928
- const relativeUrl = record.relativeurl || '';
929
- const regardingEntityName = record['_regardingobjectid_value@Microsoft.Dynamics.CRM.lookuplogicalname'] || '';
930
- const regardingRecordId = record._regardingobjectid_value || '';
931
- const isActive = record.statecode === 0;
932
- // Initialize validation result
933
- const result = {
934
- documentLocationId,
935
- documentLocationName: record.name || '',
936
- crmConfig: {
937
- absoluteUrl,
938
- relativeUrl,
939
- regardingEntity: regardingEntityName,
940
- regardingRecordId,
941
- isActive,
942
- },
943
- spoValidation: {
944
- siteExists: false,
945
- folderExists: false,
946
- folderAccessible: false,
947
- fileCount: 0,
948
- isEmpty: true,
949
- },
950
- status: 'error',
951
- issues: [],
952
- recommendations: [],
953
- };
954
- // Check if absolute URL is configured
955
- if (!absoluteUrl) {
956
- result.issues.push('Absolute URL is not configured in CRM');
957
- result.recommendations.push('Configure the absoluteurl field in the document location record');
958
- auditLogger.log({
959
- operation: 'validate-document-location',
960
- operationType: 'READ',
961
- componentType: 'DocumentLocation',
962
- componentName: result.documentLocationName,
963
- success: true,
964
- parameters: { documentLocationId, status: result.status },
965
- executionTimeMs: timer(),
966
- });
967
- return result;
968
- }
969
- // Parse site URL and folder path from absolute URL
970
- // Format: https://contoso.sharepoint.com/sites/sitename/DocumentLibrary/folder1/folder2
971
- let siteUrl;
972
- let folderPath;
973
- try {
974
- const url = new URL(absoluteUrl);
975
- const pathParts = url.pathname.split('/').filter(p => p);
976
- // Find /sites/ index
977
- const sitesIndex = pathParts.indexOf('sites');
978
- if (sitesIndex === -1) {
979
- throw new Error('URL does not contain /sites/ path');
980
- }
981
- // Site URL: https://contoso.sharepoint.com/sites/sitename
982
- const siteName = pathParts[sitesIndex + 1];
983
- siteUrl = `${url.protocol}//${url.hostname}/sites/${siteName}`;
984
- // Folder path: /DocumentLibrary/folder1/folder2
985
- const libraryAndFolder = pathParts.slice(sitesIndex + 2);
986
- folderPath = '/' + libraryAndFolder.join('/');
987
- }
988
- catch (parseError) {
989
- result.issues.push(`Failed to parse absolute URL: ${parseError.message}`);
990
- result.recommendations.push('Verify the absolute URL format in CRM');
991
- auditLogger.log({
992
- operation: 'validate-document-location',
993
- operationType: 'READ',
994
- componentType: 'DocumentLocation',
995
- componentName: result.documentLocationName,
996
- success: true,
997
- parameters: { documentLocationId, status: result.status },
998
- executionTimeMs: timer(),
999
- });
1000
- return result;
1001
- }
1002
- // Validate site exists
1003
- try {
1004
- const siteId = await this.resolveSiteId(siteUrl);
1005
- const siteInfo = await this.getSiteInfo(siteId);
1006
- result.spoValidation.siteExists = true;
1007
- }
1008
- catch (siteError) {
1009
- result.issues.push(`SharePoint site not found: ${siteUrl}`);
1010
- result.recommendations.push('Verify the site URL is correct and accessible');
1011
- result.recommendations.push('Check that the service principal has access to the site');
1012
- auditLogger.log({
1013
- operation: 'validate-document-location',
1014
- operationType: 'READ',
1015
- componentType: 'DocumentLocation',
1016
- componentName: result.documentLocationName,
1017
- success: true,
1018
- parameters: { documentLocationId, status: result.status },
1019
- executionTimeMs: timer(),
1020
- });
1021
- return result;
1022
- }
1023
- // Find the site in configured sites
1024
- const configuredSite = this.config.sites.find(s => s.siteUrl === siteUrl);
1025
- if (!configuredSite) {
1026
- result.issues.push(`Site ${siteUrl} is not configured in SHAREPOINT_SITES`);
1027
- result.recommendations.push('Add the site to SHAREPOINT_SITES configuration');
1028
- result.status = 'warning';
1029
- auditLogger.log({
1030
- operation: 'validate-document-location',
1031
- operationType: 'READ',
1032
- componentType: 'DocumentLocation',
1033
- componentName: result.documentLocationName,
1034
- success: true,
1035
- parameters: { documentLocationId, status: result.status },
1036
- executionTimeMs: timer(),
1037
- });
1038
- return result;
1039
- }
1040
- const siteId = configuredSite.id;
1041
- // Validate folder exists and is accessible
1042
- try {
1043
- // Get drives (document libraries)
1044
- const drives = await this.listDrives(siteId);
1045
- // Parse library name from folder path
1046
- const libraryName = folderPath.split('/').filter(p => p)[0];
1047
- const drive = drives.find(d => d.name === libraryName);
1048
- if (!drive) {
1049
- result.issues.push(`Document library '${libraryName}' not found in site`);
1050
- result.recommendations.push('Verify the library name in the absolute URL');
1051
- result.status = 'warning';
1052
- auditLogger.log({
1053
- operation: 'validate-document-location',
1054
- operationType: 'READ',
1055
- componentType: 'DocumentLocation',
1056
- componentName: result.documentLocationName,
1057
- success: true,
1058
- parameters: { documentLocationId, status: result.status },
1059
- executionTimeMs: timer(),
1060
- });
1061
- return result;
1062
- }
1063
- result.spoValidation.folderExists = true;
1064
- // Get folder contents
1065
- try {
1066
- const items = await this.getItemByPath(siteId, drive.id, folderPath);
1067
- result.spoValidation.folderAccessible = true;
1068
- // Count files in folder
1069
- if (items.folder) {
1070
- const folderContents = await this.listItems(siteId, drive.id, items.id);
1071
- result.spoValidation.fileCount = folderContents.length;
1072
- result.spoValidation.isEmpty = folderContents.length === 0;
1073
- }
1074
- // Determine overall status
1075
- if (result.spoValidation.isEmpty) {
1076
- result.status = 'warning';
1077
- result.issues.push('Folder is empty (no files found)');
1078
- result.recommendations.push('Upload documents to the folder or verify the folder path');
1079
- }
1080
- else {
1081
- result.status = 'valid';
1082
- }
1083
- }
1084
- catch (folderError) {
1085
- result.spoValidation.folderExists = false;
1086
- result.issues.push(`Folder not accessible at path: ${folderPath}`);
1087
- result.recommendations.push('Verify the folder path is correct');
1088
- result.recommendations.push('Check that the folder exists in SharePoint');
1089
- result.status = 'error';
1090
- }
1091
- }
1092
- catch (driveError) {
1093
- result.issues.push(`Failed to access document libraries: ${driveError.message}`);
1094
- result.recommendations.push('Verify service principal has Read permissions on the site');
1095
- result.status = 'error';
1096
- }
1097
- auditLogger.log({
1098
- operation: 'validate-document-location',
1099
- operationType: 'READ',
1100
- componentType: 'DocumentLocation',
1101
- componentName: result.documentLocationName,
1102
- success: true,
1103
- parameters: { documentLocationId, status: result.status },
1104
- executionTimeMs: timer(),
1105
- });
1106
- return result;
1107
- }
1108
- catch (error) {
1109
- auditLogger.log({
1110
- operation: 'validate-document-location',
1111
- operationType: 'READ',
1112
- componentType: 'DocumentLocation',
1113
- success: false,
1114
- error: this.sanitizeErrorMessage(error),
1115
- parameters: { documentLocationId },
1116
- executionTimeMs: timer(),
1117
- });
1118
- throw this.handleError(error, 'validate document location');
1119
- }
1120
- }
1121
- /**
1122
- * Verify document migration between two SharePoint locations
1123
- *
1124
- * Compares file counts, sizes, and names between source and target folders
1125
- * to verify successful migration.
1126
- *
1127
- * @param powerPlatformService PowerPlatformService instance (unused but kept for consistency)
1128
- * @param sourceSiteId Source SharePoint site ID
1129
- * @param sourcePath Source folder path
1130
- * @param targetSiteId Target SharePoint site ID
1131
- * @param targetPath Target folder path
1132
- * @returns Migration verification result with comparison details
1133
- */
1134
- async verifyDocumentMigration(powerPlatformService, sourceSiteId, sourcePath, targetSiteId, targetPath) {
1135
- const timer = auditLogger.startTimer();
1136
- try {
1137
- const sourceSite = this.getSiteById(sourceSiteId);
1138
- const targetSite = this.getSiteById(targetSiteId);
1139
- // Get source folder contents
1140
- const sourceDrives = await this.listDrives(sourceSiteId);
1141
- const sourceLibraryName = sourcePath.split('/').filter(p => p)[0];
1142
- const sourceDrive = sourceDrives.find(d => d.name === sourceLibraryName);
1143
- if (!sourceDrive) {
1144
- throw new Error(`Source library '${sourceLibraryName}' not found`);
1145
- }
1146
- const sourceFolder = await this.getItemByPath(sourceSiteId, sourceDrive.id, sourcePath);
1147
- const sourceItems = await this.listItems(sourceSiteId, sourceDrive.id, sourceFolder.id);
1148
- // Get target folder contents
1149
- const targetDrives = await this.listDrives(targetSiteId);
1150
- const targetLibraryName = targetPath.split('/').filter(p => p)[0];
1151
- const targetDrive = targetDrives.find(d => d.name === targetLibraryName);
1152
- if (!targetDrive) {
1153
- throw new Error(`Target library '${targetLibraryName}' not found`);
1154
- }
1155
- const targetFolder = await this.getItemByPath(targetSiteId, targetDrive.id, targetPath);
1156
- const targetItems = await this.listItems(targetSiteId, targetDrive.id, targetFolder.id);
1157
- // Calculate totals
1158
- const sourceTotalSize = sourceItems.reduce((sum, item) => sum + (item.size || 0), 0);
1159
- const targetTotalSize = targetItems.reduce((sum, item) => sum + (item.size || 0), 0);
1160
- // Compare files
1161
- const sourceFileNames = new Set(sourceItems.map(i => i.name));
1162
- const targetFileNames = new Set(targetItems.map(i => i.name));
1163
- const missingFiles = sourceItems
1164
- .filter(i => !targetFileNames.has(i.name))
1165
- .map(i => i.name);
1166
- const extraFiles = targetItems
1167
- .filter(i => !sourceFileNames.has(i.name))
1168
- .map(i => i.name);
1169
- // Compare sizes
1170
- const sizeMismatches = [];
1171
- const modifiedDateMismatches = [];
1172
- for (const sourceItem of sourceItems) {
1173
- const targetItem = targetItems.find(t => t.name === sourceItem.name);
1174
- if (targetItem) {
1175
- if (sourceItem.size !== targetItem.size) {
1176
- sizeMismatches.push({
1177
- name: sourceItem.name,
1178
- sourceSize: sourceItem.size || 0,
1179
- targetSize: targetItem.size || 0,
1180
- });
1181
- }
1182
- if (sourceItem.lastModifiedDateTime !== targetItem.lastModifiedDateTime) {
1183
- modifiedDateMismatches.push({
1184
- name: sourceItem.name,
1185
- sourceDate: sourceItem.lastModifiedDateTime,
1186
- targetDate: targetItem.lastModifiedDateTime,
1187
- });
1188
- }
1189
- }
1190
- }
1191
- // Calculate success rate
1192
- const expectedFileCount = sourceItems.length;
1193
- const actualFileCount = targetItems.length - extraFiles.length;
1194
- const successRate = expectedFileCount > 0
1195
- ? Math.round((actualFileCount / expectedFileCount) * 100)
1196
- : 100;
1197
- // Determine status
1198
- let status;
1199
- if (missingFiles.length === 0 && sizeMismatches.length === 0) {
1200
- status = 'complete';
1201
- }
1202
- else if (missingFiles.length > 0 || sizeMismatches.length > 0) {
1203
- if (successRate < 50) {
1204
- status = 'failed';
1205
- }
1206
- else {
1207
- status = 'incomplete';
1208
- }
1209
- }
1210
- else {
1211
- status = 'complete';
1212
- }
1213
- const result = {
1214
- source: {
1215
- path: sourcePath,
1216
- fileCount: sourceItems.length,
1217
- totalSize: sourceTotalSize,
1218
- files: sourceItems,
1219
- },
1220
- target: {
1221
- path: targetPath,
1222
- fileCount: targetItems.length,
1223
- totalSize: targetTotalSize,
1224
- files: targetItems,
1225
- },
1226
- comparison: {
1227
- missingFiles,
1228
- extraFiles,
1229
- sizeMismatches,
1230
- modifiedDateMismatches,
1231
- },
1232
- successRate,
1233
- status,
1234
- };
1235
- auditLogger.log({
1236
- operation: 'verify-document-migration',
1237
- operationType: 'READ',
1238
- componentType: 'Migration',
1239
- success: true,
1240
- parameters: {
1241
- sourceSiteId,
1242
- sourcePath,
1243
- targetSiteId,
1244
- targetPath,
1245
- status,
1246
- successRate,
1247
- },
1248
- executionTimeMs: timer(),
1249
- });
1250
- return result;
1251
- }
1252
- catch (error) {
1253
- auditLogger.log({
1254
- operation: 'verify-document-migration',
1255
- operationType: 'READ',
1256
- componentType: 'Migration',
1257
- success: false,
1258
- error: this.sanitizeErrorMessage(error),
1259
- parameters: { sourceSiteId, sourcePath, targetSiteId, targetPath },
1260
- executionTimeMs: timer(),
1261
- });
1262
- throw this.handleError(error, 'verify document migration');
1263
- }
1264
- }
1265
- /**
1266
- * Close service and clear resources
1267
- */
1268
- async close() {
1269
- this.accessToken = null;
1270
- this.tokenExpirationTime = 0;
1271
- this.graphClient = null;
1272
- this.cache.clear();
1273
- this.siteIdCache.clear();
1274
- console.error('SharePoint service closed');
1275
- }
1276
- }
1277
- //# sourceMappingURL=SharePointService.js.map