@arela/uploader 1.1.4 → 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.
Files changed (95) hide show
  1. package/.claude/worktrees/agent-multi-profile/.env.template +224 -0
  2. package/.claude/worktrees/agent-multi-profile/.prettierrc +13 -0
  3. package/.claude/worktrees/agent-multi-profile/README.md +405 -0
  4. package/.claude/worktrees/agent-multi-profile/package-lock.json +7096 -0
  5. package/.claude/worktrees/agent-multi-profile/package.json +78 -0
  6. package/.claude/worktrees/agent-multi-profile/scripts/cleanup-ds-store.js +109 -0
  7. package/.claude/worktrees/agent-multi-profile/scripts/cleanup-system-files.js +69 -0
  8. package/.claude/worktrees/agent-multi-profile/scripts/scoring-compare.js +243 -0
  9. package/.claude/worktrees/agent-multi-profile/scripts/scoring-phase4-check.js +96 -0
  10. package/.claude/worktrees/agent-multi-profile/scripts/tests/phase-7-features.test.js +415 -0
  11. package/.claude/worktrees/agent-multi-profile/scripts/tests/signal-handling.test.js +275 -0
  12. package/.claude/worktrees/agent-multi-profile/scripts/tests/smart-watch-integration.test.js +554 -0
  13. package/.claude/worktrees/agent-multi-profile/scripts/tests/watch-service-integration.test.js +584 -0
  14. package/.claude/worktrees/agent-multi-profile/src/commands/AgentCommand.js +229 -0
  15. package/.claude/worktrees/agent-multi-profile/src/commands/AgentInitCommand.js +316 -0
  16. package/.claude/worktrees/agent-multi-profile/src/commands/DatastageCommand.js +164 -0
  17. package/.claude/worktrees/agent-multi-profile/src/commands/GDriveSyncCommand.js +475 -0
  18. package/.claude/worktrees/agent-multi-profile/src/commands/IdentifyCommand.js +708 -0
  19. package/.claude/worktrees/agent-multi-profile/src/commands/PollWorkerCommand.js +169 -0
  20. package/.claude/worktrees/agent-multi-profile/src/commands/PropagateCommand.js +636 -0
  21. package/.claude/worktrees/agent-multi-profile/src/commands/PushCommand.js +743 -0
  22. package/.claude/worktrees/agent-multi-profile/src/commands/ScanCommand.js +722 -0
  23. package/.claude/worktrees/agent-multi-profile/src/commands/UploadCommand.js +587 -0
  24. package/.claude/worktrees/agent-multi-profile/src/commands/WatchCommand.js +1342 -0
  25. package/.claude/worktrees/agent-multi-profile/src/commands/WorkerCommand.js +337 -0
  26. package/.claude/worktrees/agent-multi-profile/src/config/config.js +862 -0
  27. package/.claude/worktrees/agent-multi-profile/src/document-type-shared.js +131 -0
  28. package/.claude/worktrees/agent-multi-profile/src/document-types/_pedimento-shared-extractors.js +348 -0
  29. package/.claude/worktrees/agent-multi-profile/src/document-types/doda-pdf.js +121 -0
  30. package/.claude/worktrees/agent-multi-profile/src/document-types/doda-xml.js +118 -0
  31. package/.claude/worktrees/agent-multi-profile/src/document-types/factura-inter-agencia.js +186 -0
  32. package/.claude/worktrees/agent-multi-profile/src/document-types/facturas-comerciales.js +233 -0
  33. package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-completo-xml.js +372 -0
  34. package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-completo.js +108 -0
  35. package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-simplificado.js +76 -0
  36. package/.claude/worktrees/agent-multi-profile/src/document-types/proforma.js +29 -0
  37. package/.claude/worktrees/agent-multi-profile/src/document-types/support-document.js +200 -0
  38. package/.claude/worktrees/agent-multi-profile/src/errors/ErrorHandler.js +278 -0
  39. package/.claude/worktrees/agent-multi-profile/src/errors/ErrorTypes.js +104 -0
  40. package/.claude/worktrees/agent-multi-profile/src/file-detection.js +338 -0
  41. package/.claude/worktrees/agent-multi-profile/src/index.js +890 -0
  42. package/.claude/worktrees/agent-multi-profile/src/scoring/db-matcher-adapter.js +98 -0
  43. package/.claude/worktrees/agent-multi-profile/src/scoring/matchers-seed.js +386 -0
  44. package/.claude/worktrees/agent-multi-profile/src/scoring/scoring-engine.js +251 -0
  45. package/.claude/worktrees/agent-multi-profile/src/services/AdvancedFilterService.js +505 -0
  46. package/.claude/worktrees/agent-multi-profile/src/services/AutoProcessingService.js +749 -0
  47. package/.claude/worktrees/agent-multi-profile/src/services/BenchmarkingService.js +381 -0
  48. package/.claude/worktrees/agent-multi-profile/src/services/DatabaseService.js +2173 -0
  49. package/.claude/worktrees/agent-multi-profile/src/services/DatastageApiService.js +240 -0
  50. package/.claude/worktrees/agent-multi-profile/src/services/ErrorMonitor.js +275 -0
  51. package/.claude/worktrees/agent-multi-profile/src/services/GoogleDriveService.js +217 -0
  52. package/.claude/worktrees/agent-multi-profile/src/services/LoggingService.js +649 -0
  53. package/.claude/worktrees/agent-multi-profile/src/services/MonitoringService.js +401 -0
  54. package/.claude/worktrees/agent-multi-profile/src/services/PerformanceOptimizer.js +511 -0
  55. package/.claude/worktrees/agent-multi-profile/src/services/PipelineApiService.js +274 -0
  56. package/.claude/worktrees/agent-multi-profile/src/services/PipelineJobRunner.js +389 -0
  57. package/.claude/worktrees/agent-multi-profile/src/services/ProfileManager.js +164 -0
  58. package/.claude/worktrees/agent-multi-profile/src/services/ReportingService.js +511 -0
  59. package/.claude/worktrees/agent-multi-profile/src/services/ScanApiService.js +775 -0
  60. package/.claude/worktrees/agent-multi-profile/src/services/SignalHandler.js +255 -0
  61. package/.claude/worktrees/agent-multi-profile/src/services/SmartWatchDatabaseService.js +527 -0
  62. package/.claude/worktrees/agent-multi-profile/src/services/WatchService.js +783 -0
  63. package/.claude/worktrees/agent-multi-profile/src/services/upload/ApiUploadService.js +676 -0
  64. package/.claude/worktrees/agent-multi-profile/src/services/upload/BaseUploadService.js +36 -0
  65. package/.claude/worktrees/agent-multi-profile/src/services/upload/MultiApiUploadService.js +233 -0
  66. package/.claude/worktrees/agent-multi-profile/src/services/upload/SupabaseUploadService.js +148 -0
  67. package/.claude/worktrees/agent-multi-profile/src/services/upload/UploadServiceFactory.js +100 -0
  68. package/.claude/worktrees/agent-multi-profile/src/utils/CleanupManager.js +262 -0
  69. package/.claude/worktrees/agent-multi-profile/src/utils/FileOperations.js +192 -0
  70. package/.claude/worktrees/agent-multi-profile/src/utils/FileSanitizer.js +99 -0
  71. package/.claude/worktrees/agent-multi-profile/src/utils/PathDetector.js +198 -0
  72. package/.claude/worktrees/agent-multi-profile/src/utils/PathNormalizer.js +274 -0
  73. package/.claude/worktrees/agent-multi-profile/src/utils/WatchEventHandler.js +522 -0
  74. package/.claude/worktrees/agent-multi-profile/supabase/migrations/001_create_initial_schema.sql +366 -0
  75. package/.claude/worktrees/agent-multi-profile/supabase/migrations/002_align_with_arela_api_schema.sql +145 -0
  76. package/.claude/worktrees/agent-multi-profile/tests/commands/IdentifyCommand.test.js +570 -0
  77. package/.claude/worktrees/agent-multi-profile/tests/commands/PropagateCommand.test.js +568 -0
  78. package/.claude/worktrees/agent-multi-profile/tests/commands/PushCommand.test.js +754 -0
  79. package/.claude/worktrees/agent-multi-profile/tests/commands/ScanCommand.test.js +382 -0
  80. package/.claude/worktrees/agent-multi-profile/tests/unit/PathAndTableNameGeneration.test.js +1211 -0
  81. package/.claude/worktrees/agent-multi-profile/tests/unit/factura-inter-agencia.test.js +218 -0
  82. package/.claude/worktrees/agent-multi-profile/tests/unit/pedimento-completo-xml-matcher.test.js +271 -0
  83. package/.claude/worktrees/agent-multi-profile/tests/unit/pedimento-simplificado-matcher.test.js +185 -0
  84. package/.claude/worktrees/agent-multi-profile/tests/unit/scoring-engine.test.js +221 -0
  85. package/README.md +65 -0
  86. package/package.json +1 -1
  87. package/src/commands/AgentCommand.js +210 -0
  88. package/src/commands/AgentInitCommand.js +316 -0
  89. package/src/commands/PollWorkerCommand.js +11 -322
  90. package/src/config/config.js +44 -6
  91. package/src/index.js +55 -0
  92. package/src/services/LoggingService.js +35 -0
  93. package/src/services/PipelineApiService.js +7 -1
  94. package/src/services/PipelineJobRunner.js +389 -0
  95. package/src/services/ProfileManager.js +164 -0
