@arela/uploader 0.2.13 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.env.template +66 -0
  2. package/README.md +263 -62
  3. package/docs/API_ENDPOINTS_FOR_DETECTION.md +647 -0
  4. package/docs/QUICK_REFERENCE_API_DETECTION.md +264 -0
  5. package/docs/REFACTORING_SUMMARY_DETECT_PEDIMENTOS.md +200 -0
  6. package/package.json +3 -2
  7. package/scripts/cleanup-ds-store.js +109 -0
  8. package/scripts/cleanup-system-files.js +69 -0
  9. package/scripts/tests/phase-7-features.test.js +415 -0
  10. package/scripts/tests/signal-handling.test.js +275 -0
  11. package/scripts/tests/smart-watch-integration.test.js +554 -0
  12. package/scripts/tests/watch-service-integration.test.js +584 -0
  13. package/src/commands/UploadCommand.js +31 -4
  14. package/src/commands/WatchCommand.js +1342 -0
  15. package/src/config/config.js +270 -2
  16. package/src/document-type-shared.js +2 -0
  17. package/src/document-types/support-document.js +200 -0
  18. package/src/file-detection.js +9 -1
  19. package/src/index.js +163 -4
  20. package/src/services/AdvancedFilterService.js +505 -0
  21. package/src/services/AutoProcessingService.js +749 -0
  22. package/src/services/BenchmarkingService.js +381 -0
  23. package/src/services/DatabaseService.js +1019 -539
  24. package/src/services/ErrorMonitor.js +275 -0
  25. package/src/services/LoggingService.js +419 -1
  26. package/src/services/MonitoringService.js +401 -0
  27. package/src/services/PerformanceOptimizer.js +511 -0
  28. package/src/services/ReportingService.js +511 -0
  29. package/src/services/SignalHandler.js +255 -0
  30. package/src/services/SmartWatchDatabaseService.js +527 -0
  31. package/src/services/WatchService.js +783 -0
  32. package/src/services/upload/ApiUploadService.js +447 -3
  33. package/src/services/upload/MultiApiUploadService.js +233 -0
  34. package/src/services/upload/SupabaseUploadService.js +12 -5
  35. package/src/services/upload/UploadServiceFactory.js +24 -0
  36. package/src/utils/CleanupManager.js +262 -0
  37. package/src/utils/FileOperations.js +44 -0
  38. package/src/utils/WatchEventHandler.js +522 -0
  39. package/supabase/migrations/001_create_initial_schema.sql +366 -0
  40. package/supabase/migrations/002_align_with_arela_api_schema.sql +145 -0
  41. package/.envbackup +0 -37
  42. package/SUPABASE_UPLOAD_FIX.md +0 -157
  43. package/commands.md +0 -14
@@ -7,17 +7,21 @@ import fetch from 'node-fetch';
7
7
  import path from 'path';
8
8
 
9
9
  import appConfig from '../../config/config.js';
10
+ import logger from '../LoggingService.js';
10
11
  import { BaseUploadService } from './BaseUploadService.js';
11
12
 
12
13
  /**
13
14
  * API Upload Service
14
15
  * Handles uploads to the Arela API with automatic processing
16
+ * Supports multiple API targets (agencia, cliente, default)
15
17
  */
