n8n-nodes-onedrive-business-sp 1.1.0 → 1.2.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.
@@ -109,11 +109,51 @@ class MicrosoftOneDriveBusinessTrigger {
109
109
  default: {},
110
110
  options: [
111
111
  {
112
- displayName: 'Poll Interval (minutes)',
113
- name: 'pollInterval',
112
+ displayName: 'Watch Subfolders',
113
+ name: 'recursive',
114
+ type: 'boolean',
115
+ default: false,
116
+ description: 'Whether to watch subfolders recursively',
117
+ },
118
+ {
119
+ displayName: 'Ignore Duplicates',
120
+ name: 'ignoreDuplicates',
121
+ type: 'boolean',
122
+ default: true,
123
+ description: 'Whether to ignore duplicate notifications for the same file',
124
+ },
125
+ {
126
+ displayName: 'Wait for File Completion (seconds)',
127
+ name: 'waitForCompletion',
114
128
  type: 'number',
115
- default: 5,
116
- description: 'How often to poll for changes (in minutes)',
129
+ default: 0,
130
+ description: 'Wait time after detecting change to ensure file is fully uploaded. Useful for large files.',
131
+ },
132
+ {
133
+ displayName: 'Include File Metadata',
134
+ name: 'includeMetadata',
135
+ type: 'boolean',
136
+ default: true,
137
+ description: 'Whether to include extended metadata like version, lock status, etc.',
138
+ },
139
+ {
140
+ displayName: 'Periodic Full Scan',
141
+ name: 'fullScanEnabled',
142
+ type: 'boolean',
143
+ default: false,
144
+ description: 'Whether to periodically perform a full scan as backup (in case delta misses changes)',
145
+ },
146
+ {
147
+ displayName: 'Full Scan Interval (hours)',
148
+ name: 'fullScanInterval',
149
+ type: 'number',
150
+ default: 24,
151
+ displayOptions: {
152
+ show: {
153
+ fullScanEnabled: [true],
154
+ },
155
+ },
156
+ description: 'How often to perform a full scan (in hours)',
117
157
  },
118
158
  ],
119
159
  },
@@ -232,6 +272,14 @@ class MicrosoftOneDriveBusinessTrigger {
232
272
  else {
233
273
  watchFolderId = watchFolder.value || 'root';
234
274
  }
275
+ // Validate webhook URL accessibility (basic check)
276
+ if (!webhookUrl.startsWith('https://')) {
277
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Webhook URL must use HTTPS. Microsoft Graph requires secure endpoints.');
278
+ }
279
+ // Check if webhook URL contains localhost/127.0.0.1
280
+ if (webhookUrl.includes('localhost') || webhookUrl.includes('127.0.0.1')) {
281
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Webhook URL cannot be localhost. Microsoft Graph cannot reach local addresses. Use a public URL or tunneling service like ngrok.');
282
+ }
235
283
  // Get drive ID if not provided
236
284
  let actualDriveId = driveId;
237
285
  if (!actualDriveId) {
@@ -242,8 +290,27 @@ class MicrosoftOneDriveBusinessTrigger {
242
290
  });
243
291
  actualDriveId = driveResponse.id;
244
292
  }
