import { Request, Response } from 'express'; import fs from 'fs'; import path from 'path'; import { randomUUID } from 'crypto'; import { CpcDocument } from '@models/CpcDocument'; import { CpcAuditLog } from '@models/CpcAuditLog'; import { cpcOcrService } from '@services/cpc-cdc/CpcOcrService'; import { CpcValidationService } from '@services/cpc-cdc/CpcValidationService'; import { CpcHistoryService } from '@services/cpc-cdc/CpcHistoryService'; import { CpcRuleExtractService } from '@services/cpc-cdc/CpcRuleExtractService'; import { cpcGcsService } from '@services/cpc-cdc/CpcGcsService'; import { extractPdfTextFromBuffer } from '@services/cpc-cdc/extractPdfText'; import { appendCpcDocumentFilters, canonicalizeMoneyFieldKeysInRecord, canonicalizeRuleFieldKey, cpcWhereFromAndParts, isMoneyFieldKey, sanitizeMoneyValuesInRecord, sanitizePersonNameFieldsInRecord } from '@services/cpc-cdc/utils'; import { gcsStorageService } from '@services/gcsStorage.service'; import logger from '@utils/logger'; import { Op } from 'sequelize'; import { sequelize } from '@config/database'; import { UPLOAD_DIR } from '@config/storage'; /** Vertex / ADC not configured locally — do not fail the whole upload in non-production. */ function isLikelyVertexAuthOrCredentialFailure(err: unknown): boolean { const e = err as { message?: string; name?: string; code?: string }; const blob = `${e?.name || ''} ${e?.message || ''} ${e?.code || ''}`.toLowerCase(); return ( blob.includes('googleauth') || blob.includes('unable to authenticate') || blob.includes('could not load the default credentials') || blob.includes('application default credentials') || blob.includes('invalid_grant') || (blob.includes('enotfound') && blob.includes('metadata.google.internal')) ); } const CPC_LOCAL_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'cpc-csd-files'); const CPC_LOCAL_URL_PREFIX = 'cpc-local:'; /** Max validation attempts per claim when `CPC_ENFORCE_MAX_ATTEMPTS` is not `false` (MSD may own attempts — then set env false). */ const CPC_MAX_ATTEMPTS = Math.max(1, parseInt(process.env.CPC_MAX_ATTEMPTS || '2', 10) || 2); function isCpcAttemptLimitEnforced(): boolean { return String(process.env.CPC_ENFORCE_MAX_ATTEMPTS ?? 'true').toLowerCase() !== 'false'; } function minCpcCdcAttachmentsForClaim(bookingIdRaw: unknown, bookingTypeRaw: unknown): number { const bt = String(bookingTypeRaw || '').toUpperCase(); if (bt === 'CSD') return 1; if (bt === 'CPC') return 2; const bid = String(bookingIdRaw || '').toUpperCase(); if (bid.startsWith('CSD-') || bid.startsWith('CSD_')) return 1; return 2; } function extForUploadedFile(mimetype: string, originalName: string): string { const fromName = path.extname(originalName || '').toLowerCase(); if (fromName && fromName.length <= 12) return fromName; if (mimetype === 'application/pdf') return '.pdf'; if (mimetype?.includes('jpeg') || mimetype === 'image/jpg') return '.jpg'; if (mimetype?.includes('png')) return '.png'; return '.bin'; } /** Safe single path segment for disk/GCS (booking id, doc type token). */ function sanitizePathSegment(segment: string, maxLen = 120): string { const s = String(segment || '').trim(); if (!s) return 'unknown-booking'; const cleaned = s.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/_+/g, '_'); return cleaned.slice(0, maxLen); } /** Folder key under `cpc-csd-files/{csd|cpc}/` (same relative path in GCS bucket and local `uploads/`). */ function deriveCpcCsdStorageChannel(bookingIdRaw: unknown, bookingTypeRaw: unknown): 'csd' | 'cpc' { const bt = String(bookingTypeRaw || '').toUpperCase(); if (bt === 'CSD') return 'csd'; if (bt === 'CPC') return 'cpc'; const bid = String(bookingIdRaw || '').toUpperCase(); if (bid.startsWith('CSD-') || bid.startsWith('CSD_')) return 'csd'; return 'cpc'; } /** * Same shape as workflow uploads: time + short id + role fields + safe original stem + extension. * Example: `1713350400123-a1b2c3d4e5f6-a1-PO-purchase_order.pdf` */ function buildCpcCsdStoredFileName(params: { documentId: string; attemptNo: number; docType: string; originalName: string; mimetype: string; fileIndex: number; }): string { const ext = extForUploadedFile(params.mimetype, params.originalName); const ts = Date.now(); const shortId = params.documentId.replace(/-/g, '').slice(0, 12); const stem = path.basename(params.originalName || 'file', path.extname(params.originalName || '')); const origStem = sanitizePathSegment(stem, 72).toLowerCase() || 'file'; const typePart = sanitizePathSegment(params.docType, 36).toLowerCase() || 'doc'; return `${ts}-${shortId}-a${params.attemptNo}-i${params.fileIndex + 1}-${typePart}-${origStem}${ext}`; } function contentTypeFromPath(p: string): string { const ext = path.extname(p).toLowerCase(); if (ext === '.pdf') return 'application/pdf'; if (ext === '.png') return 'image/png'; if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; if (ext === '.gif') return 'image/gif'; return 'application/octet-stream'; } function resolveCpcLocalDiskPath(documentGcpUrl: string): string | null { if (!documentGcpUrl.startsWith(CPC_LOCAL_URL_PREFIX)) return null; const rel = documentGcpUrl.slice(CPC_LOCAL_URL_PREFIX.length).replace(/^\/+/, '').replace(/\\/g, '/'); if (!rel || rel.includes('..')) return null; const segments = rel.split('/').filter(Boolean); if (segments.some((s) => s === '..')) return null; const full = path.resolve(CPC_LOCAL_UPLOAD_DIR, ...segments); const base = path.resolve(CPC_LOCAL_UPLOAD_DIR); const baseSep = base.endsWith(path.sep) ? base : `${base}${path.sep}`; if (!full.startsWith(baseSep) && full !== base) return null; return full; } /** Workflow-style `/uploads/...` URLs stored when GCS is unavailable (UAT). */ function resolveUploadsDirFromPublicUrl(storageRef: string): string | null { if (!storageRef.startsWith('/uploads/')) return null; const rel = storageRef.slice('/uploads/'.length).replace(/^\/+/, '').replace(/\\/g, '/'); if (!rel || rel.includes('..')) return null; const segments = rel.split('/').filter(Boolean); if (segments.some((s) => s === '..')) return null; const full = path.resolve(UPLOAD_DIR, ...segments); const base = path.resolve(UPLOAD_DIR); const baseSep = base.endsWith(path.sep) ? base : `${base}${path.sep}`; if (!full.startsWith(baseSep) && full !== base) return null; return full; } export class CpcCdcController { /** * Validate a single document upload (Legacy support or single file) */ async validateDocumentUpload(req: Request, res: Response) { try { const requestId = String(req.headers['x-request-id'] || randomUUID()); const clientId = String(req.headers['x-client-id'] || (req as any).user?.email || 'unknown'); const files = (req.files as Express.Multer.File[]) || (req.file ? [req.file] : []); if (files.length === 0) { return res.status(400).json({ error_code: 'NO_FILE_UPLOADED', error_message: 'No files were provided in the request.', retryable: false }); } const skipMinAttachmentCheck = String((req.body as any)?.skip_min_attachment_check || '').toLowerCase() === 'true'; const isUploadFlow = (req.path || req.originalUrl || '').includes('/ocr/upload'); const { document_type, msd_payload, provider, booking_id, booking_type, claim_id, metadata_queue } = req.body; const minAttachments = minCpcCdcAttachmentsForClaim(booking_id || claim_id, booking_type); if (isUploadFlow && !skipMinAttachmentCheck && files.length < minAttachments) { const msg = minAttachments === 1 ? 'CSD claims require at least 1 attachment (Purchase Order PDF/image).' : 'CPC claims require at least 2 attachments: Authorization Letter and Aadhaar (one file each, or more if you split pages).'; return res.status(400).json({ error_code: 'MIN_ATTACHMENTS_REQUIRED', error_message: msg, retryable: false }); } const targetClaimId = claim_id || booking_id; if (!targetClaimId) { return res.status(400).json({ error_code: 'MISSING_CLAIM_ID', error_message: 'claim_id or booking_id is required to track validation attempts.', retryable: false }); } const storageChannel = deriveCpcCsdStorageChannel(targetClaimId, booking_type); const safeBookingSeg = sanitizePathSegment(targetClaimId); // Support both single payload and metadata queue let queue: any[] = []; if (metadata_queue) { try { queue = typeof metadata_queue === 'string' ? JSON.parse(metadata_queue) : metadata_queue; } catch (e) { return res.status(400).json({ error_code: 'INVALID_QUEUE', error_message: 'Invalid metadata_queue format.', retryable: false }); } } else { let parsedPayload = {}; try { parsedPayload = typeof msd_payload === 'string' ? JSON.parse(msd_payload) : msd_payload; } catch (e) { return res.status(400).json({ error_code: 'INVALID_PAYLOAD', error_message: 'Invalid msd_payload.', retryable: false }); } queue = [{ document_type: document_type || "GENERIC_INVOICE", msd_payload: parsedPayload }]; } queue = queue.map((entry) => { const rawMsd = (entry.msd_payload || {}) as Record; const msd_payload = sanitizeMoneyValuesInRecord(canonicalizeMoneyFieldKeysInRecord(rawMsd)); const rawKeys = (entry as { expected_field_keys?: unknown }).expected_field_keys; const out: Record = { ...entry, msd_payload }; if (Array.isArray(rawKeys)) { out.expected_field_keys = [ ...new Set( (rawKeys as unknown[]) .map((k) => { const s = String(k ?? '').trim(); if (!s) return ''; return isMoneyFieldKey(s) ? canonicalizeRuleFieldKey(s) : s; }) .filter(Boolean) ) ]; } return out; }); const results: any[] = []; const ipAddress = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress; // Production: real Vertex/Gemini only unless CPC_ALLOW_DEGRADED_SAVE_WITHOUT_AI=true. // Non-production: allow degraded saves by default so local CPC works without GCP; set env to "false" to force strict. const nodeEnv = (process.env.NODE_ENV || '').toLowerCase(); const isProdRuntime = nodeEnv === 'production' || nodeEnv === 'prod'; const degradedEnv = String(process.env.CPC_ALLOW_DEGRADED_SAVE_WITHOUT_AI || '').toLowerCase(); const allowDegradedSave = degradedEnv === 'true' || (!isProdRuntime && degradedEnv !== 'false'); const requestedAttemptNo = Number((req.body as any)?.attempt_no); const hasRequestedAttemptNo = Number.isFinite(requestedAttemptNo) && requestedAttemptNo > 0; const attemptRows = await CpcDocument.findAll({ attributes: ['attemptNo'], where: { claimId: targetClaimId }, group: ['attemptNo'], raw: true }) as Array<{ attemptNo?: number }>; const usedAttempts = new Set( attemptRows .map((r) => Number(r?.attemptNo || 0)) .filter((n) => Number.isFinite(n) && n > 0) ); if (isCpcAttemptLimitEnforced()) { if (hasRequestedAttemptNo && requestedAttemptNo > CPC_MAX_ATTEMPTS) { return res.status(422).json({ error_code: 'MAX_ATTEMPTS_REACHED', error_message: `Only ${CPC_MAX_ATTEMPTS} validation attempts are allowed per claim.`, retryable: false }); } if (!hasRequestedAttemptNo && usedAttempts.size >= CPC_MAX_ATTEMPTS) { return res.status(422).json({ error_code: 'MAX_ATTEMPTS_REACHED', error_message: `Only ${CPC_MAX_ATTEMPTS} validation attempts are allowed per claim.`, retryable: false }); } if (hasRequestedAttemptNo && !usedAttempts.has(requestedAttemptNo) && usedAttempts.size >= CPC_MAX_ATTEMPTS) { return res.status(422).json({ error_code: 'MAX_ATTEMPTS_REACHED', error_message: `Only ${CPC_MAX_ATTEMPTS} validation attempts are allowed per claim.`, retryable: false }); } } const currentAttempt = hasRequestedAttemptNo ? requestedAttemptNo : (usedAttempts.size + 1); for (let i = 0; i < files.length; i++) { const file = files[i]; const meta = queue[i] || queue[0]; // Fallback to first meta if queue is shorter than files const currentDocType = (meta.document_type || "GENERIC_INVOICE").toUpperCase(); const expectedPayload = meta.msd_payload || {}; const rawUiKeys = Array.isArray((meta as { expected_field_keys?: unknown }).expected_field_keys) ? (meta as { expected_field_keys: unknown[] }).expected_field_keys : Object.keys(expectedPayload); const expectedFieldKeysForPipeline = [ ...new Set( rawUiKeys.map((k: unknown) => String(k ?? '').trim()).filter(Boolean) ) ]; try { // 1. OCR (Optional) let ocrText = ""; const isDocAiConfigured = process.env.DOC_AI_PROCESSOR_ID && process.env.DOC_AI_PROCESSOR_ID !== "your-processor-id"; if (isDocAiConfigured && provider !== "GEMINI_VERTEX_DIRECT") { try { const ocrResult = await cpcOcrService.runDocAIOcr({ projectId: process.env.GCP_PROJECT_ID!, location: process.env.GCP_LOCATION_DOC_AI || 'us', processorId: process.env.DOC_AI_PROCESSOR_ID!, fileBuffer: file.buffer, mimeType: file.mimetype }); ocrText = ocrResult.text; } catch (e) { logger.warn(`[CpcController] OCR failed for ${file.originalname}`, e); } } if (!ocrText?.trim() && file.buffer?.length && file.mimetype === 'application/pdf') { try { const pdfText = await extractPdfTextFromBuffer(file.buffer); if (pdfText?.trim()) { ocrText = pdfText; logger.info( `[CpcController] PDF text fallback for ${file.originalname} (${pdfText.length} chars)` ); } } catch (e) { logger.warn(`[CpcController] pdf-parse failed for ${file.originalname}`, e); } } // 2. Extraction: RULES = local parser on OCR text; otherwise Vertex Gemini (real API — no fake values). let extracted: Record = {}; let confidence: Record = {}; let extractionSource: 'rules_engine' | 'vertex_gemini' | 'degraded_empty' = 'degraded_empty'; if (provider === "RULES") { const ruleOut = CpcRuleExtractService.extractWithRules(ocrText, { msdPayload: expectedPayload, documentType: currentDocType }); extracted = ruleOut.extracted_fields; confidence = ruleOut.field_confidence; extractionSource = 'rules_engine'; } else { const projectId = (process.env.GCP_PROJECT_ID || '').trim(); const hasVertexProject = Boolean(projectId && !/^your-?project/i.test(projectId)); if (!hasVertexProject) { if (!allowDegradedSave) { results.push({ filename: file.originalname, error_code: 'CPC_VERTEX_NOT_CONFIGURED', error_message: 'Vertex AI extraction requires a valid GCP_PROJECT_ID. Configure Vertex/Gemini, use provider RULES for OCR+rules-only, or set CPC_ALLOW_DEGRADED_SAVE_WITHOUT_AI=true for dev-only saves without AI.' , retryable: false }); continue; } logger.warn( `[CpcController] GCP_PROJECT_ID missing — degraded save allowed (CPC_ALLOW_DEGRADED_SAVE_WITHOUT_AI) for ${file.originalname}` ); extracted = {}; confidence = {}; extractionSource = 'degraded_empty'; } else { try { const geminiOut = await CpcValidationService.extractWithGemini({ projectId, location: process.env.VERTEX_AI_LOCATION || 'asia-south1', documentType: currentDocType, ocrText, fileBuffer: file.buffer, mimeType: file.mimetype, expectedFields: expectedFieldKeysForPipeline.length > 0 ? expectedFieldKeysForPipeline : Object.keys(expectedPayload), msdReferencePayload: expectedPayload }); extracted = geminiOut.extracted_fields || {}; confidence = geminiOut.field_confidence || {}; extractionSource = 'vertex_gemini'; } catch (geminiErr: any) { // Non-production: never block upload on missing local GCP login / ADC (GoogleAuthError). const authOrCred = isLikelyVertexAuthOrCredentialFailure(geminiErr); const canDegrade = allowDegradedSave || (!isProdRuntime && authOrCred); if (!canDegrade) { results.push({ filename: file.originalname, error_code: 'CPC_EXTRACTION_FAILED', error_message: authOrCred && isProdRuntime ? 'Vertex AI could not authenticate (check GOOGLE_APPLICATION_CREDENTIALS or workload identity).' : geminiErr?.message || 'Gemini/Vertex extraction failed' , retryable: true }); continue; } logger.warn( `[CpcController] Vertex/Gemini unavailable or failed — saving with empty extraction (${file.originalname})`, geminiErr?.message || geminiErr ); extracted = {}; confidence = {}; extractionSource = 'degraded_empty'; } } } // 2b. When not using RULES-only: fill gaps from rule engine on OCR/PDF text (Vertex may omit; Docker OCR often empty). if (extractionSource !== 'rules_engine' && ocrText?.trim()) { const ruleFill = CpcRuleExtractService.extractWithRules(ocrText, { msdPayload: expectedPayload, documentType: currentDocType }); const ruleExtracted = ruleFill.extracted_fields as Record; const ruleConfidence = ruleFill.field_confidence as Record; for (const key of Object.keys(ruleExtracted)) { if ( expectedFieldKeysForPipeline.length > 0 && !expectedFieldKeysForPipeline.includes(key) ) { continue; } const rv = ruleExtracted[key]; const ev = extracted[key]; const emptyEv = ev === undefined || ev === null || (typeof ev === 'string' && String(ev).trim() === '') || String(ev).toLowerCase() === 'null'; if (emptyEv && rv != null && String(rv).trim() !== '') { extracted[key] = rv; if (confidence[key] === undefined || confidence[key] === null) { confidence[key] = ruleConfidence[key]; } } } } // 2c. CSD PO: Vertex sometimes returns supplier letterhead as customer_name; OCR often has Sold To / Bill To. if (String(currentDocType).toUpperCase().includes('CSD_PO') && ocrText?.trim()) { const refined = CpcRuleExtractService.refineCsdPoCustomerName(ocrText, extracted.customer_name); const prev = String(extracted.customer_name ?? '').trim(); if (refined && refined !== prev) { extracted.customer_name = refined; extracted.authorized_person_name = refined; const cc = confidence as Record; if (cc.customer_name === undefined || cc.customer_name === null || Number(cc.customer_name) < 0.68) { cc.customer_name = 0.72; } } } Object.assign( extracted, sanitizePersonNameFieldsInRecord({ ...extracted } as Record) ); Object.assign( extracted, canonicalizeMoneyFieldKeysInRecord({ ...extracted } as Record) ); Object.assign( extracted, sanitizeMoneyValuesInRecord({ ...extracted } as Record) ); // 3. Validation const v = CpcValidationService.validateSrs( expectedPayload, extracted, confidence, currentDocType, targetClaimId, currentAttempt, expectedFieldKeysForPipeline.length > 0 ? expectedFieldKeysForPipeline : null ); const uiStatus = v.validation_status || v.status; const documentId = randomUUID(); const storedFileName = buildCpcCsdStoredFileName({ documentId, attemptNo: currentAttempt, docType: currentDocType, originalName: file.originalname, mimetype: file.mimetype, fileIndex: i }); let docUrl: string; try { const uploadResult = await gcsStorageService.uploadCpcCsdFileWithFallback({ buffer: file.buffer, originalName: file.originalname, mimeType: file.mimetype, channel: storageChannel, bookingSegment: safeBookingSeg, fileName: storedFileName }); docUrl = uploadResult.storageUrl; const fp = uploadResult.filePath || ''; const nestedOk = fp.includes('/csd/') || fp.includes('/cpc/') || docUrl.startsWith('https://') || docUrl.startsWith('http://'); if (!nestedOk) { logger.warn( '[CpcController] Unexpected CPC/CSD storage path (expected cpc-csd-files/csd|cpc/.../documents/). Check you are not hitting an old API process.', { filePath: fp, storageUrl: docUrl } ); } } catch (e) { logger.error( `[CpcController] Could not persist CPC/CSD file for ${file.originalname}`, e ); docUrl = `local://temp/${file.originalname}`; } const saved = await CpcDocument.create({ id: documentId, bookingId: files.length > 1 ? `${targetClaimId}-${i + 1}` : (booking_id || targetClaimId), claimId: targetClaimId, attemptNo: currentAttempt, documentType: currentDocType, documentGcpUrl: docUrl, provider: provider || "GEMINI_VERTEX", msdPayload: expectedPayload, extractedFields: extracted, fieldConfidence: confidence, validationStatus: uiStatus, matchPercentage: v.match_percentage, ipAddress: String(ipAddress || ''), mismatchReasons: v.mismatch_reasons, fieldResults: v.field_results }); await CpcAuditLog.create({ documentId: saved.id, action: 'UPLOADED', performedBy: clientId, newState: { status: uiStatus, match: v.match_percentage, attempt: currentAttempt, request_id: requestId, client_id: clientId, timestamp: new Date().toISOString() }, remarks: `Document ${file.originalname} uploaded via unified pipeline (Attempt ${currentAttempt})` }); results.push({ document_id: saved.id, booking_id: saved.bookingId, claim_id: saved.claimId, attempt_no: saved.attemptNo, status: v.status, validation_status: v.validation_status, match_percentage: v.match_percentage, overall_match_percentage: v.match_percentage, threshold: v.threshold, mismatch_summary: v.mismatch_summary, mismatch_reasons: v.mismatch_reasons, extracted_fields: extracted, field_confidence: confidence, field_results: v.field_results, extraction_source: extractionSource }); } catch (fileErr: any) { logger.error(`[CpcController] Failed processing file ${file.originalname}`, fileErr); results.push({ filename: file.originalname, error: fileErr?.message || 'Processing failed', error_code: 'INTERNAL_SERVER_ERROR', error_message: fileErr?.message || 'Processing failed', retryable: true }); } } // Return legacy compatible bulk response if multiple files, or single object if one file if (files.length > 1) { return res.json({ count: results.length, results: results }); } else { const single = results[0]; if (!single || (single as { error?: string }).error) { return res.status(422).json({ error_code: (single as { error_code?: string })?.error_code || 'UPLOAD_FAILED', error_message: (single as { error_message?: string })?.error_message || (single as { error?: string })?.error || 'Upload failed', retryable: (single as { retryable?: boolean })?.retryable ?? true }); } return res.json(single); } } catch (error: any) { logger.error("[CpcController] validateDocumentUpload Error:", error); return res.status(500).json({ error_code: 'SERVER_ERROR', error_message: error.message || 'Internal Server Error', retryable: true }); } } /** * Get recent documents for the dashboard */ async getRecentDocuments(req: Request, res: Response) { try { const { search, status, type, limit, page, sortBy, order } = req.query; const qFirst = (v: unknown): string => { if (v == null) return ''; if (Array.isArray(v)) return v[0] != null ? String(v[0]) : ''; return String(v); }; const takeRaw = parseInt(qFirst(limit) || '50', 10); const take = Number.isFinite(takeRaw) && takeRaw > 0 ? Math.min(200, takeRaw) : 50; const pageRaw = parseInt(qFirst(page) || '1', 10); const pageNum = Number.isFinite(pageRaw) && pageRaw > 0 ? pageRaw : 1; const skip = (pageNum - 1) * take; const andParts: Record[] = []; appendCpcDocumentFilters(andParts, { type: qFirst(type) || (type as string), status: qFirst(status) || (status as string), search: qFirst(search) || (search as string), searchIncludeId: true }); const where = cpcWhereFromAndParts(andParts); const validSortFields = ['id', 'bookingId', 'createdAt', 'documentType', 'validationStatus', 'claimId', 'matchPercentage']; const sortKey = typeof sortBy === 'string' && validSortFields.includes(sortBy) ? sortBy : 'createdAt'; const sortDir = order === 'asc' ? 'ASC' : 'DESC'; const { count, rows } = await CpcDocument.findAndCountAll({ where, limit: take, offset: skip, order: [[sortKey, sortDir]] }); const enriched = rows.map((doc: any, idx: number) => { const docJson = doc.toJSON(); return { ...docJson, summary: CpcHistoryService.getSummaryRow(docJson, skip + idx) }; }); const pages = count === 0 ? 1 : Math.ceil(count / take); return res.json({ items: enriched, meta: { total: count, page: pageNum, limit: take, pages } }); } catch (error: any) { logger.error("[CpcController] getRecentDocuments Error:", error); return res.status(500).json({ error_code: 'INTERNAL_SERVER_ERROR', error_message: 'Failed to fetch documents', retryable: true }); } } /** * Stream original upload bytes (GCS or local fallback). Used by UI preview; gs:// is not loadable in a browser iframe. */ async getDocumentFile(req: Request, res: Response) { try { const { id } = req.params; const document = await CpcDocument.findByPk(id); if (!document) { return res.status(404).json({ error_code: 'DOCUMENT_NOT_FOUND', error_message: 'Document not found', retryable: false }); } const ref = document.documentGcpUrl || ''; if (ref.startsWith('gs://')) { try { const buf = await cpcGcsService.downloadFromGcs(ref); const ct = contentTypeFromPath(ref); res.setHeader('Content-Type', ct); const base = path.basename(ref.split('?')[0] || '') || 'document'; res.setHeader('Content-Disposition', `inline; filename="${base}"`); return res.status(200).send(buf); } catch (err: unknown) { logger.error('[CpcController] getDocumentFile GCS download failed', err); return res.status(502).json({ error_code: 'DOCUMENT_FETCH_FAILED', error_message: 'Could not read file from storage', retryable: true }); } } const uploadsAbs = resolveUploadsDirFromPublicUrl(ref); if (uploadsAbs && fs.existsSync(uploadsAbs)) { res.setHeader('Content-Type', contentTypeFromPath(uploadsAbs)); res.setHeader('Content-Disposition', 'inline'); return res.status(200).sendFile(path.resolve(uploadsAbs)); } const localAbs = resolveCpcLocalDiskPath(ref); if (localAbs && fs.existsSync(localAbs)) { res.setHeader('Content-Type', contentTypeFromPath(localAbs)); res.setHeader('Content-Disposition', 'inline'); return res.status(200).sendFile(path.resolve(localAbs)); } if (ref.startsWith('http://') || ref.startsWith('https://')) { return res.redirect(302, ref); } return res.status(404).json({ error_code: 'NO_PREVIEWABLE_FILE', error_message: 'No previewable file for this document', retryable: false }); } catch (error: unknown) { logger.error('[CpcController] getDocumentFile Error:', error); return res.status(500).json({ error_code: 'INTERNAL_SERVER_ERROR', error_message: 'Failed to stream document', retryable: true }); } } /** * Get single document details including analytics breakdown */ async getDocumentById(req: Request, res: Response) { try { const { id } = req.params; const document = await CpcDocument.findByPk(id, { include: [{ model: CpcAuditLog, as: 'auditLogs' }] }); if (!document) { return res.status(404).json({ error_code: 'DOCUMENT_NOT_FOUND', error_message: 'Document not found', retryable: false }); } const docJson = document.toJSON(); const enriched = { ...docJson, field_results: CpcHistoryService.getDetailedFieldResults(docJson) }; return res.json(enriched); } catch (error: any) { logger.error("[CpcController] getDocumentById Error:", error); return res.status(500).json({ error_code: 'INTERNAL_SERVER_ERROR', error_message: 'Failed to fetch document', retryable: true }); } } /** * Get dashboard analytics */ async getAnalytics(req: Request, res: Response) { try { const statusCounts: any = await CpcDocument.findAll({ attributes: [ 'validationStatus', [sequelize.fn('COUNT', sequelize.col('id')), 'count'] ], group: ['validationStatus'] }); const distribution: any = {}; let totalDocs = 0; statusCounts.forEach((item: any) => { const status = item.get('validationStatus'); const count = parseInt(item.get('count')); distribution[status] = count; totalDocs += count; }); const matchCount = (distribution['MATCH'] || 0) + (distribution['APPROVED'] || 0) + (distribution['SUCCESSFUL'] || 0); const passRate = totalDocs > 0 ? Math.round((matchCount / totalDocs) * 100) : 0; const mismatchDocs = await CpcDocument.findAll({ where: { validationStatus: { [Op.in]: ['MISMATCH', 'REJECTED', 'UNSUCCESSFUL'] } }, attributes: ['mismatchReasons'], limit: 200, order: [['createdAt', 'DESC']] }); const fieldErrors: Record = {}; mismatchDocs.forEach((doc: any) => { const reasons = doc.mismatchReasons; if (Array.isArray(reasons)) { reasons.forEach((reason: { field?: string }) => { const field = reason.field; if (field) { fieldErrors[field] = (fieldErrors[field] || 0) + 1; } }); } }); const topMismatchFields = Object.entries(fieldErrors) .sort(([, a], [, b]) => b - a) .slice(0, 5) .map(([field, count]) => ({ field, count })); const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); const dailyDocs: any = await CpcDocument.findAll({ attributes: [ [sequelize.fn('DATE', sequelize.col('created_at')), 'date'], [sequelize.fn('COUNT', sequelize.col('id')), 'count'] ], where: { createdAt: { [Op.gte]: sevenDaysAgo } }, group: [sequelize.fn('DATE', sequelize.col('created_at'))], order: [[sequelize.fn('DATE', sequelize.col('created_at')), 'ASC']] }); const dailyVolume = dailyDocs.map((d: any) => ({ date: d.get('date'), count: parseInt(d.get('count')) })); return res.json({ totalDocs, passRate, distribution, topMismatchFields, dailyVolume }); } catch (error: any) { logger.error("[CpcController] getAnalytics Error:", error); return res.status(500).json({ error_code: 'INTERNAL_SERVER_ERROR', error_message: 'Failed to fetch analytics', retryable: true }); } } /** * Manual validation override (edit / approve / reject) — disabled; status comes from pipeline only. */ async updateDocumentStatus(_req: Request, res: Response) { return res.status(403).json({ error_code: 'MANUAL_DOCUMENT_ACTIONS_DISABLED', error_message: 'Manual document status updates and corrected-field edits are not available for CPC/CSD documents.', retryable: false }); } /** * Delete document record */ async deleteDocument(req: Request, res: Response) { try { const { id } = req.params; const document = await CpcDocument.findByPk(id); if (!document) { return res.status(404).json({ error_code: 'DOCUMENT_NOT_FOUND', error_message: 'Document not found', retryable: false }); } const ref = document.documentGcpUrl || ''; const uploadsAbs = resolveUploadsDirFromPublicUrl(ref); if (uploadsAbs && fs.existsSync(uploadsAbs)) { try { fs.unlinkSync(uploadsAbs); } catch (e) { logger.warn('[CpcController] Could not delete CPC/CSD file under uploads', e); } } const localAbs = resolveCpcLocalDiskPath(ref); if (localAbs && fs.existsSync(localAbs)) { try { fs.unlinkSync(localAbs); } catch (e) { logger.warn('[CpcController] Could not delete local CPC file', e); } } await document.destroy(); return res.json({ success: true, message: 'Document deleted successfully' }); } catch (error: any) { logger.error("[CpcController] deleteDocument Error:", error); return res.status(500).json({ error_code: 'INTERNAL_SERVER_ERROR', error_message: 'Failed to delete document', retryable: false }); } } /** * Fetch all validation attempts for a claim */ async getClaimHistory(req: Request, res: Response) { try { const { claimId } = req.query; if (!claimId) { return res.status(400).json({ error_code: 'MISSING_CLAIM_ID', error_message: 'claimId is required', retryable: false }); } const documents = await CpcDocument.findAll({ where: { [Op.or]: [{ claimId: claimId as string }, { bookingId: claimId as string }] }, order: [['attemptNo', 'ASC'], ['createdAt', 'DESC']] }); const attemptsMap: any = {}; documents.forEach((doc: any) => { const attNum = doc.attemptNo || 1; if (!attemptsMap[attNum]) attemptsMap[attNum] = []; attemptsMap[attNum].push(doc.toJSON()); }); const sortedAttempts = Object.keys(attemptsMap) .sort((a, b) => parseInt(a) - parseInt(b)) .map((num: string) => { const docsInAttempt = attemptsMap[num]; return { attempt_no: parseInt(num), created_at: docsInAttempt[0].createdAt, documents: docsInAttempt.map((d: any) => ({ ...d, field_results: CpcHistoryService.getDetailedFieldResults(d) })), summary_report_rows: docsInAttempt.map((d: any, idx: number) => CpcHistoryService.getSummaryRow(d, idx)) }; }); return res.json({ claimId, attempts: sortedAttempts }); } catch (error: any) { logger.error("[CpcController] getClaimHistory Error:", error); return res.status(500).json({ error_code: 'INTERNAL_SERVER_ERROR', error_message: 'Failed to fetch history', retryable: true }); } } /** CPC-CSD `POST /api/v1/ocr/validate` — URL-based validation using document_gcp_url + msd_payload. */ async validateDocumentByUrlStub(req: Request, res: Response) { try { const { claim_id, booking_id, document_type, document_gcp_url, msd_payload, provider } = req.body || {}; const targetClaimId = claim_id || booking_id; if (!targetClaimId) { return res.status(400).json({ error_code: 'MISSING_CLAIM_ID', error_message: 'claim_id or booking_id is required', retryable: false }); } if (!document_gcp_url || typeof document_gcp_url !== 'string') { return res.status(400).json({ error_code: 'INVALID_DOCUMENT_URL', error_message: 'document_gcp_url is required and must be a gs:// path', retryable: false }); } let payload = {}; try { payload = typeof msd_payload === 'string' ? JSON.parse(msd_payload) : (msd_payload || {}); } catch { return res.status(400).json({ error_code: 'INVALID_PAYLOAD', error_message: 'msd_payload must be valid JSON', retryable: false }); } const requestedAttemptNo = Number((req.body as any)?.attempt_no); const hasRequestedAttemptNo = Number.isFinite(requestedAttemptNo) && requestedAttemptNo > 0; const attemptRows = await CpcDocument.findAll({ attributes: ['attemptNo'], where: { claimId: targetClaimId }, group: ['attemptNo'], raw: true }) as Array<{ attemptNo?: number }>; const usedAttempts = new Set( attemptRows .map((r) => Number(r?.attemptNo || 0)) .filter((n) => Number.isFinite(n) && n > 0) ); if (isCpcAttemptLimitEnforced()) { if (hasRequestedAttemptNo && requestedAttemptNo > CPC_MAX_ATTEMPTS) { return res.status(422).json({ error_code: 'MAX_ATTEMPTS_REACHED', error_message: `Only ${CPC_MAX_ATTEMPTS} validation attempts are allowed per claim`, retryable: false }); } if (!hasRequestedAttemptNo && usedAttempts.size >= CPC_MAX_ATTEMPTS) { return res.status(422).json({ error_code: 'MAX_ATTEMPTS_REACHED', error_message: `Only ${CPC_MAX_ATTEMPTS} validation attempts are allowed per claim`, retryable: false }); } if (hasRequestedAttemptNo && !usedAttempts.has(requestedAttemptNo) && usedAttempts.size >= CPC_MAX_ATTEMPTS) { return res.status(422).json({ error_code: 'MAX_ATTEMPTS_REACHED', error_message: `Only ${CPC_MAX_ATTEMPTS} validation attempts are allowed per claim`, retryable: false }); } } const currentAttempt = hasRequestedAttemptNo ? requestedAttemptNo : (usedAttempts.size + 1); let fileBuffer: Buffer; try { fileBuffer = await cpcGcsService.downloadFromGcs(String(document_gcp_url)); } catch (error: any) { return res.status(422).json({ error_code: error?.message === 'INVALID_DOCUMENT_URL' ? 'INVALID_DOCUMENT_URL' : 'DOCUMENT_FETCH_FAILED', error_message: error?.message || 'Unable to fetch document from GCS', retryable: true }); } const tempFile: Express.Multer.File = { fieldname: 'file', originalname: path.basename(String(document_gcp_url)), encoding: '7bit', mimetype: contentTypeFromPath(String(document_gcp_url)), size: fileBuffer.length, buffer: fileBuffer, stream: undefined as any, destination: '', filename: '', path: '' }; (req as any).file = tempFile; (req as any).files = [tempFile]; req.body = { ...req.body, booking_id: booking_id || claim_id, claim_id: targetClaimId, attempt_no: currentAttempt, skip_min_attachment_check: 'true', provider: provider || 'GEMINI_VERTEX', document_type: document_type || 'GENERIC_INVOICE', msd_payload: payload }; return this.validateDocumentUpload(req, res); } catch (error: any) { logger.error('[CpcController] validateDocumentByUrl Error:', error); return res.status(500).json({ error_code: 'INTERNAL_SERVER_ERROR', error_message: error?.message || 'Internal server error', retryable: true }); } } /** CPC-CSD `POST /api/upload` — same GCS vs local behaviour as workflow requests; returns `gcsUrl` + `storageUrl`. */ async uploadBareFile(req: Request, res: Response) { try { const file = req.file; if (!file) { return res.status(400).json({ error_code: 'NO_FILE_UPLOADED', error_message: 'No file uploaded', retryable: false }); } const uploadResult = await gcsStorageService.uploadCpcCsdFileWithFallback({ buffer: file.buffer, originalName: file.originalname, mimeType: file.mimetype, channel: 'cpc', bookingSegment: 'bare-upload' }); const m = uploadResult.storageUrl.match(/^https:\/\/storage\.googleapis\.com\/([^/]+)\/(.+)$/); const gcsUrl = m ? `gs://${m[1]}/${m[2]}` : uploadResult.storageUrl; return res.json({ gcsUrl, storageUrl: uploadResult.storageUrl, filePath: uploadResult.filePath }); } catch (error: unknown) { const msg = error instanceof Error ? error.message : 'UPLOAD_FAILED'; return res.status(500).json({ error_code: 'UPLOAD_FAILED', error_message: msg, retryable: true }); } } } export const cpcCdcController = new CpcCdcController();