16
18
  export class ApiUploadService extends BaseUploadService {
17
19
  constructor() {
18
20
  super();
19
- this.baseUrl = appConfig.api.baseUrl;
20
- this.token = appConfig.api.token;
21
+ // Flag to indicate if config is externally set (cross-tenant mode)
22
+ this._isExternalConfig = false;
23
+ // Get initial API config (can be overridden at runtime via setApiTarget)
24
+ this.#updateApiConfig();
21
25
 
22
26
  // Get API connection settings from config/environment
23
27
  const maxApiConnections = parseInt(process.env.MAX_API_CONNECTIONS) || 10;
@@ -50,6 +54,47 @@ export class ApiUploadService extends BaseUploadService {
50
54
  );
51
55
  }
52
56
 
57
+ /**
58
+ * Update API configuration from appConfig
59
+ * Called on initialization and when API target changes
60
+ * Skip if externally configured (cross-tenant mode)
61
+ * @private
62
+ */
63
+ #updateApiConfig() {
64
+ // Skip update if config is externally set
65
+ if (this._isExternalConfig) {
66
+ return;
67
+ }
68
+ const apiConfig = appConfig.getApiConfig();
69
+ this.baseUrl = apiConfig.baseUrl;
70
+ this.token = apiConfig.token;
71
+ }
72
+
73
+ /**
74
+ * Set external configuration (for cross-tenant mode)
75
+ * Prevents automatic config refresh from overwriting these values
76
+ * @param {string} baseUrl - API base URL
77
+ * @param {string} token - API token
78
+ */
79
+ setExternalConfig(baseUrl, token) {
80
+ this._isExternalConfig = true;
81
+ this.baseUrl = baseUrl;
82
+ this.token = token;
83
+ }
84
+
85
+ /**
86
+ * Get current API configuration (refreshes from appConfig)
87
+ * This ensures we always use the latest target configuration
88
+ * @returns {Object} Current API config with baseUrl and token
89
+ */
90
+ getApiConfig() {
91
+ this.#updateApiConfig();
92
+ return {
93
+ baseUrl: this.baseUrl,
94
+ token: this.token,
95
+ };
96
+ }
97
+
53
98
  /**
54
99
  * Upload files to Arela API with automatic detection and organization
55
100
  * @param {Array} files - Array of file objects
@@ -57,10 +102,34 @@ export class ApiUploadService extends BaseUploadService {
57
102
  * @returns {Promise<Object>} API response
58
103
  */