245
- const watchPath = watchFolderId === 'root' ? 'root' : `items/${watchFolderId}`;
246
- const resource = `/drives/${actualDriveId}/${watchPath}`;
293
+ // Validate drive and folder accessibility
294
+ try {
295
+ await this.helpers.requestOAuth2.call(this, 'microsoftOneDriveBusinessOAuth2Api', {
296
+ method: 'GET',
297
+ url: `https://graph.microsoft.com/v1.0/drives/${actualDriveId}`,
298
+ json: true,
299
+ });
300
+ if (watchFolderId !== 'root') {
301
+ await this.helpers.requestOAuth2.call(this, 'microsoftOneDriveBusinessOAuth2Api', {
302
+ method: 'GET',
303
+ url: `https://graph.microsoft.com/v1.0/drives/${actualDriveId}/items/${watchFolderId}`,
304
+ json: true,
305
+ });
306
+ }
307
+ }
308
+ catch (validationError) {
309
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Cannot access drive/folder. It may have been deleted, moved, or you may not have permission. Error: ${validationError.message}`);
310
+ }
311
+ // Microsoft Graph only supports subscriptions at drive root level
312
+ // We'll filter by folder in the webhook handler
313
+ const resource = `/drives/${actualDriveId}/root`;
247
314
  // Create subscription
248
315
  const expirationDateTime = new Date();
249
316
  expirationDateTime.setHours(expirationDateTime.getHours() + 72); // 72 hours max
@@ -263,7 +330,23 @@ class MicrosoftOneDriveBusinessTrigger {
263
330
  });
264
331
  webhookData.subscriptionId = responseData.id;
265
332
  webhookData.driveId = actualDriveId;
266
- webhookData.deltaLink = null;
333
+ webhookData.watchFolderId = watchFolderId;
334
+ webhookData.subscriptionExpiry = responseData.expirationDateTime;
335
+ webhookData.processedItems = {};
336
+ // Initialize delta link to avoid getting all existing items
337
+ try {
338
+ const deltaResponse = await this.helpers.requestOAuth2.call(this, 'microsoftOneDriveBusinessOAuth2Api', {
339
+ method: 'GET',
340
+ url: `https://graph.microsoft.com/v1.0/drives/${actualDriveId}/root/delta`,
341
+ json: true,
342
+ });
343
+ // Store the delta link to track only future changes
344
+ webhookData.deltaLink = deltaResponse['@odata.deltaLink'];
345
+ }
346
+ catch (deltaError) {
347
+ // If delta initialization fails, set to null
348
+ webhookData.deltaLink = null;
349
+ }
267
350
  }
268
351
  catch (error) {
269
352
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@@ -303,6 +386,39 @@ class MicrosoftOneDriveBusinessTrigger {
303
386
  webhookResponse: query.validationToken,
304
387
  };
305
388
  }
389
+ // Check if subscription needs renewal (renew if less than 24h remaining)
390
+ const subscriptionExpiry = webhookData.subscriptionExpiry;
391
+ const isRenewing = webhookData.isRenewing;
392
+ if (subscriptionExpiry && !isRenewing) {
393
+ const expiryTime = new Date(subscriptionExpiry).getTime();
394
+ const now = Date.now();
395
+ const hoursRemaining = (expiryTime - now) / (1000 * 60 * 60);
396
+ if (hoursRemaining < 24 && hoursRemaining > 0) {
397
+ try {
398
+ // Set renewal flag to prevent concurrent renewals
399
+ webhookData.isRenewing = true;
400
+ const newExpirationDateTime = new Date();
401
+ newExpirationDateTime.setHours(newExpirationDateTime.getHours() + 72);
402
+ const renewResponse = await this.helpers.requestOAuth2.call(this, 'microsoftOneDriveBusinessOAuth2Api', {
403
+ method: 'PATCH',
404
+ url: `https://graph.microsoft.com/v1.0/subscriptions/${webhookData.subscriptionId}`,
405
+ body: {
406
+ expirationDateTime: newExpirationDateTime.toISOString(),
407
+ },
408
+ json: true,
409
+ });
410
+ webhookData.subscriptionExpiry = renewResponse.expirationDateTime;
411
+ }
412
+ catch (renewError) {
413
+ // If renewal fails, log but continue processing
414
+ console.log('Failed to renew subscription:', renewError);
415
+ }
416
+ finally {
417
+ // Clear renewal flag
418
+ webhookData.isRenewing = false;
419
+ }
420
+ }
421
+ }
306
422
  // Verify client state
307
423
  const notification = bodyData.value?.[0];
