import { readFile } from "node:fs/promises"; import path from "node:path"; import { type NormativeConfidence, type NormativeDocumentType as PrismaNormativeDocumentType, type NormativeRiskLevel, Prisma } from "@prisma/client"; import { NextResponse } from "next/server"; import { requireAdminApiUser } from "@/lib/auth/admin"; import { isLikelyPdfUrl, parseLicitationDocumentLinks, pickPrimaryLicitationDocument, pickPrimaryLicitationPdfDocument } from "@/lib/licitations/documents"; import { type NormativeDocumentType } from "@/lib/normative-analysis/analyze"; import { analyzeNormativeTextWithAi } from "@/lib/normative-analysis/ai-analyze"; import { toNormativeHistoryView } from "@/lib/normative-analysis/history"; import { analyzePdf } from "@/lib/pdf/analyzePdf"; import { OcrFailedError, OcrUnavailableError, PdfEncryptedError, PdfNoTextDetectedError, PdfUnreadableError } from "@/lib/pdf/errors"; import { prisma } from "@/lib/prisma"; export const runtime = "nodejs"; const MAX_NORMATIVE_PDF_BYTES = 20 * 1024 * 1024; const SOURCE_FETCH_TIMEOUT_MS = 45_000; const STORAGE_ROOT = path.resolve(process.cwd(), "storage"); function isPdfFile(file: File) { const extension = file.name.toLowerCase().endsWith(".pdf"); const mimeType = file.type === "application/pdf"; return extension || mimeType; } function hasPdfSignature(buffer: Buffer) { return buffer.subarray(0, 5).toString("utf8") === "%PDF-"; } function guessFileNameFromUrl(url: string) { try { const parsed = new URL(url); const segments = parsed.pathname.split("/").filter(Boolean); const lastSegment = segments.at(-1) ?? ""; const decoded = decodeURIComponent(lastSegment).trim(); if (!decoded) { return "documento-principal.pdf"; } return decoded.toLowerCase().endsWith(".pdf") ? decoded : `${decoded}.pdf`; } catch { return "documento-principal.pdf"; } } async function fetchSourcePdf(url: string) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), SOURCE_FETCH_TIMEOUT_MS); try { const response = await fetch(url, { method: "GET", redirect: "follow", signal: controller.signal, }); if (!response.ok) { throw new Error(`SOURCE_FETCH_FAILED_${response.status}`); } const headerLength = response.headers.get("content-length"); const sizeHint = headerLength ? Number.parseInt(headerLength, 10) : 0; if (Number.isFinite(sizeHint) && sizeHint > MAX_NORMATIVE_PDF_BYTES) { throw new Error("SOURCE_PDF_TOO_LARGE"); } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); if (!buffer.byteLength) { throw new Error("SOURCE_PDF_EMPTY"); } if (buffer.byteLength > MAX_NORMATIVE_PDF_BYTES) { throw new Error("SOURCE_PDF_TOO_LARGE"); } return buffer; } finally { clearTimeout(timer); } } type LicitationSourcePdf = { fileBuffer: Buffer; fileName: string; linkedLicitationId: string; issuingEntity: string | null; }; type SourcePdfCandidate = { url: string; fileName: string; }; function buildSourcePdfCandidates(documentsRaw: unknown, rawSourceUrl: string | null) { const sourceDocuments = parseLicitationDocumentLinks(documentsRaw); const primaryPdf = pickPrimaryLicitationPdfDocument(sourceDocuments); const primaryAny = pickPrimaryLicitationDocument(sourceDocuments); const candidates: SourcePdfCandidate[] = []; const seen = new Set(); function pushCandidate(url: string | null | undefined, fileName: string | null | undefined) { const normalizedUrl = typeof url === "string" ? url.trim() : ""; if (!normalizedUrl || seen.has(normalizedUrl)) { return; } seen.add(normalizedUrl); candidates.push({ url: normalizedUrl, fileName: (fileName?.trim() || guessFileNameFromUrl(normalizedUrl)), }); } pushCandidate(primaryPdf?.url, primaryPdf?.name); pushCandidate(primaryAny?.url, primaryAny?.name); for (const document of sourceDocuments) { pushCandidate(document.url, document.name); } if (rawSourceUrl) { const normalizedSourceUrl = rawSourceUrl.trim(); const existingIndex = candidates.findIndex((item) => item.url === normalizedSourceUrl); if (existingIndex >= 0) { if (isLikelyPdfUrl(normalizedSourceUrl) && existingIndex > 0) { const [entry] = candidates.splice(existingIndex, 1); candidates.unshift(entry); } } else if (isLikelyPdfUrl(normalizedSourceUrl)) { candidates.unshift({ url: normalizedSourceUrl, fileName: guessFileNameFromUrl(normalizedSourceUrl), }); } else { pushCandidate(normalizedSourceUrl, guessFileNameFromUrl(normalizedSourceUrl)); } } return candidates; } async function resolveLicitationSourcePdf(sourceLicitationId: string, currentIssuingEntity: string | null): Promise { const sourceLicitation = await prisma.licitation.findUnique({ where: { id: sourceLicitationId }, select: { id: true, supplierAwarded: true, documents: true, rawSourceUrl: true, }, }); if (!sourceLicitation) { throw new Error("SOURCE_LICITATION_NOT_FOUND"); } const candidates = buildSourcePdfCandidates(sourceLicitation.documents, sourceLicitation.rawSourceUrl); if (candidates.length === 0) { throw new Error("SOURCE_LICITATION_NO_PDF"); } let sawSourceTooLarge = false; let sawSourceTimeout = false; for (const candidate of candidates) { try { const fileBuffer = await fetchSourcePdf(candidate.url); if (!hasPdfSignature(fileBuffer)) { continue; } return { fileBuffer, fileName: candidate.fileName, linkedLicitationId: sourceLicitation.id, issuingEntity: currentIssuingEntity || sourceLicitation.supplierAwarded?.trim() || null, }; } catch (error) { if (error instanceof Error) { if (error.message === "SOURCE_PDF_TOO_LARGE") { sawSourceTooLarge = true; } if (error.name === "AbortError") { sawSourceTimeout = true; } } } } if (sawSourceTooLarge) { throw new Error("SOURCE_LICITATION_PDF_TOO_LARGE"); } if (sawSourceTimeout) { throw new Error("SOURCE_LICITATION_PDF_TIMEOUT"); } throw new Error("SOURCE_LICITATION_NO_VALID_PDF"); } type ProposalDocumentSource = { fileName: string; filePath: string; mimeType: string; }; function isPdfProposalDocument(document: ProposalDocumentSource) { return document.mimeType === "application/pdf" || document.fileName.toLowerCase().endsWith(".pdf"); } function pickPrimaryProposalPdfDocument(documents: ProposalDocumentSource[]) { return documents.find((document) => isPdfProposalDocument(document)) ?? null; } async function readStoredProposalPdf(filePath: string) { const absolutePath = path.resolve(process.cwd(), filePath); if (!absolutePath.startsWith(`${STORAGE_ROOT}${path.sep}`)) { throw new Error("PROPOSAL_PDF_INVALID_PATH"); } try { const buffer = await readFile(absolutePath); if (!buffer.byteLength) { throw new Error("PROPOSAL_PDF_EMPTY"); } if (buffer.byteLength > MAX_NORMATIVE_PDF_BYTES) { throw new Error("PROPOSAL_PDF_TOO_LARGE"); } return buffer; } catch (error) { if ( error && typeof error === "object" && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT" ) { throw new Error("PROPOSAL_PDF_NOT_FOUND"); } throw error; } } function parseDocumentType(value: unknown): NormativeDocumentType { if (typeof value !== "string") { return "OTRO"; } const normalized = value.trim().toUpperCase(); if (normalized === "BASES_LICITACION" || normalized === "CONVOCATORIA" || normalized === "ANEXOS" || normalized === "REGLAMENTO" || normalized === "LEY") { return normalized; } return "OTRO"; } function toPrismaDocumentType(value: NormativeDocumentType): PrismaNormativeDocumentType { if (value === "BASES_LICITACION" || value === "CONVOCATORIA" || value === "REGLAMENTO" || value === "LEY") { return value; } return "OTRO"; } function toPrismaRiskLevel(value: "alto" | "medio" | "bajo"): NormativeRiskLevel { if (value === "alto") { return "ALTO"; } if (value === "medio") { return "MEDIO"; } return "BAJO"; } function toPrismaConfidence(value: "low" | "medium" | "high"): NormativeConfidence { if (value === "high") { return "HIGH"; } if (value === "medium") { return "MEDIUM"; } return "LOW"; } function mapAnalysisError(error: unknown) { if (error instanceof PdfEncryptedError) { return { status: 422, error: "El PDF esta protegido/encriptado. Sube una version sin bloqueo para extraer texto.", code: error.code, }; } if (error instanceof PdfUnreadableError) { return { status: 422, error: "No fue posible leer el PDF. Verifica que el archivo no este danado.", code: error.code, }; } if (error instanceof OcrUnavailableError) { return { status: 503, error: "No se detecto texto suficiente y OCRmyPDF no esta disponible.", code: error.code, }; } if (error instanceof PdfNoTextDetectedError) { return { status: 422, error: "No se detecto texto util en el PDF para analizar.", code: error.code, }; } if (error instanceof OcrFailedError) { return { status: 422, error: "No se detecto texto suficiente y el OCR fallo durante el procesamiento.", code: error.code, }; } return { status: 422, error: "No fue posible analizar el PDF.", code: "NORMATIVE_ANALYSIS_FAILED", }; } function isSchemaNotReadyError(error: unknown) { return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022"); } export async function POST(request: Request) { const user = await requireAdminApiUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const formData = await request.formData(); const documentType = parseDocumentType(formData.get("documentType")); const issuingEntityRaw = formData.get("issuingEntity"); let issuingEntity = typeof issuingEntityRaw === "string" ? issuingEntityRaw.trim() || null : null; const sourceLicitationIdRaw = formData.get("sourceLicitationId"); const sourceLicitationId = typeof sourceLicitationIdRaw === "string" ? sourceLicitationIdRaw.trim() || null : null; const sourceProposalIdRaw = formData.get("sourceProposalId"); const sourceProposalId = typeof sourceProposalIdRaw === "string" ? sourceProposalIdRaw.trim() || null : null; const fileInput = formData.get("file"); const uploadedFile = fileInput instanceof File && fileInput.size > 0 ? fileInput : null; const useSourceDocument = String(formData.get("useSourceDocument") ?? "") === "1"; try { let fileName = ""; let fileBuffer: Buffer | null = null; let linkedLicitationId: string | null = null; if (uploadedFile) { if (uploadedFile.size > MAX_NORMATIVE_PDF_BYTES) { return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 }); } if (!isPdfFile(uploadedFile)) { return NextResponse.json({ error: "Solo se permiten archivos PDF." }, { status: 400 }); } fileBuffer = Buffer.from(await uploadedFile.arrayBuffer()); fileName = uploadedFile.name; if (!hasPdfSignature(fileBuffer)) { return NextResponse.json({ error: "El archivo no parece ser un PDF valido." }, { status: 400 }); } } else if (sourceProposalId && useSourceDocument) { const sourceProposal = await prisma.proposal.findFirst({ where: { id: sourceProposalId, userId: user.id, }, select: { id: true, issuingEntity: true, sourceLicitationId: true, documents: { orderBy: [{ createdAt: "desc" }], select: { fileName: true, filePath: true, mimeType: true, }, }, }, }); if (!sourceProposal) { return NextResponse.json({ error: "No se encontro la propuesta vinculada de Modulo 5." }, { status: 404 }); } if (!issuingEntity) { issuingEntity = sourceProposal.issuingEntity?.trim() || null; } linkedLicitationId = sourceProposal.sourceLicitationId ?? null; const proposalPdf = pickPrimaryProposalPdfDocument(sourceProposal.documents); if (proposalPdf) { fileBuffer = await readStoredProposalPdf(proposalPdf.filePath); fileName = proposalPdf.fileName; if (!hasPdfSignature(fileBuffer)) { return NextResponse.json({ error: "El PDF principal de Modulo 5 no tiene una firma valida." }, { status: 422 }); } } else if (sourceProposal.sourceLicitationId) { const licitationSource = await resolveLicitationSourcePdf(sourceProposal.sourceLicitationId, issuingEntity); fileBuffer = licitationSource.fileBuffer; fileName = licitationSource.fileName; linkedLicitationId = licitationSource.linkedLicitationId; issuingEntity = licitationSource.issuingEntity; } else { return NextResponse.json( { error: "La propuesta de Modulo 5 no tiene un PDF disponible para analisis automatico. Sube un PDF manualmente." }, { status: 400 }, ); } } else if (sourceLicitationId && useSourceDocument) { const licitationSource = await resolveLicitationSourcePdf(sourceLicitationId, issuingEntity); fileBuffer = licitationSource.fileBuffer; fileName = licitationSource.fileName; linkedLicitationId = licitationSource.linkedLicitationId; issuingEntity = licitationSource.issuingEntity; } else { return NextResponse.json( { error: "Debes adjuntar un PDF o vincular una propuesta de Modulo 5 / oportunidad de Modulo 3 para usar su documento principal." }, { status: 400 }, ); } if (!fileBuffer) { return NextResponse.json({ error: "No fue posible obtener un documento PDF para analizar." }, { status: 400 }); } const analyzed = await analyzePdf(fileBuffer); const aiAnalysis = await analyzeNormativeTextWithAi({ text: analyzed.text, fileName, documentType, issuingEntity, methodUsed: analyzed.methodUsed, numPages: analyzed.numPages, warnings: analyzed.warnings, }); const result = aiAnalysis.result; const combinedWarnings = [ ...analyzed.warnings, ...aiAnalysis.warnings, `Motor de analisis: ${aiAnalysis.engine}${aiAnalysis.model ? ` (${aiAnalysis.model})` : ""}`, ]; if (!linkedLicitationId && sourceLicitationId) { const linkedLicitation = await prisma.licitation.findUnique({ where: { id: sourceLicitationId }, select: { id: true }, }); linkedLicitationId = linkedLicitation?.id ?? null; } if (!linkedLicitationId && sourceProposalId) { const linkedProposal = await prisma.proposal.findFirst({ where: { id: sourceProposalId, userId: user.id, }, select: { sourceLicitationId: true, }, }); linkedLicitationId = linkedProposal?.sourceLicitationId ?? null; } const created = await prisma.normativeAnalysisHistory.create({ data: { userId: user.id, sourceLicitationId: linkedLicitationId, fileName, documentType: toPrismaDocumentType(documentType), issuingEntity, methodUsed: analyzed.methodUsed === "ocr" ? "OCR" : "DIRECT", numPages: analyzed.numPages, warnings: combinedWarnings, extractedChars: analyzed.text.length, confidence: toPrismaConfidence(result.confidence), viabilityScore: result.participationViability.score, riskLevel: toPrismaRiskLevel(result.risk.level), executiveSummary: result.executiveSummary, result, analyzedAt: new Date(), }, select: { id: true, sourceLicitationId: true, fileName: true, documentType: true, issuingEntity: true, methodUsed: true, numPages: true, warnings: true, extractedChars: true, analyzedAt: true, viabilityScore: true, riskLevel: true, confidence: true, result: true, }, }); return NextResponse.json({ ok: true, item: toNormativeHistoryView(created), }); } catch (error) { if (isSchemaNotReadyError(error)) { return NextResponse.json( { error: "La base de datos aun no tiene las tablas de Analisis Normativo. Ejecuta prisma migrate para continuar." }, { status: 503 }, ); } if (error instanceof Error) { if (error.message === "SOURCE_LICITATION_NOT_FOUND") { return NextResponse.json({ error: "No se encontro la oportunidad vinculada de Modulo 3." }, { status: 404 }); } if (error.message === "SOURCE_LICITATION_NO_PDF") { return NextResponse.json( { error: "La oportunidad de Modulo 3 no tiene un PDF principal disponible para analisis automatico." }, { status: 400 }, ); } if (error.message === "SOURCE_LICITATION_NO_VALID_PDF") { return NextResponse.json( { error: "No se pudo obtener un PDF valido desde los enlaces de la oportunidad en Modulo 3." }, { status: 422 }, ); } if (error.message === "SOURCE_LICITATION_PDF_TOO_LARGE") { return NextResponse.json({ error: "Los documentos vinculados de Modulo 3 exceden el limite de 20MB." }, { status: 400 }); } if (error.message === "SOURCE_LICITATION_PDF_TIMEOUT") { return NextResponse.json({ error: "Se excedio el tiempo al descargar documentos vinculados de Modulo 3." }, { status: 504 }); } if (error.message === "SOURCE_LICITATION_INVALID_PDF") { return NextResponse.json( { error: "El documento principal de Modulo 3 no tiene una firma PDF valida." }, { status: 422 }, ); } if (error.message === "PROPOSAL_PDF_INVALID_PATH") { return NextResponse.json({ error: "El archivo de Modulo 5 no esta disponible para lectura segura." }, { status: 422 }); } if (error.message === "PROPOSAL_PDF_NOT_FOUND") { return NextResponse.json({ error: "No se encontro el PDF principal cargado en Modulo 5." }, { status: 422 }); } if (error.message === "PROPOSAL_PDF_EMPTY") { return NextResponse.json({ error: "El PDF principal de Modulo 5 esta vacio." }, { status: 422 }); } if (error.message === "PROPOSAL_PDF_TOO_LARGE") { return NextResponse.json({ error: "El PDF principal de Modulo 5 excede el limite de 20MB." }, { status: 400 }); } if (error.name === "AbortError") { return NextResponse.json({ error: "Se excedio el tiempo al descargar el PDF principal de Modulo 3." }, { status: 504 }); } if (error.message === "SOURCE_PDF_TOO_LARGE") { return NextResponse.json({ error: "El PDF principal de Modulo 3 excede el limite de 20MB." }, { status: 400 }); } if (error.message === "SOURCE_PDF_EMPTY") { return NextResponse.json({ error: "El PDF principal de Modulo 3 esta vacio." }, { status: 422 }); } if (error.message.startsWith("SOURCE_FETCH_FAILED_")) { return NextResponse.json({ error: "No fue posible descargar el PDF principal desde la fuente de Modulo 3." }, { status: 422 }); } } const mapped = mapAnalysisError(error); return NextResponse.json({ error: mapped.error, code: mapped.code }, { status: mapped.status }); } }