n8n-nodes-onedrive-business-sp 1.1.1 → 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,6 +290,24 @@ class MicrosoftOneDriveBusinessTrigger {
242
290
  });
243
291
  actualDriveId = driveResponse.id;
244
292
  }
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
+ }
245
311
  // Microsoft Graph only supports subscriptions at drive root level
246
312
  // We'll filter by folder in the webhook handler
247
313
  const resource = `/drives/${actualDriveId}/root`;
@@ -265,7 +331,22 @@ class MicrosoftOneDriveBusinessTrigger {
265
331
  webhookData.subscriptionId = responseData.id;
266
332
  webhookData.driveId = actualDriveId;
267
333
  webhookData.watchFolderId = watchFolderId;
268
- webhookData.deltaLink = null;
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
+ }
269
350
  }
270
351
  catch (error) {
271
352
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@@ -305,6 +386,39 @@ class MicrosoftOneDriveBusinessTrigger {
305
386
  webhookResponse: query.validationToken,
306
387
  };
307
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
+ }
308
422
  // Verify client state
309
423
  const notification = bodyData.value?.[0];
310
424
  if (notification?.clientState !== 'n8n-secret-token') {
@@ -314,6 +428,13 @@ class MicrosoftOneDriveBusinessTrigger {
314
428
  }
315
429
  const event = this.getNodeParameter('event');
316
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;
317
438
  // Extract folder ID from resource locator
318
439
  let watchFolderId;
319
440
  if (typeof watchFolder === 'string') {
@@ -324,20 +445,69 @@ class MicrosoftOneDriveBusinessTrigger {
324
445
  }
325
446
  const driveId = webhookData.driveId;
326
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
+ }
327
459
  // Always use root delta query (Microsoft Graph requirement)
328
460
  let deltaUrl = webhookData.deltaLink;
329
461
  if (!deltaUrl) {
330
462
  deltaUrl = `https://graph.microsoft.com/v1.0/drives/${driveId}/root/delta`;
331
463
  }
332
464
  try {
333
- const deltaResponse = await this.helpers.requestOAuth2.call(this, 'microsoftOneDriveBusinessOAuth2Api', {
334
- method: 'GET',
335
- url: deltaUrl,
336
- json: true,
337
- });
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
+ }
338
506
  // Store the new delta link
339
- webhookData.deltaLink = deltaResponse['@odata.deltaLink'];
340
- const changes = deltaResponse.value;
507
+ if (finalDeltaLink) {
508
+ webhookData.deltaLink = finalDeltaLink;
509
+ }
510
+ const changes = allChanges;
341
511
  // Get folder path if watching specific folder
342
512
  let watchFolderPath = null;
343
513
  if (storedWatchFolderId !== 'root') {
@@ -355,45 +525,184 @@ class MicrosoftOneDriveBusinessTrigger {
355
525
  // If can't get folder path, don't filter
356
526
  }
357
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
+ }
358
537
  const filteredChanges = changes.filter((change) => {
538
+ // Validate required fields
539
+ if (!change.id || !change.name) {
540
+ return false; // Skip incomplete items
541
+ }
359
542
  const isFile = change.file !== undefined;
360
543
  const isFolder = change.folder !== undefined;
361
544
  const isDeleted = change.deleted !== undefined;
362
545
  if (isDeleted)
363
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
+ }
364
562
  // Filter by folder path if watching specific folder
365
- if (watchFolderPath && change.parentReference) {
563
+ if (storedWatchFolderId !== 'root' && change.parentReference) {
366
564
  const parentRef = change.parentReference;
367
- const itemPath = parentRef.path || '';
368
- if (!itemPath.includes(watchFolderPath)) {
369
- return false;
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
+ }
370
588
  }
371
589
  }
372
590
  if (event === 'fileCreated' && isFile) {
373
- 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;
374
596
  }
375
597
  else if (event === 'fileUpdated' && isFile) {
376
- 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;
377
601
  }
378
602
  else if (event === 'folderCreated' && isFolder) {
379
- 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;
380
606
  }
381
607
  else if (event === 'folderUpdated' && isFolder) {
382
- 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;
383
611
  }
384
612
  return false;
385
613
  });
614
+ // Save processed items
615
+ webhookData.processedItems = processedItems;
386
616
  if (filteredChanges.length === 0) {
387
617
  return {
388
618
  webhookResponse: 'No matching changes',
389
619
  workflowData: [],
390
620
  };
391
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
+ }
392
659
  return {
393
660
  workflowData: [filteredChanges.map((change) => ({ json: change }))],
394
661
  };
395
662
  }
396
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
+ }
397
706
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
398
707
  throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Failed to fetch delta changes: ${errorMessage}`);
399
708
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n8n-nodes-onedrive-business-sp",
3
- "version": "1.1.1",
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",