@@ -0,0 +1,676 @@
1
+ import { Blob } from 'buffer';
2
+ import { FormData } from 'formdata-node';
3
+ import fs from 'fs';
4
+ import { Agent } from 'http';
5
+ import { Agent as HttpsAgent } from 'https';
6
+ import fetch from 'node-fetch';
7
+ import path from 'path';
8
+
9
+ import appConfig from '../../config/config.js';
10
+ import logger from '../LoggingService.js';
11
+ import { BaseUploadService } from './BaseUploadService.js';
12
+
13
+ /**
14
+ * API Upload Service
15
+ * Handles uploads to the Arela API with automatic processing
16
+ * Supports multiple API targets (agencia, cliente, default)
17
+ */
18
+ export class ApiUploadService extends BaseUploadService {
19
+ constructor() {
20
+ super();
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();
25
+
26
+ // Get API connection settings from config/environment
27
+ const maxApiConnections = parseInt(process.env.MAX_API_CONNECTIONS) || 10;
28
+ const connectionTimeout =
29
+ parseInt(process.env.API_CONNECTION_TIMEOUT) || 60000;
30
+
31
+ // Initialize HTTP agents optimized for multiple API replicas
32
+ this.httpAgent = new Agent({
33
+ keepAlive: true,
34
+ keepAliveMsecs: 30000,
35
+ maxSockets: maxApiConnections, // Match your API replica count
36
+ maxFreeSockets: Math.ceil(maxApiConnections / 2),
37
+ maxTotalSockets: maxApiConnections + 5, // Buffer for peak usage
38
+ timeout: connectionTimeout,
39
+ scheduling: 'fifo', // First-in-first-out scheduling
40
+ });
41
+
42
+ this.httpsAgent = new HttpsAgent({
43
+ keepAlive: true,
44
+ keepAliveMsecs: 30000,
45
+ maxSockets: maxApiConnections, // Match your API replica count
46
+ maxFreeSockets: Math.ceil(maxApiConnections / 2),
47
+ maxTotalSockets: maxApiConnections + 5, // Buffer for peak usage
48
+ timeout: connectionTimeout,
49
+ scheduling: 'fifo', // First-in-first-out scheduling
50
+ });
51
+
52
+ console.log(
53
+ `🔗 HTTP Agent configured for ${maxApiConnections} concurrent API connections`,
54
+ );
55
+ }
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
+
98
+ /**
99
+ * Upload files to Arela API with automatic detection and organization
100
+ * @param {Array} files - Array of file objects
101
+ * @param {Object} options - Upload options
102
+ * @returns {Promise<Object>} API response
103
+ */
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
+
113
+ const formData = new FormData();
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
+
131
+ // Add files to form data asynchronously
132
+ for (const file of filteredFiles) {
133
+ try {
134
+ // Check file size for streaming vs buffer approach
135
+ let size = file.size;
136
+ if (size === undefined || size === null) {
137
+ const stats = await fs.promises.stat(file.path);
138
+ size = stats.size;
139
+ }
140
+ const fileSizeThreshold = 10 * 1024 * 1024; // 10MB threshold
141
+
142
+ if (size > fileSizeThreshold) {
143
+ // Use streaming for large files
144
+ const fileStream = fs.createReadStream(file.path);
145
+ formData.append('files', fileStream, {
146
+ filename: file.name,
147
+ contentType: file.contentType,
148
+ knownLength: size,
149
+ });
150
+ } else {
151
+ // Use buffer for smaller files
152
+ const fileBuffer = await fs.promises.readFile(file.path);
153
+ const blob = new Blob([fileBuffer], { type: file.contentType });
154
+ formData.append('files', blob, file.name);
155
+ }
156
+ } catch (error) {
157
+ throw new Error(`Failed to read file ${file.path}: ${error.message}`);
158
+ }
159
+ }
160
+
161
+ // Add configuration parameters
162
+ if (appConfig.supabase.bucket) {
163
+ formData.append('bucket', appConfig.supabase.bucket);
164
+ }
165
+
166
+ if (options.prefix) {
167
+ formData.append('prefix', options.prefix);
168
+ }
169
+
170
+ // Handle folder structure configuration
171
+ const folderStructure = this.#buildFolderStructure(files, options);
172
+ if (folderStructure) {
173
+ formData.append('folderStructure', folderStructure);
174
+ }
175
+
176
+ // Add client path if specified
177
+ if (options.clientPath) {
178
+ formData.append('clientPath', options.clientPath);
179
+ }
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
+
186
+ // Add processing options
187
+ formData.append('autoDetect', String(options.autoDetect ?? true));
188
+ formData.append('autoOrganize', String(options.autoOrganize ?? false));
189
+ formData.append('batchSize', String(options.batchSize || 10));
190
+ formData.append('clientVersion', appConfig.packageVersion);
191
+
192
+ try {
193
+ const isHttps = this.baseUrl.startsWith('https');
194
+ const response = await fetch(
195
+ `${this.baseUrl}/api/storage/batch-upload-and-process`,
196
+ {
197
+ method: 'POST',
198
+ headers: {
199
+ 'x-api-key': this.token,
200
+ },
201
+ body: formData,
202
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
203
+ },
204
+ );
205
+
206
+ if (!response.ok) {
207
+ const errorText = await response.text();
208
+ throw new Error(
209
+ `API request failed: ${response.status} ${response.statusText} - ${errorText}`,
210
+ );
211
+ }
212
+
213
+ const result = await response.json();
214
+
215
+ // Determine if the operation was successful
216
+ // Success means at least one file was uploaded successfully, even if some failed
217
+ const hasUploads = result.uploaded && result.uploaded.length > 0;
218
+ const hasErrors = result.errors && result.errors.length > 0;
219
+ // Consider it a success if there are uploads, or if there are no errors at all
220
+ const success =
221
+ hasUploads || (!hasErrors && result.stats?.totalFiles > 0);
222
+
223
+ // Normalize response format to match DatabaseService expectations
224
+ return { success, data: result };
225
+ } catch (fetchError) {
226
+ // Return normalized error format
227
+ return { success: false, error: fetchError.message };
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Check if API service is available
233
+ * @returns {Promise<boolean>} True if available
234
+ */
235
+ async isAvailable() {
236
+ // Refresh config to get current API target
237
+ this.#updateApiConfig();
238
+
239
+ if (!this.baseUrl || !this.token) {
240
+ return false;
241
+ }
242
+
243
+ try {
244
+ const isHttps = this.baseUrl.startsWith('https');
245
+ const response = await fetch(`${this.baseUrl}/api/health`, {
246
+ headers: {
247
+ 'x-api-key': this.token,
248
+ },
249
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
250
+ });
251
+
252
+ return response.ok;
253
+ } catch (error) {
254
+ return false;
255
+ }
256
+ }
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
+
401
+ // Longer timeout for propagation (can take several minutes for large datasets)
402
+ const propagationTimeout = 5 * 60 * 1000; // 5 minutes
403
+ const controller = new AbortController();
404
+ const timeoutId = setTimeout(
405
+ () => controller.abort(),
406
+ propagationTimeout,
407
+ );
408
+
409
+ const response = await fetch(
410
+ `${this.baseUrl}/api/uploader/propagate-arela-path`,
411
+ {
412
+ method: 'POST',
413
+ headers: {
414
+ 'x-api-key': this.token,
415
+ 'Content-Type': 'application/json',
416
+ },
417
+ body: JSON.stringify({ years }),
418
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
419
+ signal: controller.signal,
420
+ },
421
+ );
422
+
423
+ clearTimeout(timeoutId);
424
+
425
+ if (!response.ok) {
426
+ const errorText = await response.text();
427
+ throw new Error(
428
+ `Arela path propagation failed: ${response.status} ${response.statusText} - ${errorText}`,
429
+ );
430
+ }
431
+
432
+ const result = await response.json();
433
+ return result;
434
+ } catch (error) {
435
+ logger.error(`Propagate arela_path API error: ${error.message}`);
436
+ return {
437
+ success: false,
438
+ processedCount: 0,
439
+ updatedCount: 0,
440
+ errorCount: 1,
441
+ error: error.message,
442
+ };
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Fetch RFC file count
448
+ * @param {Object} options - Query options
449
+ * @param {Array} options.rfcs - Array of RFC values to filter
450
+ * @returns {Promise<Object>} { count: number, error: Error|null }
451
+ */
452
+ async fetchRfcFileCount(options = {}) {
453
+ const { rfcs = [] } = options;
454
+
455
+ if (!rfcs || rfcs.length === 0) {
456
+ return { count: 0, error: null };
457
+ }
458
+
459
+ try {
460
+ const isHttps = this.baseUrl.startsWith('https');
461
+ const url = new URL(`${this.baseUrl}/api/uploader/rfc-file-count`);
462
+ url.searchParams.append('rfcs', rfcs.join(','));
463
+
464
+ const response = await fetch(url.toString(), {
465
+ method: 'GET',
466
+ headers: {
467
+ 'x-api-key': this.token,
468
+ 'Content-Type': 'application/json',
469
+ },
470
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
471
+ });
472
+
473
+ if (!response.ok) {
474
+ const errorText = await response.text();
475
+ return {
476
+ count: 0,
477
+ error: new Error(
478
+ `Failed to fetch RFC file count: ${response.status} ${response.statusText} - ${errorText}`,
479
+ ),
480
+ };
481
+ }
482
+
483
+ const data = await response.json();
484
+ return { count: data.count || 0, error: null };
485
+ } catch (error) {
486
+ logger.error(`API fetch RFC file count error: ${error.message}`);
487
+ return { count: 0, error };
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Fetch pedimento records by RFC
493
+ * @param {Object} options - Query options
494
+ * @param {Array} options.rfcs - Array of RFC values to filter
495
+ * @param {Array} options.years - Optional year filter
496
+ * @param {number} options.offset - Pagination offset
497
+ * @param {number} options.limit - Number of records to fetch
498
+ * @returns {Promise<Object>} { data: Array, error: Error|null }
499
+ */
500
+ async fetchPedimentosByRfc(options = {}) {
501
+ const { rfcs = [], years = [], offset = 0, limit = 500 } = options;
502
+
503
+ if (!rfcs || rfcs.length === 0) {
504
+ return { data: [], error: null };
505
+ }
506
+
507
+ try {
508
+ const isHttps = this.baseUrl.startsWith('https');
509
+ const url = new URL(`${this.baseUrl}/api/uploader/pedimentos-by-rfc`);
510
+ url.searchParams.append('rfcs', rfcs.join(','));
511
+ if (years && years.length > 0) {
512
+ url.searchParams.append('years', years.join(','));
513
+ }
514
+ url.searchParams.append('offset', offset);
515
+ url.searchParams.append('limit', limit);
516
+
517
+ const response = await fetch(url.toString(), {
518
+ method: 'GET',
519
+ headers: {
520
+ 'x-api-key': this.token,
521
+ 'Content-Type': 'application/json',
522
+ },
523
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
524
+ });
525
+
526
+ if (!response.ok) {
527
+ const errorText = await response.text();
528
+ return {
529
+ data: null,
530
+ error: new Error(
531
+ `Failed to fetch pedimentos by RFC: ${response.status} ${response.statusText} - ${errorText}`,
532
+ ),
533
+ };
534
+ }
535
+
536
+ const data = await response.json();
537
+ return { data, error: null };
538
+ } catch (error) {
539
+ logger.error(`API fetch pedimentos by RFC error: ${error.message}`);
540
+ return { data: null, error };
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Fetch files for upload by arela_path
546
+ * @param {Object} options - Query options
547
+ * @param {Array} options.arelaPaths - Array of arela_path values to filter
548
+ * @param {number} options.offset - Pagination offset
549
+ * @param {number} options.limit - Number of records to fetch
550
+ * @returns {Promise<Object>} { data: Array, error: Error|null }
551
+ */
552
+ async fetchFilesForUpload(options = {}) {
553
+ const { arelaPaths = [], offset = 0, limit = 1000 } = options;
554
+
555
+ if (!arelaPaths || arelaPaths.length === 0) {
556
+ return { data: [], error: null };
557
+ }
558
+
559
+ try {
560
+ const isHttps = this.baseUrl.startsWith('https');
561
+ const url = new URL(`${this.baseUrl}/api/uploader/files-for-upload`);
562
+ url.searchParams.append('arela_paths', arelaPaths.join('|'));
563
+ url.searchParams.append('offset', offset);
564
+ url.searchParams.append('limit', limit);
565
+
566
+ const response = await fetch(url.toString(), {
567
+ method: 'GET',
568
+ headers: {
569
+ 'x-api-key': this.token,
570
+ 'Content-Type': 'application/json',
571
+ },
572
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
573
+ });
574
+
575
+ if (!response.ok) {
576
+ const errorText = await response.text();
577
+ return {
578
+ data: null,
579
+ error: new Error(
580
+ `Failed to fetch files for upload: ${response.status} ${response.statusText} - ${errorText}`,
581
+ ),
582
+ };
583
+ }
584
+
585
+ const data = await response.json();
586
+ return { data, error: null };
587
+ } catch (error) {
588
+ logger.error(`API fetch files for upload error: ${error.message}`);
589
+ return { data: null, error };
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Update file status after upload
595
+ * @param {Array} updates - Array of update objects with { id, status, message, processing_status }
596
+ * @returns {Promise<Object>} Update result { success: boolean, updated: number, errors: Array }
597
+ */
598
+ async updateFileStatus(updates) {
599
+ if (!updates || !Array.isArray(updates) || updates.length === 0) {
600
+ return { success: true, updated: 0, errors: [] };
601
+ }
602
+
603
+ try {
604
+ const isHttps = this.baseUrl.startsWith('https');
605
+ const response = await fetch(
606
+ `${this.baseUrl}/api/uploader/batch-update-status`,
607
+ {
608
+ method: 'PATCH',
609
+ headers: {
610
+ 'x-api-key': this.token,
611
+ 'Content-Type': 'application/json',
612
+ },
613
+ body: JSON.stringify({ updates }),
614
+ agent: isHttps ? this.httpsAgent : this.httpAgent,
615
+ },
616
+ );
617
+
618
+ if (!response.ok) {
619
+ const errorText = await response.text();
620
+ throw new Error(
621
+ `Batch status update failed: ${response.status} ${response.statusText} - ${errorText}`,
622
+ );
623
+ }
624
+
625
+ const result = await response.json();
626
+ return result;
627
+ } catch (error) {
628
+ logger.error(`Batch status update API error: ${error.message}`);
629
+ return {
630
+ success: false,
631
+ updated: 0,
632
+ errors: [{ message: error.message }],
633
+ };
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Get service name
639
+ * @returns {string} Service name
640
+ */
641
+ getServiceName() {
642
+ return 'Arela API';
643
+ }
644
+
645
+ /**
646
+ * Build folder structure from options and auto-detection
647
+ * @private
648
+ * @param {Array} files - Files being uploaded
649
+ * @param {Object} options - Upload options
650
+ * @returns {string|null} Folder structure or null
651
+ */
652
+ #buildFolderStructure(files, options) {
653
+ let combinedStructure = null;
654
+
655
+ if (
656
+ options.folderStructure &&
657
+ options.autoDetectStructure &&
658
+ files.length > 0
659
+ ) {
660
+ // This would require the path detection utility - we'll implement this after creating that module
661
+ console.log(`📁 Custom folder structure: ${options.folderStructure}`);
662
+ return options.folderStructure;
663
+ } else if (options.folderStructure) {
664
+ console.log(
665
+ `📁 Using custom folder structure: ${options.folderStructure}`,
666
+ );
667
+ return options.folderStructure;
668
+ } else if (options.autoDetectStructure && files.length > 0) {
669
+ // Auto-detection logic would go here
670
+ console.log('📁 Auto-detection requested but not yet implemented');
671
+ return null;
672
+ }
673
+
674
+ return null;
675
+ }
676
+ }
@@ -0,0 +1,36 @@
1
+ import FormData from 'form-data';
2
+ import fs from 'fs';
3
+ import fetch from 'node-fetch';
4
+
5
+ /**
6
+ * Base upload service interface
7
+ * Defines the contract for upload implementations
8
+ */
9
+ export class BaseUploadService {
10
+ /**
11
+ * Upload files using the specific implementation
12
+ * @param {Array} files - Array of file objects with path, name, and contentType
13
+ * @param {Object} options - Upload options
14
+ * @returns {Promise<Object>} Upload result
15
+ * @throws {Error} If upload fails
16
+ */
17
+ async upload(files, options) {
18
+ throw new Error('upload() method must be implemented by subclass');
19
+ }
20
+
21
+ /**
22
+ * Check if the service is available and properly configured
23
+ * @returns {Promise<boolean>} True if service is available
24
+ */
25
+ async isAvailable() {
26
+ throw new Error('isAvailable() method must be implemented by subclass');
27
+ }
28
+
29
+ /**
30
+ * Get the service name for logging purposes
31
+ * @returns {string} Service name
32
+ */
33
+ getServiceName() {
34
+ throw new Error('getServiceName() method must be implemented by subclass');
35
+ }
36
+ }