@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
@@ -25,6 +25,15 @@ export class DatabaseService {
25
25
  return await supabaseService.getClient();
26
26
  }
27
27
 
28
+ /**
29
+ * Get Supabase client (public wrapper for dependency injection)
30
+ * Used by specialized services like SmartWatchDatabaseService
31
+ * @returns {Promise<Object>} Supabase client
32
+ */
33
+ async getSupabaseClient() {
34
+ return this.#getSupabaseClient();
35
+ }
36
+
28
37
  /**
29
38
  * Execute database query with retry logic and exponential backoff
30
39
  * @private
@@ -71,6 +80,18 @@ export class DatabaseService {
71
80
  throw lastError;
72
81
  }
73
82
 
83
+ /**
84
+ * Execute database query with retry logic (public wrapper for dependency injection)
85
+ * Used by specialized services like SmartWatchDatabaseService
86
+ * @param {Function} queryFn - Query function to execute
87
+ * @param {string} operation - Description of the operation for logging
88
+ * @param {number} maxRetries - Maximum number of retry attempts (default: 3)
89
+ * @returns {Promise<Object>} Query result
90
+ */
91
+ async queryWithRetry(queryFn, operation, maxRetries = 3) {
92
+ return this.#queryWithRetry(queryFn, operation, maxRetries);
93
+ }
94
+
74
95
  /**
75
96
  * Insert file stats with document detection into uploader table
76
97
  * @param {Array} files - Array of file objects
@@ -112,6 +133,7 @@ export class DatabaseService {
112
133
  const filename = file.originalName || path.basename(file.path);
113
134
 
114
135
  const record = {
136
+ name: filename,
115
137
  document_type: null,
116
138
  size: stats.size,
117
139
  num_pedimento: null,
@@ -122,11 +144,14 @@ export class DatabaseService {
122
144
  rfc: null,
123
145
  message: null,
124
146
  file_extension: fileExtension,
125
- is_like_simplificado:
126
- fileExtension === 'pdf' && filename.toLowerCase().includes('simp'),
147
+ is_like_simplificado: filename.toLowerCase().includes('simp'),
127
148
  year: null,
128
149
  created_at: new Date().toISOString(),
150
+ updated_at: new Date().toISOString(),
129
151
  modified_at: stats.mtime.toISOString(),
152
+ // Queue/Processing columns (for arela-api)
153
+ processing_status: 'PENDING',
154
+ upload_attempts: 0,
130
155
  };
131
156
 
132
157
  // Try to detect document type for supported files
@@ -147,11 +172,8 @@ export class DatabaseService {
147
172
  record.year = detection.detectedPedimentoYear;
148
173
  }
149
174
 
150
- const rfcField = detection.fields.find(
151
- (f) => f.name === 'rfc' && f.found,
152
- );
153
- if (rfcField) {
154
- record.rfc = rfcField.value;
175
+ if (detection.rfc) {
176
+ record.rfc = detection.rfc;
155
177
  }
156
178
  } else {
157
179
  record.status = 'not-detected';
@@ -181,18 +203,153 @@ export class DatabaseService {
181
203
  `Inserting ${records.length} new records into uploader table...`,
182
204
  );
183
205
 
206
+ // Use upsert to handle duplicates gracefully
207
+ // This will insert new records or update existing ones (by original_path)
184
208
  const { data, error } = await supabase
185
209
  .from('uploader')
186
- .insert(records)
210
+ .upsert(records, { onConflict: 'original_path' })
187
211
  .select();
188
212
 
189
213
  if (error) {
190
214
  throw new Error(`Failed to insert stats records: ${error.message}`);
191
215
  }
192
216
 
217
+ // Propagate arela_path to related files in same folder
218
+ if (data && data.length > 0) {
219
+ await this.#propagateArelaPathToRelatedFiles(data, supabase);
220
+ }
221
+
193
222
  return data;
194
223
  }
195
224
 
225
+ /**
226
+ * Propagate arela_path from pedimento files to related files in the same directory
227
+ * When a pedimento_simplificado is detected, all files in its directory get the same arela_path
228
+ * Also checks database for existing files in the same directory that need the arela_path
229
+ * @private
230
+ * @param {Array} insertedRecords - Records that were just inserted
231
+ * @param {Object} supabase - Supabase client
232
+ * @returns {Promise<void>}
233
+ */
234
+ async #propagateArelaPathToRelatedFiles(insertedRecords, supabase) {
235
+ try {
236
+ // Group records by directory
237
+ const recordsByDir = {};
238
+
239
+ for (const record of insertedRecords) {
240
+ if (!record.original_path) continue;
241
+
242
+ const dirPath = path.dirname(record.original_path);
243
+ if (!recordsByDir[dirPath]) {
244
+ recordsByDir[dirPath] = {
245
+ pedimentos: [],
246
+ allFiles: [],
247
+ };
248
+ }
249
+
250
+ recordsByDir[dirPath].allFiles.push(record);
251
+
252
+ // Identify pedimento files
253
+ if (
254
+ record.document_type === 'pedimento_simplificado' &&
255
+ record.arela_path
256
+ ) {
257
+ recordsByDir[dirPath].pedimentos.push(record);
258
+ }
259
+ }
260
+
261
+ // For each directory with a pedimento, propagate its arela_path to related files
262
+ for (const [dirPath, dirData] of Object.entries(recordsByDir)) {
263
+ if (dirData.pedimentos.length === 0) continue;
264
+
265
+ // Use the first pedimento's arela_path (should be only one per directory typically)
266
+ const pedimentoRecord = dirData.pedimentos[0];
267
+ const arelaPath = pedimentoRecord.arela_path;
268
+
269
+ logger.info(
270
+ `📁 Propagating arela_path from pedimento to files in ${path.basename(dirPath)}/`,
271
+ );
272
+
273
+ // Step 1: Update newly inserted files in this directory
274
+ const fileIds = dirData.allFiles
275
+ .filter(
276
+ (f) =>
277
+ f.id &&
278
+ f.arela_path !== arelaPath &&
279
+ f.document_type !== 'pedimento_simplificado',
280
+ )
281
+ .map((f) => f.id);
282
+
283
+ if (fileIds.length > 0) {
284
+ const { error: updateError } = await supabase
285
+ .from('uploader')
286
+ .update({ arela_path: arelaPath })
287
+ .in('id', fileIds);
288
+
289
+ if (updateError) {
290
+ logger.warn(
291
+ `Could not propagate arela_path: ${updateError.message}`,
292
+ );
293
+ } else {
294
+ logger.info(
295
+ `✅ Updated ${fileIds.length} related files with arela_path: ${arelaPath}`,
296
+ );
297
+ }
298
+ }
299
+
300
+ // Step 2: Also find and update any existing files in the same directory that don't have arela_path
301
+ // This handles the case where files were detected earlier but the pedimento is detected now
302
+ try {
303
+ const dirPattern = dirPath.replace(/\\/g, '/'); // Normalize for SQL LIKE
304
+ const { data: existingFiles, error: fetchError } = await supabase
305
+ .from('uploader')
306
+ .select('id, original_path, document_type')
307
+ .like('original_path', `${dirPattern}/%`)
308
+ .is('arela_path', null)
309
+ .limit(1000); // Reasonable limit to avoid huge queries
310
+
311
+ if (fetchError) {
312
+ logger.warn(
313
+ `Could not fetch existing files in ${path.basename(dirPath)}: ${fetchError.message}`,
314
+ );
315
+ } else if (existingFiles && existingFiles.length > 0) {
316
+ const existingFileIds = existingFiles
317
+ .filter(
318
+ (f) => f.id && f.document_type !== 'pedimento_simplificado',
319
+ )
320
+ .map((f) => f.id);
321
+
322
+ if (existingFileIds.length > 0) {
323
+ const { error: existingError } = await supabase
324
+ .from('uploader')
325
+ .update({ arela_path: arelaPath })
326
+ .in('id', existingFileIds);
327
+
328
+ if (existingError) {
329
+ logger.warn(
330
+ `Could not update existing files with arela_path: ${existingError.message}`,
331
+ );
332
+ } else {
333
+ logger.info(
334
+ `✅ Updated ${existingFileIds.length} existing files in directory with arela_path: ${arelaPath}`,
335
+ );
336
+ }
337
+ }
338
+ }
339
+ } catch (existingFilesError) {
340
+ logger.warn(
341
+ `Error checking for existing files in ${path.basename(dirPath)}: ${existingFilesError.message}`,
342
+ );
343
+ }
344
+ }
345
+ } catch (error) {
346
+ logger.warn(
347
+ `Error propagating arela_path to related files: ${error.message}`,
348
+ );
349
+ // Don't throw - this is a non-critical operation
350
+ }
351
+ }
352
+
196
353
  /**
197
354
  * Insert file stats only (no detection) into uploader table
198
355
  * @param {Array} files - Array of file objects
@@ -200,13 +357,30 @@ export class DatabaseService {
200
357
  * @returns {Promise<Object>} Statistics about the operation
201
358
  */