59
104
  async upload(files, options) {
105
+ // Refresh config to get current API target
106
+ this.#updateApiConfig();
107
+ // Validate files parameter
108
+ if (!files || !Array.isArray(files)) {
109
+ logger.warn(`Invalid files parameter: ${typeof files}`);
110
+ throw new Error('Files must be an array');
111
+ }
112
+
60
113
  const formData = new FormData();
61
114
 
115
+ // Filter out system files (macOS, Windows, etc.)
116
+ const systemFilePattern =
117
+ /^\.|__pycache__|\.pyc|\.swp|\.swo|Thumbs\.db|desktop\.ini|DS_Store|\$RECYCLE\.BIN|System Volume Information|~\$|\.tmp/i;
118
+ const filteredFiles = files.filter((file) => {
119
+ const fileName = file.name || path.basename(file.path);
120
+ if (systemFilePattern.test(fileName)) {
121
+ logger.warn(`Skipping system file from upload: ${fileName}`);
122
+ return false;
123
+ }
124
+ return true;
125
+ });
126
+
127
+ if (filteredFiles.length === 0) {
128
+ throw new Error('No valid files to upload after filtering system files');
129
+ }
130
+
62
131
  // Add files to form data asynchronously
63
- for (const file of files) {
132
+ for (const file of filteredFiles) {
64
133
  try {
65
134
  // Check file size for streaming vs buffer approach
66
135
  let size = file.size;
@@ -109,6 +178,11 @@ export class ApiUploadService extends BaseUploadService {
109
178
  formData.append('clientPath', options.clientPath);
110
179
  }
111
180
 
181
+ // Add RFC for multi-database routing (required for cross-tenant uploads)
182
+ if (options.rfc) {
183
+ formData.append('rfc', options.rfc);
184
+ }
185
+
112
186
  // Add processing options
113
187
  formData.append('autoDetect', String(options.autoDetect ?? true));
114
188
  formData.append('autoOrganize', String(options.autoOrganize ?? false));
@@ -159,6 +233,9 @@ export class ApiUploadService extends BaseUploadService {
159
233
  * @returns {Promise<boolean>} True if available
160
234
  */
161
235
  async isAvailable() {
236
+ // Refresh config to get current API target
237
+ this.#updateApiConfig();
238
+
162
239
  if (!this.baseUrl || !this.token) {
163
240
  return false;
164
241
  }
@@ -178,6 +255,373 @@ export class ApiUploadService extends BaseUploadService {
178
255
  }
179
256
  }
180
257
 
258
+ /**
259
+ * Batch upsert file stats to uploader table
260
+ * @param {Array} records - Array of record objects to upsert
261
+ * @returns {Promise<Object>} Upsert results { inserted, updated, total }
262
+ */
263
+ async batchUpsertStats(records) {
264
+ if (!records || !Array.isArray(records) || records.length === 0) {
265
+ return { inserted: 0, updated: 0, total: 0 };
266
+ }
267
+
268
+ try {
269
+ const isHttps = this.baseUrl.startsWith('https');
270
+ const response = await fetch(
271
+ `${this.baseUrl}/api/uploader/batch-upsert`,
272
+ {
273
+ method: 'POST',
274
+ headers: {
275
+ 'x-api-key': this.token,
276
+ 'Content-Type': 'application/json',
277
+ },
278
+ body: JSON.stringify(records),
279
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
280
+ },
281
+ );
282
+
283
+ if (!response.ok) {
284
+ const errorText = await response.text();
285
+ throw new Error(
286
+ `Batch upsert failed: ${response.status} ${response.statusText} - ${errorText}`,
287
+ );
288
+ }
289
+
290
+ const result = await response.json();
291
+ return result;
292
+ } catch (error) {
293
+ logger.error(`Batch upsert API error: ${error.message}`);
294
+ throw error;
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Fetch PDF records for pedimento detection
300
+ * @param {Object} options - Query options
301
+ * @param {number} options.offset - Pagination offset
302
+ * @param {number} options.limit - Number of records to fetch
303
+ * @returns {Promise<Object>} { data: Array, error: Error|null }
304
+ */
305
+ async fetchPdfRecordsForDetection(options = {}) {
306
+ const { offset = 0, limit = 100 } = options;
307
+
308
+ try {
309
+ const isHttps = this.baseUrl.startsWith('https');
310
+ const url = new URL(`${this.baseUrl}/api/uploader/pdf-records`);
311
+ url.searchParams.append('offset', offset);
312
+ url.searchParams.append('limit', limit);
313
+ url.searchParams.append('status', 'fs-stats');
314
+ url.searchParams.append('file_extension', 'pdf');
315
+ url.searchParams.append('is_like_simplificado', 'true');
316
+
317
+ const response = await fetch(url.toString(), {
318
+ method: 'GET',
319
+ headers: {
320
+ 'x-api-key': this.token,
321
+ 'Content-Type': 'application/json',
322
+ },
323
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
324
+ });
325
+
326
+ if (!response.ok) {
327
+ const errorText = await response.text();
328
+ return {
329
+ data: null,
330
+ error: new Error(
331
+ `Failed to fetch PDF records: ${response.status} ${response.statusText} - ${errorText}`,
332
+ ),
333
+ };
334
+ }
335
+
336
+ const data = await response.json();
337
+ return { data, error: null };
338
+ } catch (error) {
339
+ logger.error(`API fetch PDF records error: ${error.message}`);
340
+ return { data: null, error };
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Batch update detection results
346
+ * @param {Array} updates - Array of update objects with { id, ...updateData }
347
+ * @returns {Promise<Object>} Update result { success: boolean, updated: number, errors: Array }
348
+ */
349
+ async batchUpdateDetectionResults(updates) {
350
+ if (!updates || !Array.isArray(updates) || updates.length === 0) {
351
+ return { success: true, updated: 0, errors: [] };
352
+ }
353
+
354
+ try {
355
+ const isHttps = this.baseUrl.startsWith('https');
356
+ const response = await fetch(
357
+ `${this.baseUrl}/api/uploader/batch-update-detection`,
358
+ {
359
+ method: 'PATCH',
360
+ headers: {
361
+ 'x-api-key': this.token,
362
+ 'Content-Type': 'application/json',
363
+ },
364
+ body: JSON.stringify({ updates }),
365
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
366
+ },
367
+ );
368
+
369
+ if (!response.ok) {
370
+ const errorText = await response.text();
371
+ throw new Error(
372
+ `Batch update failed: ${response.status} ${response.statusText} - ${errorText}`,
373
+ );
374
+ }
375
+
376
+ const result = await response.json();
377
+ return result;
378
+ } catch (error) {
379
+ logger.error(`Batch update API error: ${error.message}`);
380
+ return {
381
+ success: false,
382
+ updated: 0,
383
+ errors: [{ message: error.message }],
384
+ };
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Execute arela_path propagation on the backend
390
+ * This triggers a server-side process that propagates arela_path from pedimentos to related files
391
+ * @param {Object} options - Propagation options
392
+ * @param {Array} options.years - Optional year filter
393
+ * @returns {Promise<Object>} Propagation result { success: boolean, processedCount, updatedCount, errorCount }
394
+ */
395
+ async propagateArelaPath(options = {}) {
396
+ const { years = [] } = options;
397
+
398
+ try {
399
+ const isHttps = this.baseUrl.startsWith('https');
400
+ const response = await fetch(
401
+ `${this.baseUrl}/api/uploader/propagate-arela-path`,
402
+ {
403
+ method: 'POST',
404
+ headers: {
405
+ 'x-api-key': this.token,
406
+ 'Content-Type': 'application/json',
407
+ },
408
+ body: JSON.stringify({ years }),
409
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
410
+ },
411
+ );
412
+
413
+ if (!response.ok) {
414
+ const errorText = await response.text();
415
+ throw new Error(
416
+ `Arela path propagation failed: ${response.status} ${response.statusText} - ${errorText}`,
417
+ );
418
+ }
419
+
420
+ const result = await response.json();
421
+ return result;
422
+ } catch (error) {
423
+ logger.error(`Propagate arela_path API error: ${error.message}`);
424
+ return {
425
+ success: false,
426
+ processedCount: 0,
427
+ updatedCount: 0,
428
+ errorCount: 1,
429
+ error: error.message,
430
+ };
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Fetch RFC file count
436
+ * @param {Object} options - Query options
437
+ * @param {Array} options.rfcs - Array of RFC values to filter
438
+ * @returns {Promise<Object>} { count: number, error: Error|null }
439
+ */
440
+ async fetchRfcFileCount(options = {}) {
441
+ const { rfcs = [] } = options;
442
+
443
+ if (!rfcs || rfcs.length === 0) {
444
+ return { count: 0, error: null };
445
+ }
446
+
447
+ try {
448
+ const isHttps = this.baseUrl.startsWith('https');
449
+ const url = new URL(`${this.baseUrl}/api/uploader/rfc-file-count`);
450
+ url.searchParams.append('rfcs', rfcs.join(','));
451
+
452
+ const response = await fetch(url.toString(), {
453
+ method: 'GET',
454
+ headers: {
455
+ 'x-api-key': this.token,
456
+ 'Content-Type': 'application/json',
457
+ },
458
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
459
+ });
460
+
461
+ if (!response.ok) {
462
+ const errorText = await response.text();
463
+ return {
464
+ count: 0,
465
+ error: new Error(
466
+ `Failed to fetch RFC file count: ${response.status} ${response.statusText} - ${errorText}`,
467
+ ),
468
+ };
469
+ }
470
+
471
+ const data = await response.json();
472
+ return { count: data.count || 0, error: null };
473
+ } catch (error) {
474
+ logger.error(`API fetch RFC file count error: ${error.message}`);
475
+ return { count: 0, error };
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Fetch pedimento records by RFC
481
+ * @param {Object} options - Query options
482
+ * @param {Array} options.rfcs - Array of RFC values to filter
483
+ * @param {Array} options.years - Optional year filter
484
+ * @param {number} options.offset - Pagination offset
485
+ * @param {number} options.limit - Number of records to fetch
486
+ * @returns {Promise<Object>} { data: Array, error: Error|null }
487
+ */
488
+ async fetchPedimentosByRfc(options = {}) {
489
+ const { rfcs = [], years = [], offset = 0, limit = 500 } = options;
490
+
491
+ if (!rfcs || rfcs.length === 0) {
492
+ return { data: [], error: null };
493
+ }
494
+
495
+ try {
496
+ const isHttps = this.baseUrl.startsWith('https');
497
+ const url = new URL(`${this.baseUrl}/api/uploader/pedimentos-by-rfc`);
498
+ url.searchParams.append('rfcs', rfcs.join(','));
499
+ if (years && years.length > 0) {
500
+ url.searchParams.append('years', years.join(','));
501
+ }
502
+ url.searchParams.append('offset', offset);
503
+ url.searchParams.append('limit', limit);
504
+
505
+ const response = await fetch(url.toString(), {
506
+ method: 'GET',
507
+ headers: {
508
+ 'x-api-key': this.token,
509
+ 'Content-Type': 'application/json',
510
+ },
511
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
512
+ });
513
+
514
+ if (!response.ok) {
515
+ const errorText = await response.text();
516
+ return {
517
+ data: null,
518
+ error: new Error(
519
+ `Failed to fetch pedimentos by RFC: ${response.status} ${response.statusText} - ${errorText}`,
520
+ ),
521
+ };
522
+ }
523
+
524
+ const data = await response.json();
525
+ return { data, error: null };
526
+ } catch (error) {
527
+ logger.error(`API fetch pedimentos by RFC error: ${error.message}`);
528
+ return { data: null, error };
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Fetch files for upload by arela_path
534
+ * @param {Object} options - Query options
535
+ * @param {Array} options.arelaPaths - Array of arela_path values to filter
536
+ * @param {number} options.offset - Pagination offset
537
+ * @param {number} options.limit - Number of records to fetch
538
+ * @returns {Promise<Object>} { data: Array, error: Error|null }
539
+ */
540
+ async fetchFilesForUpload(options = {}) {
541
+ const { arelaPaths = [], offset = 0, limit = 1000 } = options;
542
+
543
+ if (!arelaPaths || arelaPaths.length === 0) {
544
+ return { data: [], error: null };
545
+ }
546
+
547
+ try {
548
+ const isHttps = this.baseUrl.startsWith('https');
549
+ const url = new URL(`${this.baseUrl}/api/uploader/files-for-upload`);
550
+ url.searchParams.append('arela_paths', arelaPaths.join('|'));
551
+ url.searchParams.append('offset', offset);
552
+ url.searchParams.append('limit', limit);
553
+
554
+ const response = await fetch(url.toString(), {
555
+ method: 'GET',
556
+ headers: {
557
+ 'x-api-key': this.token,
558
+ 'Content-Type': 'application/json',
559
+ },
560
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
561
+ });
562
+
563
+ if (!response.ok) {
564
+ const errorText = await response.text();
565
+ return {
566
+ data: null,
567
+ error: new Error(
568
+ `Failed to fetch files for upload: ${response.status} ${response.statusText} - ${errorText}`,
569
+ ),
570
+ };
571
+ }
572
+
573
+ const data = await response.json();
574
+ return { data, error: null };
575
+ } catch (error) {
576
+ logger.error(`API fetch files for upload error: ${error.message}`);
577
+ return { data: null, error };
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Update file status after upload
583
+ * @param {Array} updates - Array of update objects with { id, status, message, processing_status }
584
+ * @returns {Promise<Object>} Update result { success: boolean, updated: number, errors: Array }
585
+ */
586
+ async updateFileStatus(updates) {
587
+ if (!updates || !Array.isArray(updates) || updates.length === 0) {
588
+ return { success: true, updated: 0, errors: [] };
589
+ }
590
+
591
+ try {
592
+ const isHttps = this.baseUrl.startsWith('https');
593
+ const response = await fetch(
594
+ `${this.baseUrl}/api/uploader/batch-update-status`,
595
+ {
596
+ method: 'PATCH',
597
+ headers: {
598
+ 'x-api-key': this.token,
599
+ 'Content-Type': 'application/json',
600
+ },
601
+ body: JSON.stringify({ updates }),
602
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
603
+ },
604
+ );
605
+
606
+ if (!response.ok) {
607
+ const errorText = await response.text();
608
+ throw new Error(
609
+ `Batch status update failed: ${response.status} ${response.statusText} - ${errorText}`,
610
+ );
611
+ }
612
+
613
+ const result = await response.json();
614
+ return result;
615
+ } catch (error) {
616
+ logger.error(`Batch status update API error: ${error.message}`);
617
+ return {
618
+ success: false,
619
+ updated: 0,
620
+ errors: [{ message: error.message }],
621
+ };
622
+ }
623
+ }
624
+
181
625
  /**
182
626
  * Get service name
183
627
  * @returns {string} Service name