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