308
424
  if (notification?.clientState !== 'n8n-secret-token') {
@@ -312,6 +428,13 @@ class MicrosoftOneDriveBusinessTrigger {
312
428
  }
313
429
  const event = this.getNodeParameter('event');
314
430
  const watchFolder = this.getNodeParameter('watchFolderId', {});
431
+ const options = this.getNodeParameter('options', {});
432
+ const recursive = options.recursive || false;
433
+ const ignoreDuplicates = options.ignoreDuplicates !== false;
434
+ const waitForCompletion = options.waitForCompletion || 0;
435
+ const includeMetadata = options.includeMetadata !== false;
436
+ const fullScanEnabled = options.fullScanEnabled || false;
437
+ const fullScanInterval = options.fullScanInterval || 24;
315
438
  // Extract folder ID from resource locator
316
439
  let watchFolderId;
317
440
  if (typeof watchFolder === 'string') {
@@ -321,52 +444,265 @@ class MicrosoftOneDriveBusinessTrigger {
321
444
  watchFolderId = watchFolder.value || 'root';
322
445
  }
323
446
  const driveId = webhookData.driveId;
324
- // Use delta query to get changes
447
+ const storedWatchFolderId = webhookData.watchFolderId || 'root';
448
+ // Check if full scan is needed
449
+ if (fullScanEnabled) {
450
+ const lastFullScan = webhookData.lastFullScan || 0;
451
+ const now = Date.now();
452
+ const hoursSinceLastScan = (now - lastFullScan) / (1000 * 60 * 60);
453
+ if (hoursSinceLastScan >= fullScanInterval) {
454
+ // Reset delta link to force full scan
455
+ webhookData.deltaLink = null;
456
+ webhookData.lastFullScan = now;
457
+ }
458
+ }
459
+ // Always use root delta query (Microsoft Graph requirement)
325
460
  let deltaUrl = webhookData.deltaLink;
326
461
  if (!deltaUrl) {
327
- const watchPath = watchFolderId === 'root' ? 'root' : `items/${watchFolderId}`;
328
- deltaUrl = `https://graph.microsoft.com/v1.0/drives/${driveId}/${watchPath}/delta`;
462
+ deltaUrl = `https://graph.microsoft.com/v1.0/drives/${driveId}/root/delta`;
329
463
  }
330
464
  try {
331
- const deltaResponse = await this.helpers.requestOAuth2.call(this, 'microsoftOneDriveBusinessOAuth2Api', {
332
- method: 'GET',
333
- url: deltaUrl,
334
- json: true,
335
- });
465
+ // Wait for file completion if configured
466
+ if (waitForCompletion > 0) {
467
+ await new Promise(resolve => setTimeout(resolve, waitForCompletion * 1000));
468
+ }
469
+ // Fetch all pages of delta changes
470
+ let allChanges = [];
471
+ let currentDeltaUrl = deltaUrl;
472
+ let finalDeltaLink;
473
+ let retryCount = 0;
474
+ const maxRetries = 3;
475
+ while (currentDeltaUrl) {
476
+ try {
477
+ const deltaResponse = await this.helpers.requestOAuth2.call(this, 'microsoftOneDriveBusinessOAuth2Api', {
478
+ method: 'GET',
479
+ url: currentDeltaUrl,
480
+ json: true,
481
+ });
482
+ const pageChanges = deltaResponse.value;
483
+ allChanges = allChanges.concat(pageChanges);
484
+ // Check for next page or delta link
485
+ if (deltaResponse['@odata.nextLink']) {
486
+ currentDeltaUrl = deltaResponse['@odata.nextLink'];
487
+ }
488
+ else {
489
+ currentDeltaUrl = undefined;
490
+ finalDeltaLink = deltaResponse['@odata.deltaLink'];
491
+ }
492
+ retryCount = 0; // Reset retry count on success
493
+ }
494
+ catch (pageError) {
495
+ // Retry logic for transient failures
496
+ const statusCode = pageError?.statusCode || pageError?.response?.status;
497
+ if ((statusCode === 429 || statusCode >= 500) && retryCount < maxRetries) {
498
+ retryCount++;
499
+ const delay = Math.min(1000 * Math.pow(2, retryCount), 10000); // Exponential backoff
500
+ await new Promise(resolve => setTimeout(resolve, delay));
501
+ continue; // Retry same URL
502
+ }
503
+ throw pageError; // Re-throw if not retryable or max retries reached
504
+ }
505
+ }
336
506
  // Store the new delta link
337
- webhookData.deltaLink = deltaResponse['@odata.deltaLink'];
338
- const changes = deltaResponse.value;
507
+ if (finalDeltaLink) {
508
+ webhookData.deltaLink = finalDeltaLink;
509
+ }
510
+ const changes = allChanges;
511
+ // Get folder path if watching specific folder
512
+ let watchFolderPath = null;
513
+ if (storedWatchFolderId !== 'root') {
514
+ try {
515
+ const folderInfo = await this.helpers.requestOAuth2.call(this, 'microsoftOneDriveBusinessOAuth2Api', {
516
+ method: 'GET',
517
+ url: `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${storedWatchFolderId}`,
518
+ qs: { $select: 'id,name,parentReference' },
519
+ json: true,
520
+ });
521
+ const parentRef = folderInfo.parentReference;
522
+ watchFolderPath = (parentRef?.path || '') + '/' + folderInfo.name;
523
+ }
524
+ catch (error) {
525
+ // If can't get folder path, don't filter
526
+ }
527
+ }
528
+ // Deduplication: track processed items
529
+ const processedItems = webhookData.processedItems || {};
530
+ const now = Date.now();
531
+ // Clean up old processed items (older than 1 hour)
532
+ for (const key in processedItems) {
533
+ if (now - processedItems[key] > 3600000) {
534
+ delete processedItems[key];
535
+ }
536
+ }
339
537
  const filteredChanges = changes.filter((change) => {
538
+ // Validate required fields
539
+ if (!change.id || !change.name) {
540
+ return false; // Skip incomplete items
541
+ }
340
542
  const isFile = change.file !== undefined;
341
543
  const isFolder = change.folder !== undefined;
342
544
  const isDeleted = change.deleted !== undefined;
343
545
  if (isDeleted)
344
546
  return false;
547
+ // Skip the root folder itself
548
+ if (change.root !== undefined)
549
+ return false;
550
+ // Skip if missing critical metadata
551
+ if (!change.createdDateTime || !change.lastModifiedDateTime) {
552
+ return false;
553
+ }
554
+ // Deduplication check
555
+ if (ignoreDuplicates) {
556
+ const itemKey = `${change.id}_${change.lastModifiedDateTime}`;
557
+ if (processedItems[itemKey]) {
558
+ return false; // Already processed this exact change
559
+ }
560
+ processedItems[itemKey] = now;
561
+ }
562
+ // Filter by folder path if watching specific folder
563
+ if (storedWatchFolderId !== 'root' && change.parentReference) {
564
+ const parentRef = change.parentReference;
565
+ const parentId = parentRef.id;
566
+ if (recursive) {
567
+ // For recursive, check if item is in watched folder or any subfolder
568
+ const itemPath = parentRef.path || '';
569
+ if (watchFolderPath) {
570
+ if (!itemPath.includes(watchFolderPath)) {
571
+ return false;
572
+ }
573
+ }
574
+ else {
575
+ // Fallback: check parent ID chain (would need additional API calls)
576
+ // For now, just check direct parent
577
+ if (parentId !== storedWatchFolderId) {
578
+ // Could be in subfolder, but we can't verify without more API calls
579
+ // Let it through for now
580
+ }
581
+ }
582
+ }
583
+ else {
584
+ // Non-recursive: must be direct child
585
+ if (parentId !== storedWatchFolderId) {
586
+ return false;
587
+ }
588
+ }
589
+ }
345
590
  if (event === 'fileCreated' && isFile) {
346
- return change.createdDateTime === change.lastModifiedDateTime;
591
+ // Compare timestamps instead of strings for better accuracy
592
+ const created = new Date(change.createdDateTime).getTime();
593
+ const modified = new Date(change.lastModifiedDateTime).getTime();
594
+ // Allow small time difference (within 5 seconds) for creation detection
595
+ return Math.abs(created - modified) < 5000;
347
596
  }
348
597
  else if (event === 'fileUpdated' && isFile) {
349
- return change.createdDateTime !== change.lastModifiedDateTime;
598
+ const created = new Date(change.createdDateTime).getTime();
599
+ const modified = new Date(change.lastModifiedDateTime).getTime();
600
+ return modified > created + 5000;
350
601
  }
351
602
  else if (event === 'folderCreated' && isFolder) {
352
- return change.createdDateTime === change.lastModifiedDateTime;
603
+ const created = new Date(change.createdDateTime).getTime();
604
+ const modified = new Date(change.lastModifiedDateTime).getTime();
605
+ return Math.abs(created - modified) < 5000;
353
606
  }
354
607
  else if (event === 'folderUpdated' && isFolder) {
355
- return change.createdDateTime !== change.lastModifiedDateTime;
608
+ const created = new Date(change.createdDateTime).getTime();
609
+ const modified = new Date(change.lastModifiedDateTime).getTime();
610
+ return modified > created + 5000;
356
611
  }
357
612
  return false;
358
613
  });
614
+ // Save processed items
615
+ webhookData.processedItems = processedItems;
359
616
  if (filteredChanges.length === 0) {
360
617
  return {
361
618
  webhookResponse: 'No matching changes',
362
619
  workflowData: [],
363
620
  };
364
621
  }
622
+ // Enrich with extended metadata if requested
623
+ if (includeMetadata && filteredChanges.length > 0) {
624
+ for (const change of filteredChanges) {
625
+ try {
626
+ const itemDetails = await this.helpers.requestOAuth2.call(this, 'microsoftOneDriveBusinessOAuth2Api', {
627
+ method: 'GET',
628
+ url: `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${change.id}`,
629
+ qs: {
630
+ $select: 'id,name,size,webUrl,file,folder,publication,createdBy,lastModifiedBy,fileSystemInfo,versions'
631
+ },
632
+ json: true,
633
+ });
634
+ // Add version information
635
+ if (itemDetails.file) {
636
+ change.versionNumber = itemDetails.file.hashes ?
637
+ itemDetails['@microsoft.graph.version'] : undefined;
638
+ }
639
+ // Add lock/checkout status (SharePoint feature)
640
+ if (itemDetails.publication) {
641
+ const pub = itemDetails.publication;
642
+ change.checkoutUser = pub.checkedOutBy;
643
+ change.isCheckedOut = !!pub.checkedOutBy;
644
+ }
645
+ // Merge additional details
646
+ Object.assign(change, {
647
+ webUrl: itemDetails.webUrl,
648
+ createdBy: itemDetails.createdBy,
649
+ lastModifiedBy: itemDetails.lastModifiedBy,
650
+ fileSystemInfo: itemDetails.fileSystemInfo,
651
+ });
652
+ }
653
+ catch (enrichError) {
654
+ // If enrichment fails, continue with basic data
655
+ console.log('Failed to enrich metadata:', enrichError);
656
+ }
657
+ }
658
+ }
365
659
  return {
366
660
  workflowData: [filteredChanges.map((change) => ({ json: change }))],
367
661
  };
368
662
  }
369
663
  catch (error) {
664
+ // Handle specific error cases
665
+ const statusCode = error?.statusCode || error?.response?.status;
666
+ const errorCode = error?.error?.code || error?.response?.data?.error?.code;
667
+ // Delta link expired (410 Gone) or invalid (404)
668
+ if (statusCode === 410 || statusCode === 404 || errorCode === 'resyncRequired') {
669
+ // Reset delta link and try again with fresh sync
670
+ webhookData.deltaLink = null;
671
+ return {
672
+ webhookResponse: 'Delta link expired, will resync on next change',
673
+ workflowData: [],
674
+ };
675
+ }
676
+ // Folder not found or access denied
677
+ if (statusCode === 404 && storedWatchFolderId !== 'root') {
678
+ const errorMessage = 'Watched folder no longer exists or is inaccessible. Please reconfigure the trigger.';
679
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), errorMessage);
680
+ }
681
+ // Forbidden - permissions changed
682
+ if (statusCode === 403) {
683
+ const errorMessage = 'Access denied. Permissions may have changed or credentials need to be refreshed.';
684
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), errorMessage);
685
+ }
686
+ // Unauthorized - token expired/revoked
687
+ if (statusCode === 401) {
688
+ const errorMessage = 'Authentication failed. Please re-authenticate your OneDrive connection.';
689
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), errorMessage);
690
+ }
691
+ // Service unavailable or gateway timeout
692
+ if (statusCode === 503 || statusCode === 504) {
693
+ return {
694
+ webhookResponse: 'Microsoft Graph service temporarily unavailable, will retry on next notification',
695
+ workflowData: [],
696
+ };
697
+ }
698
+ // Rate limiting (429)
699
+ if (statusCode === 429) {
700
+ const retryAfter = error?.response?.headers?.['retry-after'] || '60';
701
+ return {
702
+ webhookResponse: `Rate limited, retry after ${retryAfter} seconds`,
703
+ workflowData: [],
704
+ };
705
+ }
370
706
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
371
707
  throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Failed to fetch delta changes: ${errorMessage}`);
372
708
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n8n-nodes-onedrive-business-sp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "n8n node for Microsoft OneDrive for Business (SharePoint)",
5
5
  "keywords": [
6
6
  "n8n-community-node-package",