202
359
  async insertStatsOnlyToUploaderTable(files, options) {
203
- const supabase = await this.#getSupabaseClient();
204
360
  const batchSize = 1000;
361
+ const quietMode = options?.quietMode || false;
205
362
  const allRecords = [];
206
363
 
207
- logger.info('Collecting filesystem stats...');
364
+ if (!quietMode) {
365
+ logger.info('Collecting filesystem stats...');
366
+ }
367
+
368
+ // Filter out system files and hidden files (macOS, Windows, Python, editors)
369
+ const systemFilePattern =
370
+ /^\.|__pycache__|\.pyc|\.swp|\.swo|Thumbs\.db|desktop\.ini|DS_Store|\$RECYCLE\.BIN|System Volume Information|~\$|\.tmp/i;
371
+
208
372
  for (const file of files) {
209
373
  try {
374
+ const fileName = file.originalName || path.basename(file.path);
375
+
376
+ // Skip system and hidden files
377
+ if (systemFilePattern.test(fileName)) {
378
+ if (!quietMode) {
379
+ logger.debug(`Skipping system file: ${fileName}`);
380
+ }
381
+ continue;
382
+ }
383
+
210
384
  const stats = file.stats || fs.statSync(file.path);
211
385
  const originalPath = options.clientPath || file.path;
212
386
  const fileExtension = path
@@ -215,24 +389,27 @@ export class DatabaseService {
215
389
  .replace('.', '');
216
390
 
217
391
  const record = {
218
- document_type: null,
392
+ name: file.originalName || path.basename(file.path),
393
+ documentType: null,
219
394
  size: stats.size,
220
- num_pedimento: null,
395
+ numPedimento: null,
221
396
  filename: file.originalName || path.basename(file.path),
222
- original_path: originalPath,
223
- arela_path: null,
397
+ originalPath: originalPath,
398
+ arelaPath: null,
224
399
  status: 'fs-stats',
225
400
  rfc: null,
226
401
  message: null,
227
- file_extension: fileExtension,
228
- created_at: new Date().toISOString(),
229
- modified_at: stats.mtime.toISOString(),
230
- is_like_simplificado:
402
+ fileExtension: fileExtension,
403
+ modifiedAt: stats.mtime.toISOString(),
404
+ isLikeSimplificado:
231
405
  fileExtension === 'pdf' &&
232
406
  (file.originalName || path.basename(file.path))
233
407
  .toLowerCase()
234
408
  .includes('simp'),
235
409
  year: null,
410
+ // Queue/Processing columns (for arela-api)
411
+ processingStatus: 'PENDING',
412
+ uploadAttempts: 0,
236
413
  };
237
414
 
238
415
  allRecords.push(record);
@@ -242,104 +419,66 @@ export class DatabaseService {
242
419
  }
243
420
 
244
421
  if (allRecords.length === 0) {
245
- logger.info('No file stats to insert');
422
+ if (!quietMode) {
423
+ logger.info('No file stats to insert');
424
+ }
246
425
  return { totalInserted: 0, totalSkipped: 0, totalProcessed: 0 };
247
426
  }
248
427
 
249
- logger.info(
250
- `Processing ${allRecords.length} file stats in batches of ${batchSize}...`,
251
- );
428
+ if (!quietMode) {
429
+ logger.info(
430
+ `Processing ${allRecords.length} file stats in batches of ${batchSize}...`,
431
+ );
432
+ }
252
433
 
253
434
  let totalInserted = 0;
254
435
  let totalUpdated = 0;
255
436
 
437
+ // Use API service for batch upsert
438
+ // If apiTarget is specified, use that specific API, otherwise use default
439
+ let uploadService;
440
+ if (options.apiTarget) {
441
+ uploadService = await uploadServiceFactory.getApiServiceForTarget(
442
+ options.apiTarget,
443
+ );
444
+ } else {
445
+ uploadService = await uploadServiceFactory.getUploadService(
446
+ options.forceSupabase,
447
+ );
448
+ }
449
+
450
+ // Use API service for batch upsert
256
451
  for (let i = 0; i < allRecords.length; i += batchSize) {
257
452
  const batch = allRecords.slice(i, i + batchSize);
258
453
 
259
454
  try {
260
- // Check which records already exist
261
- const originalPaths = batch.map((r) => r.original_path);
262
- const { data: existingRecords, error: checkError } = await supabase
263
- .from('uploader')
264
- .select('original_path')
265
- .in('original_path', originalPaths);
266
-
267
- if (checkError) {
268
- logger.error(
269
- `Error checking existing records: ${checkError.message}`,
270
- );
271
- continue;
272
- }
455
+ const result = await uploadService.batchUpsertStats(batch);
273
456
 
274
- const existingPaths = new Set(
275
- existingRecords?.map((r) => r.original_path) || [],
276
- );
277
- const newRecords = batch.filter(
278
- (r) => !existingPaths.has(r.original_path),
279
- );
280
- const updateRecords = batch.filter((r) =>
281
- existingPaths.has(r.original_path),
282
- );
457
+ totalInserted += result.inserted || 0;
458
+ totalUpdated += result.updated || 0;
283
459
 
284
- // Only log every 10th batch to reduce noise
460
+ // Only log every 10th batch to reduce noise (skip in quiet mode)
285
461
  if (
286
- (Math.floor(i / batchSize) + 1) % 10 === 0 ||
287
- Math.floor(i / batchSize) + 1 === 1
462
+ !quietMode &&
463
+ ((Math.floor(i / batchSize) + 1) % 10 === 0 ||
464
+ Math.floor(i / batchSize) + 1 === 1)
288
465
  ) {
289
466
  logger.info(
290
- `Batch ${Math.floor(i / batchSize) + 1}: ${newRecords.length} new, ${updateRecords.length} updates`,
467
+ `Batch ${Math.floor(i / batchSize) + 1}: ${result.inserted || 0} new, ${result.updated || 0} updates`,
291
468
  );
292
469
  }
293
-
294
- // Insert new records
295
- if (newRecords.length > 0) {
296
- const { error: insertError } = await supabase
297
- .from('uploader')
298
- .insert(newRecords);
299
-
300
- if (insertError) {
301
- logger.error(`Error inserting new records: ${insertError.message}`);
302
- } else {
303
- totalInserted += newRecords.length;
304
- // Only log the batch insertion, not the summary (which comes at the end)
305
- }
306
- }
307
-
308
- // Update existing records
309
- if (updateRecords.length > 0) {
310
- let batchUpdated = 0;
311
- for (const record of updateRecords) {
312
- const { error: updateError } = await supabase
313
- .from('uploader')
314
- .update({
315
- size: record.size,
316
- modified_at: record.modified_at,
317
- filename: record.filename,
318
- file_extension: record.file_extension,
319
- is_like_simplificado: record.is_like_simplificado,
320
- })
321
- .eq('original_path', record.original_path);
322
-
323
- if (!updateError) {
324
- batchUpdated++;
325
- }
326
- }
327
- totalUpdated += batchUpdated;
328
- // Reduce logging noise - only log when there are updates
329
- if (batchUpdated > 0) {
330
- logger.info(`Updated ${batchUpdated} existing records`);
331
- }
332
- }
333
470
  } catch (error) {
334
471
  logger.error(
335
- `Unexpected error in batch ${Math.floor(i / batchSize) + 1}: ${error.message}`,
472
+ `Error in batch ${Math.floor(i / batchSize) + 1}: ${error.message}`,
336
473
  );
337
474
  }
338
475
  }
339
476
 
340
- logger.success(
341
- `Phase 1 Summary: ${totalInserted} new records inserted, ${totalUpdated} existing records updated`,
342
- );
477
+ if (!quietMode) {
478
+ logger.success(
479
+ `Phase 1 Summary: ${totalInserted} new records inserted, ${totalUpdated} existing records updated`,
480
+ );
481
+ }
343
482
 
344
483
  return {
345
484
  totalInserted,
@@ -354,8 +493,6 @@ export class DatabaseService {
354
493
  * @returns {Promise<Object>} Processing result
355
494
  */
356
495
  async detectPedimentosInDatabase(options = {}) {
357
- const supabase = await this.#getSupabaseClient();
358
-
359
496
  logger.info(
360
497
  'Phase 2: Starting PDF detection for pedimento-simplificado documents...',
361
498
  );
@@ -370,6 +507,25 @@ export class DatabaseService {
370
507
  let offset = 0;
371
508
  let chunkNumber = 1;
372
509
 
510
+ // Get API service - use specific target if provided
511
+ let apiService;
512
+ if (options.apiTarget) {
513
+ apiService = await uploadServiceFactory.getApiServiceForTarget(
514
+ options.apiTarget,
515
+ );
516
+ logger.info(`Using API target: ${options.apiTarget}`);
517
+ } else {
518
+ apiService = await uploadServiceFactory.getUploadService();
519
+ }
520
+
521
+ if (apiService.getServiceName() !== 'Arela API') {
522
+ throw new Error(
523
+ 'API service is required for PDF detection. Please configure ARELA_API_URL and ARELA_API_TOKEN.',
524
+ );
525
+ }
526
+
527
+ logger.info('Using API service for PDF detection...');
528
+
373
529
  logger.info(
374
530
  `Processing PDF files in chunks of ${queryBatchSize} records...`,
375
531
  );
@@ -380,18 +536,12 @@ export class DatabaseService {
380
536
  );
381
537
 
382
538
  try {
383
- // Split the query to make it more efficient with retry logic
539
+ // Fetch records using API
384
540
  const { data: pdfRecords, error: queryError } =
385
- await this.#queryWithRetry(async () => {
386
- return await supabase
387
- .from('uploader')
388
- .select('id, original_path, filename, file_extension, status')
389
- .eq('status', 'fs-stats')
390
- .eq('file_extension', 'pdf')
391
- .eq('is_like_simplificado', true)
392
- .range(offset, offset + queryBatchSize - 1)
393
- .order('created_at');
394
- }, `fetch PDF records chunk ${chunkNumber}`);
541
+ await apiService.fetchPdfRecordsForDetection({
542
+ offset,
543
+ limit: queryBatchSize,
544
+ });
395
545
 
396
546
  if (queryError) {
397
547
  throw new Error(
@@ -413,9 +563,10 @@ export class DatabaseService {
413
563
  let chunkErrors = 0;
414
564
 
415
565
  // Process files in smaller batches
566
+ const batchUpdates = [];
567
+
416
568
  for (let i = 0; i < pdfRecords.length; i += processingBatchSize) {
417
569
  const batch = pdfRecords.slice(i, i + processingBatchSize);
418
- const updatePromises = [];
419
570
 
420
571
  for (const record of batch) {
421
572
  try {
@@ -423,15 +574,11 @@ export class DatabaseService {
423
574
  logger.warn(
424
575
  `File not found: ${record.filename} at ${record.original_path}`,
425
576
  );
426
- updatePromises.push(
427
- supabase
428
- .from('uploader')
429
- .update({
430
- status: 'file-not-found',
431
- message: 'File no longer exists at original path',
432
- })
433
- .eq('id', record.id),
434
- );
577
+ batchUpdates.push({
578
+ id: record.id,
579
+ status: 'file-not-found',
580
+ message: 'File no longer exists at original path',
581
+ });
435
582
  chunkErrors++;
436
583
  totalErrors++;
437
584
  continue;
@@ -444,28 +591,24 @@ export class DatabaseService {
444
591
  totalProcessed++;
445
592
 
446
593
  const updateData = {
594
+ id: record.id,
447
595
  status: detection.detectedType ? 'detected' : 'not-detected',
448
- document_type: detection.detectedType,
449
- num_pedimento: detection.detectedPedimento,
450
- arela_path: detection.arelaPath,
596
+ documentType: detection.detectedType,
597
+ numPedimento: detection.detectedPedimento,
598
+ arelaPath: detection.arelaPath,
451
599
  message: detection.error || null,
452
600
  year: detection.detectedPedimentoYear || null,
453
601
  };
454
602
 
455
- if (detection.fields) {
456
- const rfcField = detection.fields.find(
457
- (f) => f.name === 'rfc' && f.found,
458
- );
459
- if (rfcField) {
460
- updateData.rfc = rfcField.value;
461
- }
603
+ if (detection.rfc) {
604
+ updateData.rfc = detection.rfc;
462
605
  }
463
606
 
464
607
  if (detection.detectedType) {
465
608
  chunkDetected++;
466
609
  totalDetected++;
467
610
  logger.success(
468
- `Detected: ${record.filename} -> ${detection.detectedType} | Pedimento: ${detection.detectedPedimento || 'N/A'} | RFC: ${detection.fields?.rfc || 'N/A'}`,
611
+ `Detected: ${record.filename} -> ${detection.detectedType} | Pedimento: ${detection.detectedPedimento || 'N/A'} | RFC: ${updateData.rfc || 'N/A'}`,
469
612
  );
470
613
  } else {
471
614
  logger.info(
@@ -473,12 +616,7 @@ export class DatabaseService {
473
616
  );
474
617
  }
475
618
 
476
- updatePromises.push(
477
- supabase
478
- .from('uploader')
479
- .update(updateData)
480
- .eq('id', record.id),
481
- );
619
+ batchUpdates.push(updateData);
482
620
  } catch (error) {
483
621
  logger.error(
484
622
  `Error detecting ${record.filename}: ${error.message}`,
@@ -486,25 +624,30 @@ export class DatabaseService {
486
624
  chunkErrors++;
487
625
  totalErrors++;
488
626
 
489
- updatePromises.push(
490
- supabase
491
- .from('uploader')
492
- .update({
493
- status: 'detection-error',
494
- message: error.message,
495
- })
496
- .eq('id', record.id),
497
- );
627
+ batchUpdates.push({
628
+ id: record.id,
629
+ status: 'detection-error',
630
+ message: error.message,
631
+ });
498
632
  }
499
633
  }
634
+ }
500
635
 
501
- try {
502
- await Promise.all(updatePromises);
503
- } catch (error) {
504
- logger.error(
505
- `Error updating batch in chunk ${chunkNumber}: ${error.message}`,
506
- );
636
+ // Batch update using API
637
+ try {
638
+ if (batchUpdates.length > 0) {
639
+ const updateResult =
640
+ await apiService.batchUpdateDetectionResults(batchUpdates);
641
+ if (!updateResult.success) {
642
+ logger.error(
643
+ `Some updates failed in chunk ${chunkNumber}: ${updateResult.errors?.length || 0} errors`,
644
+ );
645
+ }
507
646
  }
647
+ } catch (error) {
648
+ logger.error(
649
+ `Error updating batch in chunk ${chunkNumber}: ${error.message}`,
650
+ );
508
651
  }
509
652
 
510
653
  logger.success(
@@ -550,276 +693,156 @@ export class DatabaseService {
550
693
 
551
694
  /**
552
695
  * Propagate arela_path from pedimento_simplificado records to related files
696
+ * This operation is performed entirely on the backend for efficiency
553
697
  * @param {Object} options - Options for propagation
554
698
  * @returns {Promise<Object>} Processing result
555
699
  */
556
700
  async propagateArelaPath(options = {}) {
557
- const supabase = await this.#getSupabaseClient();
558
-
559
701
  logger.info('Phase 3: Starting arela_path and year propagation process...');
560
- console.log('🔍 Processing pedimento_simplificado records page by page...');
702
+ console.log('🔍 Triggering backend propagation process...');
561
703
 
562
- // Log year filtering configuration
563
- if (appConfig.upload.years && appConfig.upload.years.length > 0) {
564
- logger.info(
565
- `🗓️ Year filter enabled: ${appConfig.upload.years.join(', ')}`,
566
- );
567
- console.log(
568
- `🗓️ Year filter enabled: ${appConfig.upload.years.join(', ')}`,
704
+ // Get API service - use specific target if provided
705
+ let apiService;
706
+ if (options.apiTarget) {
707
+ apiService = await uploadServiceFactory.getApiServiceForTarget(
708
+ options.apiTarget,
569
709
  );
710
+ logger.info(`Using API target: ${options.apiTarget}`);
570
711
  } else {
571
- logger.info('🗓️ No year filter configured - processing all years');
572
- console.log('🗓️ No year filter configured - processing all years');
712
+ apiService = await uploadServiceFactory.getUploadService();
573
713
  }
574
714
 
575
- let totalProcessed = 0;
576
- let totalUpdated = 0;
577
- let totalErrors = 0;
578
- let offset = 0;
579
- const pageSize = 50;
580
- let hasMoreData = true;
581
- let pageNumber = 1;
582
- const BATCH_SIZE = 50; // Process files in batches
583
-
584
- // Process pedimento records page by page for memory efficiency
585
- while (hasMoreData) {
586
- logger.info(
587
- `Fetching and processing pedimento records page ${pageNumber} (records ${offset + 1} to ${offset + pageSize})...`,
588
- );
589
-
590
- let query = supabase
591
- .from('uploader')
592
- .select('id, original_path, arela_path, filename, year')
593
- .eq('document_type', 'pedimento_simplificado')
594
- .not('arela_path', 'is', null);
595
-
596
- // Add year filter if UPLOAD_YEARS is configured
597
- if (appConfig.upload.years && appConfig.upload.years.length > 0) {
598
- query = query.in('year', appConfig.upload.years);
599
- }
600
-
601
- const { data: pedimentoPage, error: pedimentoError } = await query
602
- .range(offset, offset + pageSize - 1)
603
- .order('created_at');
604
-
605
- if (pedimentoError) {
606
- const errorMsg = `Error fetching pedimento records page ${pageNumber}: ${pedimentoError.message}`;
607
- logger.error(errorMsg);
608
- throw new Error(errorMsg);
609
- }
610
-
611
- if (!pedimentoPage || pedimentoPage.length === 0) {
612
- hasMoreData = false;
613
- logger.info('No more pedimento records found');
614
- break;
615
- }
616
-
617
- logger.info(
618
- `Processing page ${pageNumber}: ${pedimentoPage.length} pedimento records`,
715
+ if (apiService.getServiceName() !== 'Arela API') {
716
+ throw new Error(
717
+ 'API service is required for arela_path propagation. Please configure ARELA_API_URL and ARELA_API_TOKEN.',
619
718
  );
719
+ }
620
720
 
621
- // Process each pedimento record in the current page
622
- for (const pedimento of pedimentoPage) {
623
- try {
624
- totalProcessed++;
625
-
626
- // Extract base path from original_path (remove filename)
627
- const basePath = path.dirname(pedimento.original_path);
628
-
629
- logger.info(
630
- `Processing pedimento: ${pedimento.filename} | Base path: ${basePath} | Year: ${pedimento.year || 'N/A'}`,
631
- );
632
-
633
- // Extract folder part from existing arela_path
634
- const existingPath = pedimento.arela_path;
635
- const folderArelaPath = existingPath.includes('/')
636
- ? existingPath.substring(0, existingPath.lastIndexOf('/')) + '/'
637
- : existingPath.endsWith('/')
638
- ? existingPath
639
- : existingPath + '/';
640
-
641
- // Process related files page by page for memory efficiency
642
- let relatedFilesFrom = 0;
643
- const relatedFilesPageSize = 50;
644
- let hasMoreRelatedFiles = true;
645
- let relatedFilesPageNumber = 1;
646
- let totalRelatedFilesProcessed = 0;
647
-
648
- logger.info(
649
- `Searching and processing related files in base path: ${basePath}`,
650
- );
651
-
652
- while (hasMoreRelatedFiles) {
653
- const { data: relatedFilesPage, error: relatedError } =
654
- await this.#queryWithRetry(async () => {
655
- return await supabase
656
- .from('uploader')
657
- .select('id, filename, original_path')
658
- .like('original_path', `${basePath}%`)
659
- .is('arela_path', null)
660
- .neq('id', pedimento.id) // Exclude the pedimento itself
661
- .range(
662
- relatedFilesFrom,
663
- relatedFilesFrom + relatedFilesPageSize - 1,
664
- );
665
- }, `query related files for ${pedimento.filename} (page ${relatedFilesPageNumber})`);
666
-
667
- if (relatedError) {
668
- logger.error(
669
- `Error finding related files for ${pedimento.filename}: ${relatedError.message}`,
670
- );
671
- totalErrors++;
672
- break;
673
- }
674
- // console.log(`query by basePath: ${basePath} count: [${relatedFilesPage.length}]`);
675
-
676
- if (!relatedFilesPage || relatedFilesPage.length === 0) {
677
- hasMoreRelatedFiles = false;
678
- if (totalRelatedFilesProcessed === 0) {
679
- logger.info(`No related files found for ${pedimento.filename}`);
680
- }
681
- break;
682
- }
683
-
684
- logger.info(
685
- `Processing related files page ${relatedFilesPageNumber}: ${relatedFilesPage.length} files for ${pedimento.filename}`,
686
- );
687
-
688
- // Track if any updates occurred in this page
689
- let updatesOccurred = false;
690
- // Process this page of related files in batches
691
- const pageFileIds = relatedFilesPage.map((f) => f.id);
721
+ logger.info('Using API service for arela_path propagation...');
692
722
 
693
- for (let i = 0; i < pageFileIds.length; i += BATCH_SIZE) {
694
- const batchIds = pageFileIds.slice(i, i + BATCH_SIZE);
695
- const batchNumber =
696
- Math.floor(relatedFilesFrom / BATCH_SIZE) +
697
- Math.floor(i / BATCH_SIZE) +
698
- 1;
723
+ // Log year filtering configuration
724
+ const years = appConfig.upload.years || [];
725
+ if (years.length > 0) {
726
+ logger.info(`🗓️ Year filter enabled: ${years.join(', ')}`);
727
+ console.log(`🗓️ Year filter enabled: ${years.join(', ')}`);
728
+ } else {
729
+ logger.info('🗓️ No year filter configured - processing all years');
730
+ console.log('🗓️ No year filter configured - processing all years');
731
+ }
699
732
 
700
- logger.info(
701
- `Updating batch ${batchNumber}: ${batchIds.length} files with arela_path and year...`,
702
- );
733
+ console.log('⏳ Processing on backend... This may take a moment.');
703
734
 
704
- try {
705
- const { error: updateError } = await supabase
706
- .from('uploader')
707
- .update({
708
- arela_path: folderArelaPath,
709
- year: pedimento.year,
710
- })
711
- .in('id', batchIds);
712
-
713
- if (updateError) {
714
- logger.error(
715
- `Error in batch ${batchNumber}: ${updateError.message}`,
716
- );
717
- totalErrors++;
718
- } else {
719
- totalUpdated += batchIds.length;
720
- totalRelatedFilesProcessed += batchIds.length;
721
- updatesOccurred = true; // Mark that updates occurred
722
- logger.info(
723
- `Successfully updated batch ${batchNumber}: ${batchIds.length} files with arela_path and year`,
724
- );
725
- }
726
- } catch (batchError) {
727
- logger.error(
728
- `Exception in batch ${batchNumber}: ${batchError.message}`,
729
- );
730
- totalErrors++;
731
- }
732
- }
735
+ // Trigger backend propagation - all logic runs server-side
736
+ const result = await apiService.propagateArelaPath({ years });
733
737
 
734
- // Check if we need to fetch the next page of related files
735
- if (relatedFilesPage.length < relatedFilesPageSize) {
736
- logger.info('No more related files for', basePath);
737
- hasMoreRelatedFiles = false;
738
- logger.info(
739
- `Completed processing related files for ${pedimento.filename}. Total processed: ${totalRelatedFilesProcessed}`,
740
- );
741
- } else {
742
- // If updates occurred, reset pagination to start from beginning
743
- // since records that matched the query may no longer match after update
744
- if (updatesOccurred) {
745
- relatedFilesFrom = 0;
746
- logger.info(
747
- `Page ${relatedFilesPageNumber} complete with updates: ${relatedFilesPage.length} files processed, restarting pagination from beginning due to query condition changes...`,
748
- );
749
- } else {
750
- relatedFilesFrom += relatedFilesPageSize - 1;
751
- logger.info(
752
- `Page ${relatedFilesPageNumber} complete: ${relatedFilesPage.length} files processed, continuing to next page...`,
753
- );
754
- }
755
- relatedFilesPageNumber++;
756
- }
757
- }
758
- } catch (error) {
759
- logger.error(
760
- `Error processing pedimento ${pedimento.filename}: ${error.message}`,
761
- );
762
- totalErrors++;
763
- }
764
- }
765
-
766
- // Check if we need to fetch the next page
767
- if (pedimentoPage.length < pageSize) {
768
- hasMoreData = false;
769
- logger.info(
770
- `Completed processing. Last page ${pageNumber} had ${pedimentoPage.length} records`,
771
- );
772
- } else {
773
- offset += pageSize;
774
- pageNumber++;
775
- logger.info(
776
- `Page ${pageNumber - 1} complete: ${pedimentoPage.length} records processed, moving to next page...`,
777
- );
778
- }
738
+ if (!result.success) {
739
+ const errorMsg = `Backend propagation failed: ${result.error || 'Unknown error'}`;
740
+ logger.error(errorMsg);
741
+ throw new Error(errorMsg);
779
742
  }
780
743
 
781
- // Final summary
782
- if (totalProcessed === 0) {
744
+ // Display results
745
+ const { processedCount = 0, updatedCount = 0, errorCount = 0 } = result;
746
+
747
+ if (processedCount === 0) {
783
748
  logger.info('No pedimento_simplificado records with arela_path found');
784
749
  console.log(
785
750
  'ℹ️ No pedimento_simplificado records with arela_path found',
786
751
  );
787
752
  } else {
788
- console.log(
789
- `📋 Processed ${totalProcessed} pedimento records across ${pageNumber - 1} pages`,
790
- );
791
- logger.info(
792
- `Processed ${totalProcessed} pedimento records across ${pageNumber - 1} pages`,
793
- );
753
+ console.log(`📋 Processed ${processedCount} pedimento records`);
754
+ logger.info(`Processed ${processedCount} pedimento records`);
794
755
  }
795
756
 
796
- const result = {
797
- processedCount: totalProcessed,
798
- updatedCount: totalUpdated,
799
- errorCount: totalErrors,
800
- };
801
-
802
757
  logger.success(
803
- `Phase 3 Summary: ${totalProcessed} pedimentos processed, ${totalUpdated} files updated with arela_path and year, ${totalErrors} errors`,
758
+ `Phase 3 Summary: ${processedCount} pedimentos processed, ${updatedCount} files updated with arela_path and year, ${errorCount} errors`,
804
759
  );
805
760
 
806
- return result;
761
+ return {
762
+ processedCount,
763
+ updatedCount,
764
+ errorCount,
765
+ };
807
766
  }
808
767
 
809
768
  /**
810
769
  * Upload files to Arela API based on specific RFC values
770
+ * Supports cross-tenant mode where source and target APIs can be different
811
771
  * @param {Object} options - Upload options
772
+ * @param {string} options.sourceApi - Source API target for reading data (cross-tenant mode)
773
+ * @param {string} options.targetApi - Target API target for uploading files (cross-tenant mode)
774
+ * @param {string} options.apiTarget - Single API target for both reading and uploading (single API mode)
812
775
  * @returns {Promise<Object>} Processing result
813
776
  */
814
777
  async uploadFilesByRfc(options = {}) {
815
- const supabase = await this.#getSupabaseClient();
816
- const uploadService = await uploadServiceFactory.getUploadService();
817
-
818
778
  // Get configuration
819
779
  const appConfig = await import('../config/config.js').then(
820
780
  (m) => m.appConfig,
821
781
  );
822
782
 
783
+ // Determine if we're in cross-tenant mode
784
+ const isCrossTenant =
785
+ options.sourceApi &&
786
+ options.targetApi &&
787
+ options.sourceApi !== options.targetApi;
788
+
789
+ // Determine if we're in single API mode with specific target
790
+ const isSingleApiMode = !isCrossTenant && options.apiTarget;
791
+
792
+ let sourceService, targetService;
793
+
794
+ if (isCrossTenant) {
795
+ console.log('🔀 Cross-tenant upload mode enabled');
796
+ console.log(` 📖 Source API: ${options.sourceApi}`);
797
+ console.log(` 📝 Target API: ${options.targetApi}`);
798
+
799
+ // Get separate services for source and target
800
+ sourceService = await uploadServiceFactory.getApiServiceForTarget(
801
+ options.sourceApi,
802
+ );
803
+ targetService = await uploadServiceFactory.getApiServiceForTarget(
804
+ options.targetApi,
805
+ );
806
+
807
+ // Verify both services are available
808
+ if (!(await sourceService.isAvailable())) {
809
+ throw new Error(`Source API '${options.sourceApi}' is not available`);
810
+ }
811
+ if (!(await targetService.isAvailable())) {
812
+ throw new Error(`Target API '${options.targetApi}' is not available`);
813
+ }
814
+
815
+ console.log(`✅ Connected to source: ${sourceService.baseUrl}`);
816
+ console.log(`✅ Connected to target: ${targetService.baseUrl}`);
817
+ } else if (isSingleApiMode) {
818
+ // Single API mode with specific target - use the same service for both
819
+ console.log(`🎯 Single API mode: ${options.apiTarget}`);
820
+
821
+ const apiService = await uploadServiceFactory.getApiServiceForTarget(
822
+ options.apiTarget,
823
+ );
824
+
825
+ if (!(await apiService.isAvailable())) {
826
+ throw new Error(`API '${options.apiTarget}' is not available`);
827
+ }
828
+
829
+ console.log(`✅ Connected to: ${apiService.baseUrl}`);
830
+ sourceService = apiService;
831
+ targetService = apiService;
832
+ } else {
833
+ // Default mode - use the default service for both
834
+ const apiService = await uploadServiceFactory.getUploadService();
835
+
836
+ if (apiService.getServiceName() !== 'Arela API') {
837
+ throw new Error(
838
+ 'API service is required for RFC-based upload. Please configure ARELA_API_URL and ARELA_API_TOKEN.',
839
+ );
840
+ }
841
+
842
+ sourceService = apiService;
843
+ targetService = apiService;
844
+ }
845
+
823
846
  if (!appConfig.upload.rfcs || appConfig.upload.rfcs.length === 0) {
824
847
  const errorMsg =
825
848
  'No RFCs specified. Please set UPLOAD_RFCS environment variable with pipe-separated RFC values.';
@@ -828,16 +851,18 @@ export class DatabaseService {
828
851
  }
829
852
 
830
853
  logger.info('Phase 4: Starting RFC-based upload process...');
854
+ logger.info(
855
+ `Using ${isCrossTenant ? 'cross-tenant' : 'standard'} API service for RFC-based upload...`,
856
+ );
831
857
  console.log('🎯 RFC-based Upload Mode');
832
858
  console.log(`📋 Target RFCs: ${appConfig.upload.rfcs.join(', ')}`);
833
859
  console.log('🔍 Searching for files to upload...');
834
860
 
835
861
  // First, count total files for the RFCs to show filtering effect
836
- const { count: totalRfcFiles, error: countError } = await supabase
837
- .from('uploader')
838
- .select('*', { count: 'exact', head: true })
839
- .in('rfc', appConfig.upload.rfcs)
840
- .not('arela_path', 'is', null);
862
+ const { count: totalRfcFiles, error: countError } =
863
+ await sourceService.fetchRfcFileCount({
864
+ rfcs: appConfig.upload.rfcs,
865
+ });
841
866
 
842
867
  if (countError) {
843
868
  logger.warn(`Could not count total RFC files: ${countError.message}`);
@@ -863,22 +888,13 @@ export class DatabaseService {
863
888
  `Fetching pedimento records page ${pageNumber} (records ${offset + 1} to ${offset + pageSize})...`,
864
889
  );
865
890
 
866
- let pedimentoQuery = supabase
867
- .from('uploader')
868
- .select('arela_path')
869
- .eq('document_type', 'pedimento_simplificado')
870
- .in('rfc', appConfig.upload.rfcs)
871
- .not('arela_path', 'is', null);
872
-
873
- // Add year filter if UPLOAD_YEARS is configured
874
- if (appConfig.upload.years && appConfig.upload.years.length > 0) {
875
- pedimentoQuery = pedimentoQuery.in('year', appConfig.upload.years);
876
- }
877
-
878
891
  const { data: pedimentoPage, error: pedimentoError } =
879
- await pedimentoQuery
880
- .range(offset, offset + pageSize - 1)
881
- .order('created_at');
892
+ await sourceService.fetchPedimentosByRfc({
893
+ rfcs: appConfig.upload.rfcs,
894
+ years: appConfig.upload.years || [],
895
+ offset,
896
+ limit: pageSize,
897
+ });
882
898
 
883
899
  if (pedimentoError) {
884
900
  const errorMsg = `Error fetching pedimento RFC records page ${pageNumber}: ${pedimentoError.message}`;
@@ -926,11 +942,13 @@ export class DatabaseService {
926
942
  const uniqueArelaPaths = [
927
943
  ...new Set(allPedimentoRecords.map((r) => r.arela_path)),
928
944
  ];
945
+ // pageNumber represents the current page (starts at 1)
946
+ const totalPages = pageNumber;
929
947
  console.log(
930
- `📋 Found ${allPedimentoRecords.length} pedimento records with ${uniqueArelaPaths.length} unique arela_paths for specified RFCs across ${pageNumber - 1} pages`,
948
+ `📋 Found ${allPedimentoRecords.length} pedimento records with ${uniqueArelaPaths.length} unique arela_paths for specified RFCs across ${totalPages} page${totalPages !== 1 ? 's' : ''}`,
931
949
  );
932
950
  logger.info(
933
- `Found ${allPedimentoRecords.length} pedimento records with ${uniqueArelaPaths.length} unique arela_paths across ${pageNumber - 1} pages`,
951
+ `Found ${allPedimentoRecords.length} pedimento records with ${uniqueArelaPaths.length} unique arela_paths across ${totalPages} page${totalPages !== 1 ? 's' : ''}`,
934
952
  );
935
953
 
936
954
  // Step 2: Process files with optimized single query per chunk
@@ -940,6 +958,7 @@ export class DatabaseService {
940
958
  let globalFileCount = 0;
941
959
  const arelaPathChunkSize = 50;
942
960
  const batchSize = parseInt(options.batchSize) || 10;
961
+ const filePageSize = 1000; // Supabase limit per request
943
962
 
944
963
  // Import performance configuration
945
964
  const { performance: perfConfig } = appConfig;
@@ -959,28 +978,59 @@ export class DatabaseService {
959
978
  ` Processing arela_path chunk ${chunkNumber}/${totalChunks} (${arelaPathChunk.length} paths)`,
960
979
  );
961
980
 
962
- // Fetch all files for this chunk in a single query
963
- const { data: batch, error: queryError } = await supabase
964
- .from('uploader')
965
- .select('id, original_path, arela_path, filename, rfc, document_type')
966
- .in('arela_path', arelaPathChunk)
967
- .neq('status', 'file-uploaded')
968
- .order('created_at');
981
+ // Fetch all files for this chunk with pagination to handle >1000 records
982
+ let allChunkFiles = [];
983
+ let fileOffset = 0;
984
+ let hasMoreFiles = true;
985
+ let filePageNum = 1;
986
+
987
+ while (hasMoreFiles) {
988
+ const { data: batch, error: queryError } =
989
+ await sourceService.fetchFilesForUpload({
990
+ arelaPaths: arelaPathChunk,
991
+ offset: fileOffset,
992
+ limit: filePageSize,
993
+ });
969
994
 
970
- if (queryError) {
971
- const errorMsg = `Error fetching files for chunk ${chunkNumber}: ${queryError.message}`;
972
- logger.error(errorMsg);
973
- throw new Error(errorMsg);
974
- }
995
+ if (queryError) {
996
+ const errorMsg = `Error fetching files for chunk ${chunkNumber} page ${filePageNum}: ${queryError.message}`;
997
+ logger.error(errorMsg);
998
+ throw new Error(errorMsg);
999
+ }
975
1000
 
976
- if (!batch || batch.length === 0) {
977
- console.log(
978
- ` ℹ️ Chunk ${chunkNumber}/${totalChunks}: No files to upload`,
1001
+ if (!batch || batch.length === 0) {
1002
+ hasMoreFiles = false;
1003
+ if (filePageNum === 1) {
1004
+ // No files found at all for this chunk
1005
+ console.log(
1006
+ ` ℹ️ Chunk ${chunkNumber}/${totalChunks}: No files to upload`,
1007
+ );
1008
+ }
1009
+ break;
1010
+ }
1011
+
1012
+ logger.debug(
1013
+ `Chunk ${chunkNumber} page ${filePageNum}: fetched ${batch.length} files`,
979
1014
  );
1015
+ allChunkFiles = allChunkFiles.concat(batch);
1016
+
1017
+ // Check if we need more pages
1018
+ if (batch.length < filePageSize) {
1019
+ hasMoreFiles = false;
1020
+ logger.debug(
1021
+ `Chunk ${chunkNumber}: Completed pagination with ${allChunkFiles.length} total files`,
1022
+ );
1023
+ } else {
1024
+ fileOffset += filePageSize;
1025
+ filePageNum++;
1026
+ }
1027
+ }
1028
+
1029
+ if (allChunkFiles.length === 0) {
980
1030
  continue;
981
1031
  }
982
1032
 
983
- const chunkFileCount = batch.length;
1033
+ const chunkFileCount = allChunkFiles.length;
984
1034
  globalFileCount += chunkFileCount;
985
1035
 
986
1036
  console.log(
@@ -989,20 +1039,21 @@ export class DatabaseService {
989
1039
 
990
1040
  // Process this batch of files immediately using concurrent processing
991
1041
  // Split batch into upload batches
992
- for (let j = 0; j < batch.length; j += batchSize) {
993
- const uploadBatch = batch.slice(j, j + batchSize);
1042
+ for (let j = 0; j < allChunkFiles.length; j += batchSize) {
1043
+ const uploadBatch = allChunkFiles.slice(j, j + batchSize);
994
1044
  const batchNum = Math.floor(j / batchSize) + 1;
995
- const totalBatches = Math.ceil(batch.length / batchSize);
1045
+ const totalBatches = Math.ceil(allChunkFiles.length / batchSize);
996
1046
 
997
1047
  console.log(
998
1048
  ` 📦 Processing upload batch ${batchNum}/${totalBatches} within chunk ${chunkNumber} (${uploadBatch.length} files)`,
999
1049
  );
1000
1050
 
1001
1051
  // Process batch using concurrent processing similar to UploadCommand
1052
+ // In cross-tenant mode: targetService for uploading, sourceService for reading
1002
1053
  const batchResults = await this.#processRfcBatch(
1003
1054
  uploadBatch,
1004
- uploadService,
1005
- supabase,
1055
+ targetService, // Used for uploading files
1056
+ sourceService, // Used for reading metadata
1006
1057
  options,
1007
1058
  maxConcurrency,
1008
1059
  );
@@ -1061,7 +1112,14 @@ export class DatabaseService {
1061
1112
  * @returns {Promise<Array>} Array of files ready for upload
1062
1113
  */
1063
1114
  async getFilesReadyForUpload(options = {}) {
1064
- const supabase = await this.#getSupabaseClient();
1115
+ // Get API service
1116
+ const apiService = await uploadServiceFactory.getUploadService();
1117
+
1118
+ if (apiService.getServiceName() !== 'Arela API') {
1119
+ throw new Error(
1120
+ 'API service is required for querying files. Please configure ARELA_API_URL and ARELA_API_TOKEN.',
1121
+ );
1122
+ }
1065
1123
 
1066
1124
  logger.info('Querying files ready for upload...');
1067
1125
  console.log('🔍 Querying files ready for upload...');
@@ -1085,23 +1143,13 @@ export class DatabaseService {
1085
1143
  '🎯 Finding pedimento_simplificado documents for specified RFCs with arela_path...',
1086
1144
  );
1087
1145
 
1088
- let pedimentoReadyQuery = supabase
1089
- .from('uploader')
1090
- .select('arela_path')
1091
- .eq('document_type', 'pedimento_simplificado')
1092
- .in('rfc', uploadRfcs)
1093
- .not('arela_path', 'is', null);
1094
-
1095
- // Add year filter if UPLOAD_YEARS is configured
1096
- if (appConfig.upload.years && appConfig.upload.years.length > 0) {
1097
- pedimentoReadyQuery = pedimentoReadyQuery.in(
1098
- 'year',
1099
- appConfig.upload.years,
1100
- );
1101
- }
1102
-
1103
1146
  const { data: pedimentoRecords, error: pedimentoError } =
1104
- await pedimentoReadyQuery;
1147
+ await apiService.fetchPedimentosByRfc({
1148
+ rfcs: uploadRfcs,
1149
+ years: appConfig.upload.years || [],
1150
+ offset: 0,
1151
+ limit: 10000, // Fetch all pedimentos in one go for this query
1152
+ });
1105
1153
 
1106
1154
  if (pedimentoError) {
1107
1155
  throw new Error(
@@ -1134,22 +1182,19 @@ export class DatabaseService {
1134
1182
  for (let i = 0; i < uniqueArelaPaths.length; i += chunkSize) {
1135
1183
  const pathChunk = uniqueArelaPaths.slice(i, i + chunkSize);
1136
1184
 
1137
- // Query with pagination to get all results (Supabase default limit is 1000)
1185
+ // Query with pagination to get all results
1138
1186
  let chunkFiles = [];
1139
1187
  let from = 0;
1140
1188
  const pageSize = 1000;
1141
1189
  let hasMoreData = true;
1142
1190
 
1143
1191
  while (hasMoreData) {
1144
- const { data: pageData, error: chunkError } = await supabase
1145
- .from('uploader')
1146
- .select(
1147
- 'id, original_path, arela_path, filename, rfc, document_type, status',
1148
- )
1149
- .in('arela_path', pathChunk)
1150
- .neq('status', 'file-uploaded')
1151
- .not('original_path', 'is', null)
1152
- .range(from, from + pageSize - 1);
1192
+ const { data: pageData, error: chunkError } =
1193
+ await apiService.fetchFilesForUpload({
1194
+ arelaPaths: pathChunk,
1195
+ offset: from,
1196
+ limit: pageSize,
1197
+ });
1153
1198
 
1154
1199
  if (chunkError) {
1155
1200
  throw new Error(
@@ -1213,7 +1258,7 @@ export class DatabaseService {
1213
1258
  * Process a batch of files using concurrent processing for RFC uploads
1214
1259
  * @param {Array} files - Files to process in this batch
1215
1260
  * @param {Object} uploadService - Upload service instance
1216
- * @param {Object} supabase - Supabase client
1261
+ * @param {Object} apiService - API service instance for database updates
1217
1262
  * @param {Object} options - Upload options
1218
1263
  * @param {number} maxConcurrency - Maximum concurrent operations
1219
1264
  * @returns {Promise<Object>} Batch processing results
@@ -1221,7 +1266,7 @@ export class DatabaseService {
1221
1266
  async #processRfcBatch(
1222
1267
  files,
1223
1268
  uploadService,
1224
- supabase,
1269
+ apiService,
1225
1270
  options,
1226
1271
  maxConcurrency,
1227
1272
  ) {
@@ -1245,7 +1290,7 @@ export class DatabaseService {
1245
1290
  return await this.#processRfcSingleFile(
1246
1291
  file,
1247
1292
  uploadService,
1248
- supabase,
1293
+ apiService,
1249
1294
  options,
1250
1295
  fs,
1251
1296
  );
@@ -1291,7 +1336,7 @@ export class DatabaseService {
1291
1336
  return await this.#processRfcApiBatch(
1292
1337
  chunk,
1293
1338
  uploadService,
1294
- supabase,
1339
+ apiService,
1295
1340
  options,
1296
1341
  fs,
1297
1342
  );
@@ -1319,20 +1364,20 @@ export class DatabaseService {
1319
1364
  /**
1320
1365
  * Process a single file for RFC upload (Supabase mode)
1321
1366
  */
1322
- async #processRfcSingleFile(file, uploadService, supabase, options, fs) {
1367
+ async #processRfcSingleFile(file, uploadService, apiService, options, fs) {
1323
1368
  try {
1324
1369
  // Check if file exists
1325
1370
  if (!fs.existsSync(file.original_path)) {
1326
1371
  logger.warn(
1327
1372
  `File not found: ${file.filename} at ${file.original_path}`,
1328
1373
  );
1329
- await supabase
1330
- .from('uploader')
1331
- .update({
1374
+ await apiService.updateFileStatus([
1375
+ {
1376
+ id: file.id,
1332
1377
  status: 'file-not-found',
1333
1378
  message: 'File no longer exists at original path',
1334
- })
1335
- .eq('id', file.id);
1379
+ },
1380
+ ]);
1336
1381
  return { success: false, error: 'File not found' };
1337
1382
  }
1338
1383
 
@@ -1359,24 +1404,25 @@ export class DatabaseService {
1359
1404
 
1360
1405
  // Check upload result before updating database status
1361
1406
  if (uploadResult.success) {
1362
- await supabase
1363
- .from('uploader')
1364
- .update({
1407
+ await apiService.updateFileStatus([
1408
+ {
1409
+ id: file.id,
1365
1410
  status: 'file-uploaded',
1366
1411
  message: 'Successfully uploaded to Supabase',
1367
- })
1368
- .eq('id', file.id);
1412
+ processing_status: 'UPLOADED',
1413
+ },
1414
+ ]);
1369
1415
 
1370
1416
  logger.info(`✅ Uploaded: ${file.filename}`);
1371
1417
  return { success: true, filename: file.filename };
1372
1418
  } else {
1373
- await supabase
1374
- .from('uploader')
1375
- .update({
1419
+ await apiService.updateFileStatus([
1420
+ {
1421
+ id: file.id,
1376
1422
  status: 'upload-error',
1377
1423
  message: `Upload failed: ${uploadResult.error}`,
1378
- })
1379
- .eq('id', file.id);
1424
+ },
1425
+ ]);
1380
1426
 
1381
1427
  logger.error(
1382
1428
  `❌ Upload failed: ${file.filename} - ${uploadResult.error}`,
@@ -1392,13 +1438,13 @@ export class DatabaseService {
1392
1438
  `❌ Error processing file ${file.filename}: ${error.message}`,
1393
1439
  );
1394
1440
 
1395
- await supabase
1396
- .from('uploader')
1397
- .update({
1441
+ await apiService.updateFileStatus([
1442
+ {
1443
+ id: file.id,
1398
1444
  status: 'upload-error',
1399
1445
  message: `Processing error: ${error.message}`,
1400
- })
1401
- .eq('id', file.id);
1446
+ },
1447
+ ]);
1402
1448
 
1403
1449
  return { success: false, error: error.message, filename: file.filename };
1404
1450
  }
@@ -1407,7 +1453,7 @@ export class DatabaseService {
1407
1453
  /**
1408
1454
  * Process multiple files in a single API batch call (API service mode)
1409
1455
  */
1410
- async #processRfcApiBatch(files, uploadService, supabase, options, fs) {
1456
+ async #processRfcApiBatch(files, uploadService, apiService, options, fs) {
1411
1457
  let processed = 0;
1412
1458
  let uploaded = 0;
1413
1459
  let errors = 0;
@@ -1439,15 +1485,15 @@ export class DatabaseService {
1439
1485
  }
1440
1486
 
1441
1487
  // Update invalid files in database
1442
- for (const file of invalidFiles) {
1443
- await supabase
1444
- .from('uploader')
1445
- .update({
1488
+ if (invalidFiles.length > 0) {
1489
+ await apiService.updateFileStatus(
1490
+ invalidFiles.map((file) => ({
1491
+ id: file.id,
1446
1492
  status: 'file-not-found',
1447
1493
  message: 'File no longer exists at original path',
1448
- })
1449
- .eq('id', file.id);
1450
- errors++;
1494
+ })),
1495
+ );
1496
+ errors += invalidFiles.length;
1451
1497
  }
1452
1498
 
1453
1499
  // Process valid files in batch if any exist
@@ -1464,9 +1510,15 @@ export class DatabaseService {
1464
1510
  }
1465
1511
 
1466
1512
  // Make single API call with multiple files
1513
+ // Include RFC for multi-database routing (required for cross-tenant uploads)
1467
1514
  const uploadResult = await uploadService.upload(
1468
1515
  validFiles.map((f) => f.fileData),
1469
- { folderStructure: fullFolderStructure },
1516
+ {
1517
+ folderStructure: fullFolderStructure,
1518
+ rfc: sampleFile.rfc, // For cross-tenant: routes to correct client DB
1519
+ autoDetect: true, // Enable detection on target API
1520
+ autoOrganize: true, // Enable organization on target API
1521
+ },
1470
1522
  );
1471
1523
 
1472
1524
  if (uploadResult.success && uploadResult.data) {
@@ -1497,6 +1549,9 @@ export class DatabaseService {
1497
1549
  `🔍 Expected filenames: ${Array.from(fileNameToRecord.keys()).join(', ')}`,
1498
1550
  );
1499
1551
 
1552
+ // Prepare status updates
1553
+ const statusUpdates = [];
1554
+
1500
1555
  // Handle successfully uploaded files
1501
1556
  if (apiResult.uploaded && apiResult.uploaded.length > 0) {
1502
1557
  const successfulFileIds = [];
@@ -1521,6 +1576,13 @@ export class DatabaseService {
1521
1576
  successfulFileIds.push(dbRecord.id);
1522
1577
  matchedFilenames.push(possibleFilename);
1523
1578
  logger.debug(`✅ Matched file: ${possibleFilename}`);
1579
+
1580
+ statusUpdates.push({
1581
+ id: dbRecord.id,
1582
+ status: 'file-uploaded',
1583
+ message: 'Successfully uploaded to Arela API (batch)',
1584
+ processing_status: 'UPLOADED',
1585
+ });
1524
1586
  } else {
1525
1587
  logger.warn(
1526
1588
  `⚠️ Could not match uploaded file with any known filename: ${JSON.stringify(uploadedFile)}`,
@@ -1536,29 +1598,21 @@ export class DatabaseService {
1536
1598
  logger.warn(
1537
1599
  `🔄 Fallback: No individual file matches found, but API indicates ${apiResult.uploaded.length} uploads. Marking all ${validFiles.length} batch files as uploaded.`,
1538
1600
  );
1539
- validFiles.forEach((f) => successfulFileIds.push(f.dbRecord.id));
1540
- }
1541
-
1542
- if (successfulFileIds.length > 0) {
1543
- await supabase
1544
- .from('uploader')
1545
- .update({
1601
+ validFiles.forEach((f) => {
1602
+ statusUpdates.push({
1603
+ id: f.dbRecord.id,
1546
1604
  status: 'file-uploaded',
1547
1605
  message: 'Successfully uploaded to Arela API (batch)',
1548
- })
1549
- .in('id', successfulFileIds);
1550
-
1551
- uploaded += successfulFileIds.length;
1552
- logger.info(
1553
- `✅ Batch upload successful: ${successfulFileIds.length} files uploaded`,
1554
- );
1606
+ processing_status: 'UPLOADED',
1607
+ });
1608
+ });
1555
1609
  }
1610
+
1611
+ uploaded += successfulFileIds.length || validFiles.length;
1556
1612
  }
1557
1613
 
1558
1614
  // Handle failed files
1559
1615
  if (apiResult.errors && apiResult.errors.length > 0) {
1560
- const failedFileIds = [];
1561
-
1562
1616
  apiResult.errors.forEach((errorInfo) => {
1563
1617
  // Try multiple possible property names for filename in errors
1564
1618
  const possibleFilename =
@@ -1571,32 +1625,21 @@ export class DatabaseService {
1571
1625
 
1572
1626
  const dbRecord = fileNameToRecord.get(possibleFilename);
1573
1627
  if (dbRecord) {
1574
- failedFileIds.push(dbRecord.id);
1628
+ statusUpdates.push({
1629
+ id: dbRecord.id,
1630
+ status: 'upload-error',
1631
+ message: `Upload failed: ${errorInfo.error || 'Unknown error'}`,
1632
+ });
1633
+ errors++;
1575
1634
  } else {
1576
1635
  logger.warn(
1577
1636
  `⚠️ Could not match error file: ${JSON.stringify(errorInfo)}`,
1578
1637
  );
1579
1638
  }
1580
1639
  });
1581
-
1582
- if (failedFileIds.length > 0) {
1583
- await supabase
1584
- .from('uploader')
1585
- .update({
1586
- status: 'upload-error',
1587
- message: `Upload failed: ${apiResult.errors[0].error}`,
1588
- })
1589
- .in('id', failedFileIds);
1590
-
1591
- errors += failedFileIds.length;
1592
- logger.error(
1593
- `❌ Batch upload errors: ${failedFileIds.length} files failed`,
1594
- );
1595
- }
1596
1640
  }
1597
1641
 
1598
1642
  // Handle any remaining files that weren't in uploaded or errors arrays
1599
- // Use robust filename extraction for both uploaded and error files
1600
1643
  const extractFilename = (fileObj) => {
1601
1644
  return (
1602
1645
  fileObj.fileName ||
@@ -1616,26 +1659,24 @@ export class DatabaseService {
1616
1659
  const unprocessedFiles = validFiles.filter(
1617
1660
  (f) => !processedFileNames.has(f.fileData.name),
1618
1661
  );
1662
+
1619
1663
  if (unprocessedFiles.length > 0) {
1620
- // Only mark as unprocessed if we haven't already handled all files through fallback logic
1621
- // If we used fallback (all files marked as uploaded), don't mark any as unprocessed
1622
1664
  const alreadyHandledCount = uploaded + errors;
1623
1665
  const shouldMarkUnprocessed =
1624
1666
  alreadyHandledCount < validFiles.length;
1625
1667
 
1626
1668
  if (shouldMarkUnprocessed) {
1627
- const unprocessedIds = unprocessedFiles.map((f) => f.dbRecord.id);
1628
- await supabase
1629
- .from('uploader')
1630
- .update({
1669
+ unprocessedFiles.forEach((f) => {
1670
+ statusUpdates.push({
1671
+ id: f.dbRecord.id,
1631
1672
  status: 'upload-error',
1632
1673
  message: 'File not found in API response',
1633
- })
1634
- .in('id', unprocessedIds);
1674
+ });
1675
+ });
1676
+ errors += unprocessedFiles.length;
1635
1677
 
1636
- errors += unprocessedIds.length;
1637
1678
  logger.warn(
1638
- `⚠️ Unprocessed files: ${unprocessedIds.length} files not found in API response`,
1679
+ `⚠️ Unprocessed files: ${unprocessedFiles.length} files not found in API response`,
1639
1680
  );
1640
1681
  logger.debug(
1641
1682
  `🔍 API response uploaded array: ${JSON.stringify(apiResult.uploaded)}`,
@@ -1649,16 +1690,30 @@ export class DatabaseService {
1649
1690
  );
1650
1691
  }
1651
1692
  }
1693
+
1694
+ // Batch update all status changes
1695
+ if (statusUpdates.length > 0) {
1696
+ const updateResult =
1697
+ await apiService.updateFileStatus(statusUpdates);
1698
+ if (!updateResult.success) {
1699
+ logger.error(
1700
+ `Some status updates failed: ${updateResult.errors?.length || 0} errors`,
1701
+ );
1702
+ } else {
1703
+ logger.info(
1704
+ `✅ Batch upload successful: ${uploaded} files uploaded`,
1705
+ );
1706
+ }
1707
+ }
1652
1708
  } else {
1653
1709
  // Complete batch failure - mark all files as failed
1654
- const fileIds = validFiles.map((f) => f.dbRecord.id);
1655
- await supabase
1656
- .from('uploader')
1657
- .update({
1658
- status: 'upload-error',
1659
- message: uploadResult.error || 'Batch upload failed',
1660
- })
1661
- .in('id', fileIds);
1710
+ const failureUpdates = validFiles.map((f) => ({
1711
+ id: f.dbRecord.id,
1712
+ status: 'upload-error',
1713
+ message: uploadResult.error || 'Batch upload failed',
1714
+ }));
1715
+
1716
+ await apiService.updateFileStatus(failureUpdates);
1662
1717
 
1663
1718
  errors += validFiles.length;
1664
1719
  logger.error(
@@ -1670,20 +1725,445 @@ export class DatabaseService {
1670
1725
  logger.error(`❌ Error processing batch: ${error.message}`);
1671
1726
 
1672
1727
  // Mark all files as failed
1673
- const fileIds = files.map((f) => f.id);
1674
- await supabase
1675
- .from('uploader')
1676
- .update({
1677
- status: 'upload-error',
1678
- message: `Batch processing error: ${error.message}`,
1679
- })
1680
- .in('id', fileIds);
1728
+ const failureUpdates = files.map((f) => ({
1729
+ id: f.id,
1730
+ status: 'upload-error',
1731
+ message: `Batch processing error: ${error.message}`,
1732
+ }));
1733
+
1734
+ await apiService.updateFileStatus(failureUpdates);
1681
1735
 
1682
1736
  errors += files.length;
1683
1737
  }
1684
1738
 
1685
1739
  return { processed, uploaded, errors };
1686
1740
  }
1741
+
1742
+ /**
1743
+ * Insert upload session event into watch_uploads table
1744
+ * @param {Object} uploadEvent - Upload event from LoggingService
1745
+ * @param {string} sessionId - Session ID for tracking
1746
+ * @returns {Promise<Object>} Inserted record
1747
+ */
1748
+ async insertUploadEvent(uploadEvent, sessionId) {
1749
+ const supabase = await this.#getSupabaseClient();
1750
+
1751
+ const record = {
1752
+ session_id: sessionId,
1753
+ timestamp: uploadEvent.timestamp || new Date().toISOString(),
1754
+ strategy: uploadEvent.strategy, // 'individual', 'batch', 'full-structure'
1755
+ file_count: uploadEvent.fileCount || 0,
1756
+ success_count: uploadEvent.successCount || 0,
1757
+ failure_count: uploadEvent.failureCount || 0,
1758
+ retry_count: uploadEvent.retryCount || 0,
1759
+ duration_ms: uploadEvent.duration || 0,
1760
+ status: uploadEvent.status || 'completed',
1761
+ metadata: uploadEvent.metadata || null,
1762
+ };
1763
+
1764
+ try {
1765
+ const { data, error } = await this.#queryWithRetry(async () => {
1766
+ return await supabase.from('watch_uploads').insert([record]).select();
1767
+ }, `insert upload event for session ${sessionId}`);
1768
+
1769
+ if (error) {
1770
+ logger.error(`Failed to insert upload event: ${error.message}`);
1771
+ throw error;
1772
+ }
1773
+
1774
+ return data[0];
1775
+ } catch (error) {
1776
+ logger.error(`Error inserting upload event: ${error.message}`);
1777
+ throw error;
1778
+ }
1779
+ }
1780
+
1781
+ /**
1782
+ * Insert retry event into watch_events table
1783
+ * @param {string} uploadEventId - ID of the parent upload event
1784
+ * @param {string} sessionId - Session ID for tracking
1785
+ * @param {Object} retryEvent - Retry event from LoggingService
1786
+ * @returns {Promise<Object>} Inserted record
1787
+ */
1788
+ async insertRetryEvent(uploadEventId, sessionId, retryEvent) {
1789
+ const supabase = await this.#getSupabaseClient();
1790
+
1791
+ const record = {
1792
+ upload_event_id: uploadEventId,
1793
+ session_id: sessionId,
1794
+ timestamp: retryEvent.timestamp || new Date().toISOString(),
1795
+ attempt_number: retryEvent.attemptNumber || 0,
1796
+ error_message: retryEvent.error || null,
1797
+ backoff_ms: retryEvent.backoffMs || 0,
1798
+ type: 'retry',
1799
+ };
1800
+
1801
+ try {
1802
+ const { data, error } = await this.#queryWithRetry(async () => {
1803
+ return await supabase.from('watch_events').insert([record]).select();
1804
+ }, `insert retry event for upload ${uploadEventId}`);
1805
+
1806
+ if (error) {
1807
+ logger.error(`Failed to insert retry event: ${error.message}`);
1808
+ throw error;
1809
+ }
1810
+
1811
+ return data[0];
1812
+ } catch (error) {
1813
+ logger.error(`Error inserting retry event: ${error.message}`);
1814
+ throw error;
1815
+ }
1816
+ }
1817
+
1818
+ /**
1819
+ * Get upload history for a session
1820
+ * @param {string} sessionId - Session ID to query
1821
+ * @param {Object} options - Query options (limit, offset, strategy filter)
1822
+ * @returns {Promise<Array>} Array of upload events
1823
+ */
1824
+ async getSessionUploadHistory(sessionId, options = {}) {
1825
+ const supabase = await this.#getSupabaseClient();
1826
+ const limit = options.limit || 100;
1827
+ const offset = options.offset || 0;
1828
+
1829
+ try {
1830
+ let query = supabase
1831
+ .from('watch_uploads')
1832
+ .select('*')
1833
+ .eq('session_id', sessionId)
1834
+ .order('timestamp', { ascending: false })
1835
+ .range(offset, offset + limit - 1);
1836
+
1837
+ // Filter by strategy if provided
1838
+ if (options.strategy) {
1839
+ query = query.eq('strategy', options.strategy);
1840
+ }
1841
+
1842
+ const { data, error } = await this.#queryWithRetry(async () => {
1843
+ return await query;
1844
+ }, `fetch upload history for session ${sessionId}`);
1845
+
1846
+ if (error) {
1847
+ logger.error(`Failed to fetch upload history: ${error.message}`);
1848
+ throw error;
1849
+ }
1850
+
1851
+ return data || [];
1852
+ } catch (error) {
1853
+ logger.error(`Error fetching upload history: ${error.message}`);
1854
+ return [];
1855
+ }
1856
+ }
1857
+
1858
+ /**
1859
+ * Get retry history for an upload event
1860
+ * @param {string} uploadEventId - Upload event ID to query
1861
+ * @param {Object} options - Query options (limit, offset)
1862
+ * @returns {Promise<Array>} Array of retry events
1863
+ */
1864
+ async getUploadRetryHistory(uploadEventId, options = {}) {
1865
+ const supabase = await this.#getSupabaseClient();
1866
+ const limit = options.limit || 100;
1867
+ const offset = options.offset || 0;
1868
+
1869
+ try {
1870
+ const { data, error } = await this.#queryWithRetry(async () => {
1871
+ return await supabase
1872
+ .from('watch_events')
1873
+ .select('*')
1874
+ .eq('upload_event_id', uploadEventId)
1875
+ .eq('type', 'retry')
1876
+ .order('timestamp', { ascending: true })
1877
+ .range(offset, offset + limit - 1);
1878
+ }, `fetch retry history for upload ${uploadEventId}`);
1879
+
1880
+ if (error) {
1881
+ logger.error(`Failed to fetch retry history: ${error.message}`);
1882
+ throw error;
1883
+ }
1884
+
1885
+ return data || [];
1886
+ } catch (error) {
1887
+ logger.error(`Error fetching retry history: ${error.message}`);
1888
+ return [];
1889
+ }
1890
+ }
1891
+
1892
+ /**
1893
+ * Get session statistics
1894
+ * @param {string} sessionId - Session ID to analyze
1895
+ * @returns {Promise<Object>} Session statistics
1896
+ */
1897
+ async getSessionStatistics(sessionId) {
1898
+ const supabase = await this.#getSupabaseClient();
1899
+
1900
+ try {
1901
+ // Fetch all upload events for the session
1902
+ const { data: uploads, error: uploadError } = await this.#queryWithRetry(
1903
+ async () => {
1904
+ return await supabase
1905
+ .from('watch_uploads')
1906
+ .select('*')
1907
+ .eq('session_id', sessionId);
1908
+ },
1909
+ `fetch statistics for session ${sessionId}`,
1910
+ );
1911
+
1912
+ if (uploadError) {
1913
+ throw uploadError;
1914
+ }
1915
+
1916
+ // Fetch all retry events for the session
1917
+ const { data: retries, error: retryError } = await this.#queryWithRetry(
1918
+ async () => {
1919
+ return await supabase
1920
+ .from('watch_events')
1921
+ .select('*')
1922
+ .eq('session_id', sessionId)
1923
+ .eq('type', 'retry');
1924
+ },
1925
+ `fetch retry statistics for session ${sessionId}`,
1926
+ );
1927
+
1928
+ if (retryError) {
1929
+ throw retryError;
1930
+ }
1931
+
1932
+ // Calculate statistics
1933
+ const stats = {
1934
+ sessionId,
1935
+ totalUploadEvents: uploads?.length || 0,
1936
+ totalRetryEvents: retries?.length || 0,
1937
+ totalFileCount: 0,
1938
+ totalSuccessCount: 0,
1939
+ totalFailureCount: 0,
1940
+ totalRetryCount: 0,
1941
+ totalDuration: 0,
1942
+ byStrategy: {
1943
+ individual: {
1944
+ uploadCount: 0,
1945
+ totalFiles: 0,
1946
+ totalSuccess: 0,
1947
+ totalFailure: 0,
1948
+ successRate: 0,
1949
+ totalDuration: 0,
1950
+ },
1951
+ batch: {
1952
+ uploadCount: 0,
1953
+ totalFiles: 0,
1954
+ totalSuccess: 0,
1955
+ totalFailure: 0,
1956
+ successRate: 0,
1957
+ totalDuration: 0,
1958
+ },
1959
+ 'full-structure': {
1960
+ uploadCount: 0,
1961
+ totalFiles: 0,
1962
+ totalSuccess: 0,
1963
+ totalFailure: 0,
1964
+ successRate: 0,
1965
+ totalDuration: 0,
1966
+ },
1967
+ },
1968
+ retryStats: {
1969
+ totalRetries: retries?.length || 0,
1970
+ uniqueUploadsWithRetries: new Set(
1971
+ retries?.map((r) => r.upload_event_id) || [],
1972
+ ).size,
1973
+ totalRetryDuration:
1974
+ retries?.reduce((sum, r) => sum + (r.backoff_ms || 0), 0) || 0,
1975
+ },
1976
+ };
1977
+
1978
+ // Process upload events
1979
+ if (uploads && uploads.length > 0) {
1980
+ uploads.forEach((upload) => {
1981
+ stats.totalFileCount += upload.file_count || 0;
1982
+ stats.totalSuccessCount += upload.success_count || 0;
1983
+ stats.totalFailureCount += upload.failure_count || 0;
1984
+ stats.totalRetryCount += upload.retry_count || 0;
1985
+ stats.totalDuration += upload.duration_ms || 0;
1986
+
1987
+ const strategyKey = upload.strategy || 'individual';
1988
+ if (stats.byStrategy[strategyKey]) {
1989
+ stats.byStrategy[strategyKey].uploadCount += 1;
1990
+ stats.byStrategy[strategyKey].totalFiles += upload.file_count || 0;
1991
+ stats.byStrategy[strategyKey].totalSuccess +=
1992
+ upload.success_count || 0;
1993
+ stats.byStrategy[strategyKey].totalFailure +=
1994
+ upload.failure_count || 0;
1995
+ stats.byStrategy[strategyKey].totalDuration +=
1996
+ upload.duration_ms || 0;
1997
+
1998
+ // Calculate success rate
1999
+ const totalFiles =
2000
+ stats.byStrategy[strategyKey].totalSuccess +
2001
+ stats.byStrategy[strategyKey].totalFailure;
2002
+ if (totalFiles > 0) {
2003
+ stats.byStrategy[strategyKey].successRate = (
2004
+ (stats.byStrategy[strategyKey].totalSuccess / totalFiles) *
2005
+ 100
2006
+ ).toFixed(2);
2007
+ }
2008
+ }
2009
+ });
2010
+ }
2011
+
2012
+ return stats;
2013
+ } catch (error) {
2014
+ logger.error(`Error calculating session statistics: ${error.message}`);
2015
+ return null;
2016
+ }
2017
+ }
2018
+
2019
+ /**
2020
+ * Delete old session data (cleanup)
2021
+ * @param {number} daysOld - Delete sessions older than this many days
2022
+ * @returns {Promise<Object>} Deletion results
2023
+ */
2024
+ async cleanupOldSessions(daysOld = 30) {
2025
+ const supabase = await this.#getSupabaseClient();
2026
+
2027
+ try {
2028
+ const cutoffDate = new Date();
2029
+ cutoffDate.setDate(cutoffDate.getDate() - daysOld);
2030
+
2031
+ // Get sessions to delete
2032
+ const { data: sessionsToDelete, error: fetchError } =
2033
+ await this.#queryWithRetry(async () => {
2034
+ return await supabase
2035
+ .from('watch_uploads')
2036
+ .select('session_id')
2037
+ .lt('timestamp', cutoffDate.toISOString())
2038
+ .distinct();
2039
+ }, `fetch sessions older than ${daysOld} days`);
2040
+
2041
+ if (fetchError) {
2042
+ throw fetchError;
2043
+ }
2044
+
2045
+ let deletedUploads = 0;
2046
+ let deletedEvents = 0;
2047
+
2048
+ if (sessionsToDelete && sessionsToDelete.length > 0) {
2049
+ const sessionIds = sessionsToDelete.map((s) => s.session_id);
2050
+
2051
+ // Delete events
2052
+ const { count: eventCount, error: eventError } =
2053
+ await this.#queryWithRetry(async () => {
2054
+ return await supabase
2055
+ .from('watch_events')
2056
+ .delete()
2057
+ .in('session_id', sessionIds);
2058
+ }, `delete events for old sessions`);
2059
+
2060
+ if (!eventError) {
2061
+ deletedEvents = eventCount || 0;
2062
+ }
2063
+
2064
+ // Delete uploads
2065
+ const { count: uploadCount, error: uploadError } =
2066
+ await this.#queryWithRetry(async () => {
2067
+ return await supabase
2068
+ .from('watch_uploads')
2069
+ .delete()
2070
+ .in('session_id', sessionIds);
2071
+ }, `delete old session uploads`);
2072
+
2073
+ if (!uploadError) {
2074
+ deletedUploads = uploadCount || 0;
2075
+ }
2076
+ }
2077
+
2078
+ return {
2079
+ deletedUploads,
2080
+ deletedEvents,
2081
+ sessionsDeleted: sessionsToDelete?.length || 0,
2082
+ };
2083
+ } catch (error) {
2084
+ logger.error(`Error cleaning up old sessions: ${error.message}`);
2085
+ return { deletedUploads: 0, deletedEvents: 0, sessionsDeleted: 0 };
2086
+ }
2087
+ }
2088
+
2089
+ /**
2090
+ * Cleanup database connections and resources
2091
+ * Called during graceful shutdown
2092
+ * @returns {Promise<Object>} Cleanup results
2093
+ */
2094
+ async cleanup() {
2095
+ try {
2096
+ logger.info('DatabaseService: Starting cleanup...');
2097
+
2098
+ // Commit any pending transactions
2099
+ const transactionResult = await this.commitPendingTransactions();
2100
+
2101
+ // Close database connections
2102
+ const closeResult = await this.closeConnections();
2103
+
2104
+ logger.info('DatabaseService: Cleanup complete');
2105
+
2106
+ return {
2107
+ success: true,
2108
+ transactionsCommitted: transactionResult.count,
2109
+ connectionsClosedResult: closeResult,
2110
+ };
2111
+ } catch (error) {
2112
+ logger.error(`DatabaseService: Error during cleanup: ${error.message}`);
2113
+ return {
2114
+ success: false,
2115
+ error: error.message,
2116
+ };
2117
+ }
2118
+ }
2119
+
2120
+ /**
2121
+ * Commit any pending transactions before shutdown
2122
+ * @private
2123
+ * @returns {Promise<Object>} Results
2124
+ */
2125
+ async commitPendingTransactions() {
2126
+ try {
2127
+ logger.debug('DatabaseService: Committing pending transactions...');
2128
+
2129
+ // Note: This is a placeholder for actual transaction handling
2130
+ // In a real implementation, you would track active transactions
2131
+ // and ensure they are properly committed before shutdown
2132
+
2133
+ logger.debug('DatabaseService: Pending transactions committed');
2134
+ return { count: 0, success: true };
2135
+ } catch (error) {
2136
+ logger.error(
2137
+ `DatabaseService: Error committing transactions: ${error.message}`,
2138
+ );
2139
+ return { count: 0, success: false, error: error.message };
2140
+ }
2141
+ }
2142
+
2143
+ /**
2144
+ * Close all database connections
2145
+ * @private
2146
+ * @returns {Promise<Object>} Results
2147
+ */
2148
+ async closeConnections() {
2149
+ try {
2150
+ logger.debug('DatabaseService: Closing database connections...');
2151
+
2152
+ // Close Supabase connection if available
2153
+ if (this.supabase) {
2154
+ // Supabase client will handle connection cleanup automatically
2155
+ logger.debug('DatabaseService: Supabase connection cleanup initiated');
2156
+ }
2157
+
2158
+ logger.info('DatabaseService: All database connections closed');
2159
+ return { success: true };
2160
+ } catch (error) {
2161
+ logger.error(
2162
+ `DatabaseService: Error closing connections: ${error.message}`,
2163
+ );
2164
+ return { success: false, error: error.message };
2165
+ }
2166
+ }
1687
2167
  }
1688
2168
 
1689
2169
  // Export singleton instance