diff --git a/.claude/worktrees/modest-meninsky-d24f27 b/.claude/worktrees/modest-meninsky-d24f27 new file mode 160000 index 0000000..65aaf92 --- /dev/null +++ b/.claude/worktrees/modest-meninsky-d24f27 @@ -0,0 +1 @@ +Subproject commit 65aaf9275eeb260be2d506d0deffd06cdde8f9b7 diff --git a/.claude/worktrees/thirsty-mclean-9799f6 b/.claude/worktrees/thirsty-mclean-9799f6 new file mode 160000 index 0000000..65aaf92 --- /dev/null +++ b/.claude/worktrees/thirsty-mclean-9799f6 @@ -0,0 +1 @@ +Subproject commit 65aaf9275eeb260be2d506d0deffd06cdde8f9b7 diff --git a/Implementation fix.md b/Implementation fix.md new file mode 100644 index 0000000..4e3eb5b --- /dev/null +++ b/Implementation fix.md @@ -0,0 +1,273 @@ +Implementation Goal +Deliver a single, continuous evidence flow so users do not re-upload PDFs across M4->M5->M8->M9->M10, and always see the right document lineage. + +Target User Flow (after changes) + +User analyzes source docs in M4 from M3 or M5 context. +User opens M5 and gets only relevant extracted requirements. +User creates M8 contract from M5 and contract gets proposal PDF continuity automatically. +User opens M9 from M8 with contractId preserved through diagnosis and case creation. +User opens M10 and sees live dossier with file-level links, freshness state, and clear traceability. +Phase Plan + +Phase 1: Fix Critical Continuity Breaks +Stop unrelated M4 history fallback in M5 extraction when proposal has no linked source. +Persist and use contractId in legal diagnosis finalization. +Replace M9 contract selector from “IDs from existing cases” to “actual contracts list”. +Files: workflow extract API, M5 detail page, legal diagnosis start, legal diagnosis answer, M9 view, M9 page. +Phase 2: M5->M8 Document Reuse (No Reupload) +On M8 autocreate, auto-copy latest proposal PDF into ContractDocument. +Extend M8 extraction endpoint to accept sourceProposalId without mandatory new file upload. +Add “Use proposal PDF from M5” action in M8 upload tab. +Files: M8 page, contract extract API, contracts view, contract storage, proposal storage. +Phase 3: Complete Contract Document UX +Wire existing /api/contracts/upload into M8 UI. +Show contract documents list in M8 cards. +Add delete endpoint for contract documents and surface actions in UI. +Files: contracts upload API, contracts view, contracts API folder. +Phase 4: M10 Live Dossier + File-Level Traceability +Make dossier load strategy explicit (live default, snapshot optional). +Add freshness metadata (generatedAt, isStale, source indicator). +Extend dossier payload from counts to include document-level entries and link targets. +Render proposals/contracts/legal sections with drill-down and links in M10. +Files: audits server, audits types, expediente API, M10 view. +Phase 5: Secure Download Endpoints +Add proposal document download endpoint. +Add contract document download endpoint. +Enforce ownership checks and safe storage path resolution. +Files: proposals API folder, contracts API folder, proposal delete route pattern. +Phase 6: Continuity Safeguards and Observability +Add structured logs for handoffs (sourceProposalId, contractId, history source used). +Add continuity warnings in UI when source context is missing. +Add “why this file” trace badges where extraction picks source docs. +Files: M4 view, M5 workflow view, M8 view, M10 view. +Phase 7: Tests and Release Gates +Add integration tests for full continuity path M4->M5->M8->M9->M10. +Add regression tests for “wrong history picked” and “diagnosis loses contractId”. +Add API tests for download ownership and safe path rules. +Keep existing npm test, npm run lint, npm run build as mandatory gate. +Files: lib tests root, component tests root. +Acceptance Criteria + +No forced reupload from M5 to M8 when proposal PDF exists. +Legal cases created via diagnosis stay linked to selected contract. +M5 never uses unrelated M4 history for extraction. +M10 shows live, fresh dossier state and file-level evidence traceability. +Users can securely download proposal/contract evidence from module context. +End-to-end continuity test passes for M4->M10. +Execution Order (Critical Path) + +Phase 1 +Phase 2 +Phase 4 +Phase 5 +Phase 3 +Phase 6 +Phase 7 +Estimated Delivery + +Phase 1-2: 2-3 days +Phase 3-5: 2-3 days +Phase 6-7: 1-2 days +Total: ~1 week of focused implementation +If you want, I can start immediately with Phase 1 and push the exact code changes in that order. + +Progress Update + +Date: 2026-04-09 +Phase 1 Status: Completed + +Completed in Phase 1 + +- M5 extraction continuity guard: removed unrelated M4 history fallback when proposal has no linked `sourceLicitationId`. +- M5 detail continuity guard: history context now loads only when tied to the same linked `sourceLicitationId`. +- M9 diagnosis continuity: diagnosis start now validates and persists `contractId` in diagnosis context. +- M9 diagnosis finalization continuity: legal case auto-created from diagnosis now reuses persisted `contractId` (with ownership validation). +- M9 contract selector source: selector options now come from actual M8 contracts instead of contract IDs inferred from existing legal cases. + +Validation Run + +- `npm test`: passed (22 files, 61 tests). +- `npm run lint`: passed with existing warnings in `pdfExtractor.js` (no errors). +- `npm run build`: passed (Next.js production build succeeded). + +Next Planned Step + +- Phase 2: M5 -> M8 document reuse (no reupload). + +Progress Update + +Date: 2026-04-09 +Phase 2 Status: Completed + +Completed in Phase 2 + +- M8 autocreate continuity: when opening M8 from M5 (`autocreate=1`), the flow now auto-links the latest proposal PDF into `ContractDocument` (deduplicated). +- M8 extraction endpoint continuity: `/api/contracts/extract` now accepts `sourceProposalId` without mandatory file upload. +- M8 extraction source handling: route can analyze either uploaded PDF or proposal PDF; it preserves `sourceProposalId` on contract create/update. +- M8 proposal PDF reuse UI: added explicit action in upload tab to "Usar PDF de propuesta de M5" with proposal selector and timestamped file labels. +- Shared continuity utilities: + - Safe proposal document read helper in proposal storage. + - Proposal->contract continuity helper for latest PDF lookup and deduplicated linking. + +Validation Run (Phase 2) + +- `npm test`: passed (22 files, 61 tests). +- `npm run build`: passed (Next.js production build succeeded). +- `npm run lint`: passed with existing warnings in `pdfExtractor.js` (no errors). + +Next Planned Step + +- Phase 4: M10 live dossier + file-level traceability (per critical path order). + +Progress Update + +Date: 2026-04-14 +Phase 4 Status: Completed + +Completed in Phase 4 + +- Dossier load strategy is now explicit in M10: + - API and UI support `live` (default) and `snapshot` (optional) loading. + - M10 now shows requested strategy vs actual source used. +- Freshness metadata added and surfaced: + - `generatedAt`, `isStale`, `source`, `snapshotId`, `staleAfterMinutes`. + - Snapshot generation returns synchronized freshness metadata. +- Dossier payload upgraded from aggregate-only counts to file-level traceability: + - M5 proposals now include `documentEntries` with links and trace labels. + - M8 contracts now include `documentEntries` with links, kind, and lineage to source proposal when available. + - M9 legal cases now include `documentEntries` with links and trace labels to legal context/contract continuity. +- M10 UI drill-down completed: + - Separate sections for proposals/contracts/legal with per-record links. + - Per-file expandable trace blocks showing mime, size/date, trace reason, and actionable links. + +Validation Run (Phase 4) + +- `npm test`: passed (23 files, 67 tests). +- `npm run lint`: passed with existing warnings in `pdfExtractor.js` (no errors). +- `npm run build`: passed (Next.js production build succeeded). + +Next Planned Step + +- Phase 5: secure download endpoints for proposal and contract evidence. + +Progress Update + +Date: 2026-04-14 +Phase 5 Status: Completed + +Completed in Phase 5 + +- Added secure proposal evidence download endpoint: + - `GET /api/proposals/[id]/documents/[documentId]`. + - Enforces ownership (`proposalId` + `documentId` + `userId`) before file access. + - Uses safe storage path guard and bounded file read helper. + - Returns attachment headers with safe filename handling. +- Added secure contract evidence download endpoint: + - `GET /api/contracts/[id]/documents/[documentId]`. + - Enforces ownership via contract relation (`contract.userId`). + - Uses new safe contract document read helper with path traversal protection. + - Returns attachment headers with safe filename handling. +- Added safe contract storage read helper: + - `readStoredContractDocumentFile(...)` with canonical path validation and size guard. +- M10 file-level traceability now exposes direct secure download links for proposal and contract evidence from dossier context. + +Validation Run (Phase 5) + +- `npm test`: passed (23 files, 67 tests). +- `npm run lint`: passed with existing warnings in `pdfExtractor.js` (no errors). +- `npm run build`: passed (Next.js production build succeeded). + +Next Planned Step + +- Phase 3: complete contract document UX (wire upload UI, show docs in M8 cards, add delete endpoint/actions). + +Progress Update + +Date: 2026-04-14 +Phase 3 Status: Completed + +Completed in Phase 3 + +- M8 upload UX now wires existing `/api/contracts/upload`: + - Added explicit "Adjuntar documento al contrato (sin IA)" flow in M8 upload tab. + - Supports selecting contract target, document kind, and PDF file. +- Contract document cards in M8 now include document list and actions: + - File name, kind, size, and timestamp shown per document. + - Direct download action per file. + - Delete action per file. +- Added contract document delete endpoint: + - `DELETE /api/contracts/[id]/documents/[documentId]`. + - Enforces ownership via `contract.userId`. + - Removes both storage file and DB row with safe path handling. + +Validation Run (Phase 3) + +- `npm test`: passed (23 files, 67 tests). +- `npm run lint`: passed with existing warnings in `pdfExtractor.js` (no errors). +- `npm run build`: passed (Next.js production build succeeded). + +Next Planned Step + +- Phase 6: continuity safeguards and observability. + +Progress Update + +Date: 2026-04-15 +Phase 6 Status: Completed + +Completed in Phase 6 + +- Added structured continuity handoff logs across critical APIs: + - `m4_to_m5_*` events in `/api/proposals/[id]/workflow/extract`. + - `m5_to_m8_*` events in `/api/contracts/extract`. + - `m8_to_m9_*` events in `/api/legal/diagnosis/start`. + - `m9_*` continuity events in `/api/legal/diagnosis/answer`. +- Added page-level observability for M5->M8 autocreate in `/gestion-contratos`: + - Logs for autocreate request, proposal resolution, contract target resolution, proposal PDF lookup, and link outcomes. +- Added continuity safeguards and operator-facing warnings in UI: + - M4 (`normative-analysis-view`): warning when no linked source context. + - M5 (`proposal-workflow-view`): warning when no M4 context for extraction. + - M8 (`contracts-management-view`): warnings for missing proposal PDFs and contracts without `sourceProposalId`. + - M10 (`preventive-dossier-view`): warnings derived from incomplete dossier component evidence. +- Added explicit traceability badges (`Por que este archivo`) where file/source selection affects continuity in M4, M5, M8, and M10. + +Validation Note (Phase 6) + +- Continuity observability changes were carried into Phase 7 release-gate validation and passed together with final gates. + +Next Planned Step + +- Phase 7: tests and release gates. + +Progress Update + +Date: 2026-04-15 +Phase 7 Status: Completed + +Completed in Phase 7 + +- Added integration continuity test for M4->M5->M8->M9->M10 dossier lineage: + - `src/lib/audits/__tests__/dossier-continuity.integration.test.ts`. + - Verifies proposal->contract->legal continuity links and evidence trace payload in M10. +- Added regression test for "wrong history picked" continuity break: + - `src/app/api/proposals/[id]/workflow/extract/route.test.ts`. + - Verifies extraction is blocked when no linked M4 source and no uploaded PDF. +- Added regression test for "diagnosis loses contractId": + - `src/app/api/legal/diagnosis/answer/route.test.ts`. + - Verifies finalization preserves persisted `contractId` and links the auto-created legal case to that contract. +- Added API security tests for download ownership/path safety: + - `src/app/api/proposals/[id]/documents/[documentId]/route.test.ts`. + - `src/app/api/contracts/[id]/documents/[documentId]/route.test.ts`. + - Verifies 404 on non-owned/missing document and 400 on invalid storage path. +- Fixed JSX parsing safety in M8 continuity messaging (`M5->M8`) to keep lint/build green. + +Validation Run (Phase 7) + +- `npm test`: passed (28 files, 74 tests). +- `npm run lint`: passed with existing warnings in `pdfExtractor.js` (no errors). +- `npm run build`: passed (Next.js production build succeeded). + +Next Planned Step + +- Planned implementation phases (1-7) are complete. Ready for QA/UAT and deployment checklist. diff --git a/README.md b/README.md index 070081d..d950b6a 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,66 @@ OPENAI_ACTA_TIMEOUT_MS=60000 OPENAI_ACTA_MAX_CHARS=45000 ``` +## AI Assist Modules (M1/M2/M3/M7/M10) + +The platform now includes assist-only AI helpers for: + +- `M1` Diagnostic suggestions per question (apply/dismiss). +- `M2` Strategic insights and suggested field values (explicit apply only). +- `M3` AI fit + blended score layered on deterministic recommendations. +- `M7` Compliance playbook recommendations (no auto state/severity updates). +- `M10` Auditor-style findings simulation from dossier + simulation data. + +Deterministic scoring and compliance engines remain the source of truth. + +Environment variables: + +```bash +OPENAI_SMART_MODEL=gpt-4.1 +OPENAI_SMART_FALLBACK_MODEL=gpt-4.1-mini +OPENAI_SMART_TIMEOUT_MS=75000 +OPENAI_SMART_MAX_CHARS=55000 + +# Optional module overrides +OPENAI_M1_MODEL=gpt-4.1 +OPENAI_M2_MODEL=gpt-4.1 +OPENAI_M3_MODEL=gpt-4.1 +OPENAI_M7_MODEL=gpt-4.1 +OPENAI_M10_MODEL=gpt-4.1 +``` + +Traceability: + +- AI suggestions are persisted in `AiSuggestion` with request/response metadata. +- Use `POST /api/ai/suggestions/{id}/decision` with `{ "decision": "accept" | "dismiss" }` to persist user action. + +## Mercado Pago Checkout (Planes M2-M10) + +Se agrego integracion de checkout para vender planes: + +- `Plan 1` -> Modulos `2-4` +- `Plan 2` -> Modulos `5-7` +- `Plan 3` -> Modulos `8-10` + +Variables requeridas: + +```bash +MP_ACCESS_TOKEN=TEST-... # token de prueba o produccion +MP_API_BASE_URL=https://api.mercadopago.com +MP_PLAN_DURATION_DAYS=30 +``` + +Flujo: + +- `GET /api/payments/checkout?plan=plan-1|plan-2|plan-3` crea preferencia y redirige a Mercado Pago. +- `GET /api/payments/checkout/return` procesa callback y regresa a `/dashboard`. +- `POST /api/payments/mercadopago/webhook` sincroniza pagos aprobados. + +DB: + +- Nuevos modelos Prisma: `ModulePlanPurchase`, `ModulePlanSubscription`. +- Ejecuta migraciones antes de usar el checkout en un entorno nuevo. + ## Local CLI Script (PDF -> OCR/text -> AI) Run: @@ -87,9 +147,17 @@ Add these vars to `.env`: ```bash LICITAYA_API_KEY=your-licitaya-api-key LICITAYA_BASE_URL=https:// -LICITAYA_TEST_ENDPOINT=/tender/search?items=10&page=1 +LICITAYA_TEST_ENDPOINT=/tender/SCRZJ LICITAYA_ACCEPT=application/json LICITAYA_TIMEOUT_MS=20000 +LICITAYA_ALLOW_EMPTY_SEARCH=true +LICITAYA_ENABLED=true +LICITAYA_ITEMS_PER_PAGE=50 +LICITAYA_MAX_PAGES_PER_RUN=3 +LICITAYA_SYNC_INTERVAL_HOURS=12 +LICITAYA_DATE_OFFSET_DAYS=1 +LICITAYA_DATE_FALLBACK_WINDOW_DAYS=3 +LICITAYA_FALLBACK_TENDER_IDS=SCRZJ ``` Run the connection test: @@ -132,4 +200,29 @@ Notes: - The script sends your key in header `X-API-KEY`. - It prints status code + response preview. -- A non-2xx response exits with code `1` (useful for CI checks). +- For `/tender/search`, you can allow an empty 404 as connectivity success with `LICITAYA_ALLOW_EMPTY_SEARCH=true` or `--allow-empty-search`. +- A non-2xx response exits with code `1` unless empty-search mode is allowed. + +## Compliance Cron + Persistence (M7) + +This project now persists official-regulations verification state and suggestions in Postgres. + +Apply migration and regenerate Prisma client: + +```bash +npm run prisma:migrate +npm run prisma:generate +``` + +Scheduler endpoints: + +- `GET/POST /api/cron/licitations-sync` (includes periodic regulations verification by default) +- `GET/POST /api/cron/regulations-verify` (regulations-only run) + +Accepted auth for cron routes: + +- Header `x-sync-token: $LICITATIONS_SYNC_TOKEN` +- Header `Authorization: Bearer $CRON_SECRET` (Vercel cron compatible) +- Query param `?token=$LICITATIONS_SYNC_TOKEN` (fallback) + +Vercel schedules are included in [`vercel.json`](./vercel.json). diff --git a/ai_integration.md b/ai_integration.md new file mode 100644 index 0000000..781ba20 --- /dev/null +++ b/ai_integration.md @@ -0,0 +1,106 @@ +# AI Integration Plan for Modules 1, 2, 3, 7, and 10 + +## Summary +- Goal: add AI assistance to M1, M2, M3, M7, and M10 while keeping deterministic scoring/compliance logic as the source of truth. +- Product mode selected: **Assist-only** (AI suggests; user explicitly accepts/applies). +- Model strategy selected: **Higher quality** (primary `gpt-4.1`, fallback `gpt-4.1-mini`). +- Persistence selected: **Structured traceability** (store request/response metadata, model, usage, warnings, and accept/dismiss events). + +## Core Architecture Changes +- Add a shared AI service layer used by all 5 modules: + - `callOpenAiJsonSchema()` and `callOpenAiJsonObjectFallback()`. + - shared timeout/retry handling, prompt versioning, truncation guard, and safe JSON parsing. + - standardized output envelope: `engine`, `model`, `usage`, `warnings`, `confidence`. +- Add a shared persistence model for AI traces: + - New Prisma model `AiSuggestion` (or equivalent name), fields: + - `id`, `userId`, `moduleKey`, `featureKey`, `subjectType`, `subjectId`. + - `inputHash`, `requestJson`, `responseJson`, `confidence`. + - `engine`, `model`, `usageJson`, `warningsJson`, `promptVersion`. + - `status` (`GENERATED | ACCEPTED | DISMISSED | EXPIRED`), `actedAt`, `createdAt`, `updatedAt`. + - Purpose: + - deduplicate repeated requests by `inputHash`. + - preserve auditability and user decisions. +- Add shared decision endpoint: + - `POST /api/ai/suggestions/{id}/decision` with `{ decision: "accept" | "dismiss" }`. +- Keep deterministic engines untouched and authoritative: + - M1 scoring, M2 scoring, M3 deterministic match score, M7 rule alerts, M10 scoring remain unchanged. + +## Public API and Type Additions +- `POST /api/diagnostic/ai/suggestions` (M1): + - Input: `moduleKey`, optional `questionId`. + - Output: list of suggestions with `questionId`, `suggestedAnswerOptionId`, `rationale`, `missingEvidence`, `confidence`, `suggestionId`. +- `POST /api/strategic-diagnostic/ai/insights` (M2): + - Input: current strategic form snapshot + evidence metadata. + - Output: `sectionGaps`, `priorityActions`, `suggestedEvidence`, `suggestedFieldValues`, `confidence`, `suggestionId`. +- `GET /api/licitations/ai/recommendations` (M3): + - Input: optional query (`top`, `profileId`), based on deterministic pre-ranked items. + - Output: deterministic score + AI score + blended score + `aiReasons`, `aiRisks`, `nextStep`, `suggestionId`. +- `POST /api/compliance/m7/ai/playbook` (M7): + - Input: current M7 dataset. + - Output: `predictedIncidents`, `priorityOrder`, `preventiveActions`, `escalationAdvice`, `confidence`, `suggestionId`. +- `POST /api/audits/ai/findings` (M10): + - Input: selected simulation + current institutional dossier. + - Output: `auditorLikelyFindings`, `missingEvidence`, `topRisks`, `remediationPlan`, `confidence`, `suggestionId`. +- Type updates: + - Extend view types to include optional AI sections per module (`aiSuggestion`, `aiInsights`, `aiFit`, `aiPlaybook`, `aiFindings`) with strict nullable typing. + - Add common `AiUsage`, `AiWarning`, `AiSuggestionStatus`, `AiDecision`. + +## Module-by-Module Behavior +- M1: + - AI infers likely answers for unanswered questions based on answered responses and evidence notes. + - UI per question: “Sugerencia IA” card with `Apply` and `Dismiss`. + - Applying uses existing response save route so scoring pipeline is unchanged. +- M2: + - AI analyzes strategic data + evidence counts to propose prioritized actions and missing evidence by section. + - UI in each section + results tab: “Plan sugerido por IA”. + - Suggested field values are shown as explicit apply actions, never auto-written. +- M3: + - Keep deterministic scoring as base. + - AI re-ranks only top deterministic candidates to control latency/cost. + - UI shows three values: deterministic score, AI fit, blended score; reasons become semantic and more specific. +- M7: + - AI creates a prioritized compliance playbook from existing alerts/deadlines/checklist. + - UI adds “Plan IA” tab with incident predictions + actions with owner suggestion and target date. + - No severity/status changes are auto-applied. +- M10: + - AI simulates auditor perspective using latest simulation and dossier. + - UI adds “Dictamen IA” with likely findings, missing evidence, and remediation order. + - Deterministic section scores remain unchanged and visible alongside AI narrative. + +## Test Plan +- Unit tests: + - AI parser/normalizer for each module schema. + - prompt truncation + fallback behavior. + - suggestion persistence lifecycle (`GENERATED -> ACCEPTED/DISMISSED`). +- Route tests: + - auth, validation, malformed JSON handling. + - OpenAI failure paths return structured fallback/empty-assist safely. +- Integration tests: + - M1 apply suggestion updates response but does not break scoring calculations. + - M2/M3/M7/M10 AI outputs render without changing deterministic KPIs/scores unless user explicitly applies where applicable. +- Regression tests: + - existing deterministic test suites for M1/M2/M3/M7/M10 continue passing unchanged. +- Acceptance scenarios: + - each module can produce at least one AI suggestion with trace metadata. + - user can accept/dismiss and decision is persisted. + - pages remain functional when AI is unavailable. + +## Environment and Rollout Defaults +- New env vars: + - `OPENAI_SMART_MODEL=gpt-4.1` + - `OPENAI_SMART_FALLBACK_MODEL=gpt-4.1-mini` + - `OPENAI_SMART_TIMEOUT_MS=75000` + - `OPENAI_SMART_MAX_CHARS=55000` + - optional per-module overrides (`OPENAI_M1_MODEL`, `OPENAI_M2_MODEL`, etc.). +- Rollout: + - Phase 1: backend + trace model + M3 and M10 (highest visible value). + - Phase 2: M1 and M2 assist flows. + - Phase 3: M7 predictive playbook tuning. +- Documentation updates: + - add a new section in README for “AI Assist Modules (M1/M2/M3/M7/M10)” and env variables. + +## Assumptions +- Current auth gating behavior remains unchanged in this scope. +- AI output language is Spanish for end-user text. +- No auto-application beyond explicit user action. +- Existing AI-enabled modules (M4/M5/M6/M8/M9) are not refactored in this effort. diff --git a/docs/ui-parity-checklist.md b/docs/ui-parity-checklist.md new file mode 100644 index 0000000..254a565 --- /dev/null +++ b/docs/ui-parity-checklist.md @@ -0,0 +1,32 @@ +# UI Parity Checklist (Modules 3-5) + +## Baseline +- Font stack: `Manrope` (body), `Playfair Display` (display headings). +- Primary colors: navy action buttons, slate neutrals, green success accents. +- Card radius and border hierarchy aligned to reference module screenshots. + +## Module 3 +- Hero title/subtitle and sync CTA placement. +- Guide panel + KPI cards + "ultimo analisis" row. +- Search/filter bars with geo filters. +- Opportunity cards (badges, compatibility %, progress line, details toggle). +- Bottom handoff CTA to Module 4. + +## Module 4 +- Hero title/subtitle. +- Left column: guide + drag/drop upload + metadata fields. +- Right column: empty-state and selected-analysis state. +- Bottom navigation block to Module 3 / Module 5 / Dashboard. + +## Module 5 +- Hero title/subtitle. +- Search + "Nueva Propuesta" controls. +- Empty state with primary CTA. +- Populated cards with edit fields, status, drag/drop docs, document list. +- Forward nav to M6 placeholder. + +## Topbar +- Authenticated module pages use minimal module-flow header. +- Back link visible in module mode. +- Optional Manual, plan badge, and concise actions only. +- No dense authenticated global nav links. diff --git a/final_implementation.md b/final_implementation.md new file mode 100644 index 0000000..d4e8a50 --- /dev/null +++ b/final_implementation.md @@ -0,0 +1,463 @@ +# Final Implementation Plan - Modulos 8, 9 y 10 + +## 1. Objetivo +Implementar M08, M09 y M10 como una cadena operativa completa: + +- M08 Gestion Estrategica de Contratos: registro de contratos, entregables, pagos y carga inteligente de PDF. +- M09 Proteccion Legal: diagnostico guiado, gestion de casos, ruta de escalada, generador de escritos y directorio. +- M10 Expediente Preventivo: simulador de auditoria y expediente institucional centralizado. + +El alcance sigue exactamente la UX mostrada en las referencias y se integra con M5 y M7 ya existentes. + +## 2. Principios de implementacion +- Reusar patrones actuales: App Router, Prisma, componentes estilo M7, endpoints administrativos autenticados. +- Mantener trazabilidad completa: cada caso legal y cada auditoria debe poder rastrear contrato, entregable, pago y evidencia origen. +- Diseñar para escalamiento nacional: iniciar con seed federal + NL y permitir extender por estado/municipio. +- Regla conservadora legal: donde no haya certeza normativa, marcar "requiere validacion legal". + +## 3. M08 - Gestion Estrategica de Contratos + +### 3.1 Ruta y UI +- Nueva ruta: `/gestion-contratos`. +- Header de flujo: + - back: `M7: Alertas` + - next: `M9: Legal` +- KPIs: + - Contratos activos + - Total cobrado + - Entregables pendientes + - Entregables vencidos +- Tabs: + - Subir Contrato + - Contratos + - Entregables + - Pagos + +### 3.2 Dominio y modelo de datos +Agregar en Prisma: + +- `ContractRecord` + - `id, userId, sourceProposalId?` + - `title, counterpartyEntity, contractNumber, contractType` + - `startDate, endDate, totalAmount, currency` + - `status` (`ACTIVE|COMPLETED|PAUSED|CANCELLED`) + - `description, createdAt, updatedAt` +- `ContractDeliverable` + - `id, contractId, title, dueDate, amountLinked?` + - `status` (`PENDING|DELIVERED|APPROVED|REJECTED|OVERDUE`) + - `deliveredAt?, approvedAt?, notes` +- `ContractPayment` + - `id, contractId, amount, paymentDate, invoiceNumber?, concept` + - `status` (`REGISTERED|CONFIRMED|DISPUTED`) +- `ContractDocument` + - `id, contractId, fileName, filePath, mimeType, sizeBytes, checksumSha256` + - `kind` (`SIGNED_CONTRACT|ADDENDUM|DELIVERABLE_EVIDENCE|PAYMENT_EVIDENCE|OTHER`) +- `ContractExtractionHistory` + - `id, contractId?, userId, engine, model, resultJson, warningsJson, analyzedAt` + +Indices: +- `ContractRecord(userId,status,endDate)` +- `ContractDeliverable(contractId,status,dueDate)` +- `ContractPayment(contractId,paymentDate)` + +### 3.3 APIs +- `GET /api/contracts` list + filtros. +- `POST /api/contracts` crear contrato manual (modal Nuevo Contrato). +- `PATCH /api/contracts/:id` editar estado y datos clave. +- `POST /api/contracts/upload` subir PDF firmado. +- `POST /api/contracts/extract` OCR + IA y crear/actualizar contrato. +- `GET /api/contracts/kpis` dataset de M08. +- `POST /api/contracts/:id/deliverables` crear entregable (modal). +- `PATCH /api/deliverables/:id` cambiar estado. +- `POST /api/contracts/:id/payments` registrar pago (modal). +- `GET /api/contracts/:id/payments` historial. + +### 3.4 IA para "Subir Contrato" +Reusar pipeline de PDF existente: +- `analyzePdf` para texto/OCR. +- Nuevo extractor IA `extractContractWithAi`: + - campos: titulo, entidad contratante, numero, tipo, fechas, monto total. + - lista de entregables detectados. + - hitos de pago detectados. + - clausulas de riesgo contractual (para alimentar M9). +- Guardar trazabilidad en `ContractExtractionHistory`. + +### 3.5 Reglas funcionales +- Contrato activo: `status=ACTIVE` y fecha fin no vencida (o sin fecha fin). +- Entregable vencido: `status=PENDING` y `dueDate < now`. +- Total cobrado: sumatoria pagos confirmados. +- Barra de avance por contrato: `entregables completados / entregables totales`. + +## 4. M09 - Proteccion Legal + +### 4.1 Ruta y UI +- Nueva ruta: `/proteccion-legal`. +- Header de flujo: + - back: `M8: Contratos` + - next: `M10: Auditorias` +- KPIs: + - Casos abiertos + - Alta severidad + - Monto en riesgo + - Casos resueltos +- Tabs: + - Diagnostico + - Casos + - Escalada + - Escritos + - Directorio + +### 4.2 Dominio y modelo de datos +Agregar en Prisma: + +- `LegalCase` + - `id, userId, contractId?` + - `caseType` (`CONTRACT_BREACH|PAYMENT_RETENTION|UNJUST_SANCTION|CONTRACT_DISPUTE`) + - `severity` (`LOW|MEDIUM|HIGH`) + - `counterparty, description` + - `amountAtRisk` + - `status` (`OPEN|IN_PROGRESS|ESCALATED|RESOLVED|CLOSED`) + - `openedAt, resolvedAt?` +- `LegalDiagnosis` + - `id, userId, legalCaseId?` + - `stepIndex, totalSteps, answersJson, recommendedRouteJson` +- `LegalEscalationStepLog` + - `id, legalCaseId, routeStepKey, completedAt?, notes` +- `LegalDocument` + - `id, legalCaseId?, userId, templateKey?, aiGenerated` + - `title, content, createdAt` +- `LegalDirectoryEntity` + - `id, jurisdictionLevel, name, scopeTagsJson` + - `websiteUrl?, phone?, email?, stateCode?, municipalityCode?` + - `isActive` + +### 4.3 APIs +- `GET /api/legal/kpis` +- `POST /api/legal/diagnosis/start` +- `POST /api/legal/diagnosis/answer` +- `POST /api/legal/cases` +- `GET /api/legal/cases` +- `PATCH /api/legal/cases/:id` (resolver/escalar) +- `GET /api/legal/escalation/:caseId` +- `POST /api/legal/documents/generate` (IA) +- `GET /api/legal/templates` +- `GET /api/legal/directory` + +### 4.4 Motor de diagnostico +Wizard de 4 pasos como referencia: +1. Tipo de incumplimiento. +2. Estado procesal y evidencia disponible. +3. Impacto economico y urgencia. +4. Objetivo legal (pago, correccion, impugnacion, mediacion). + +Salida: +- severidad sugerida +- tipo de caso sugerido +- ruta escalada sugerida +- lista de escritos recomendados + +### 4.5 Ruta Escalada (tab Escalada) +Catalogo base de pasos: +1. Requerimiento formal +2. Conciliacion administrativa +3. Queja ante OIC +4. Inconformidad ante SFP/Organo estatal +5. Via jurisdiccional + +Cada paso incluye: +- ventana estimada de tiempo +- checklist de acciones +- bandera de "requiere abogado" +- dependencias del paso previo + +### 4.6 Escritos (IA + plantillas) +- Selector de tipo de caso y severidad. +- Inputs: contraparte, empresa, monto, descripcion. +- Generador IA produce borrador estructurado y editable. +- Plantillas predefinidas por categoria (como en referencia): + - General: Acta entrega-recepcion. + - Incumplimiento: Carta de inconformidad. + - Retencion de pagos: Solicitud de liberacion. + - Sanciones: Recurso de inconformidad. +- Guardar documento y boton copiar/exportar. + +### 4.7 Directorio +Seed inicial federal + estatal: +- SFP, TFJA, ASF, OIC, CompraNet, sistemas anticorrupcion estatales, etc. +- Filtros por jurisdiccion y tipo de autoridad. + +## 5. M10 - Expediente Preventivo + +### 5.1 Ruta y UI +- Nueva ruta: `/expediente-preventivo`. +- Header de flujo: + - back: `M9: Legal` +- KPIs: + - Auditorias completadas + - Ultima calificacion + - Contratos registrados (desde M08) + - PDFs resguardados (M08 + M5) +- Tabs: + - Simulador + - Expediente + +### 5.2 Dominio y modelo de datos +Agregar en Prisma: + +- `AuditSimulation` + - `id, userId, name, auditType` + - `status` (`DRAFT|COMPLETED`) + - `overallScore, completedAt?` +- `AuditSimulationSection` + - `id, simulationId, key` + - `score, status` (`READY|WARNING|CRITICAL`) + - `findingsJson, recommendationsJson` +- `AuditChecklistResponse` + - `id, simulationId, questionKey, answer, evidenceRefsJson` +- `InstitutionalDossierSnapshot` + - `id, userId, generatedAt, payloadJson` + +Secciones del simulador v1: +- Cumplimiento fiscal +- Cumplimiento laboral +- Documentacion legal +- Control operativo +- Transparencia financiera + +### 5.3 APIs +- `POST /api/audits/simulations` +- `GET /api/audits/simulations` +- `POST /api/audits/simulations/:id/score` +- `GET /api/audits/simulations/:id` +- `GET /api/audits/expediente` +- `POST /api/audits/expediente/refresh` + +### 5.4 Logica del expediente +Consolidar automaticamente: +- M5: propuestas, workflow, documentos. +- M8: contratos, entregables, pagos, contratos PDF. +- M9: casos, escritos, evidencia de escalada. +- M7: alertas activas de cumplimiento para semaforo preventivo. + +Resultado: +- vista de historial institucional lista para auditoria. +- estado por componente (completo/incompleto). + +## 6. Integraciones entre modulos +- M5 -> M8: crear contrato desde propuesta adjudicada. +- M8 -> M9: boton "abrir caso legal" desde contrato/entregable/pago. +- M8 -> M10: contratos y PDFs alimentan expediente. +- M9 -> M10: casos, escritos y escalada alimentan expediente. +- M7 <-> M8/M9: alertas por entregables vencidos, pagos disputados, casos alta severidad. + +## 7. Tipos de dominio (frontend/backend) +Crear `src/lib/contracts/types.ts`, `src/lib/legal/types.ts`, `src/lib/audits/types.ts` con: +- snapshots KPI por modulo +- enums de estado +- payloads de formularios (modales) +- DTOs para tabs +- contratos de IA (input/output) para extractor de contrato y generador de escritos + +## 8. Plan de implementacion por fases + +### Fase A (base de datos + navegacion) +- Prisma models, migraciones, seeds. +- Rutas vacias M08/M09/M10 con header y tabs. +- Linkear dashboard (Plan 3) a rutas reales. + +### Fase B (M08 operativo) +- CRUD contratos + entregables + pagos. +- KPIs y tabs funcionales. +- Upload PDF + extraccion IA + guardado en expediente. + +### Fase C (M09 operativo) +- Diagnostico wizard y creacion de casos. +- Registro/seguimiento de casos. +- Escalada guiada con checklist por paso. +- Generador IA de escritos + plantillas + copiar/exportar. +- Directorio con seed inicial. + +### Fase D (M10 operativo) +- Simulador de auditoria con scoring por seccion. +- Expediente centralizado con agregacion M5/M8/M9. +- KPIs finales y acciones correctivas sugeridas. + +### Fase E (hardening) +- permisos por plan, auditoria de cambios, logs de errores IA. +- observabilidad de pipelines OCR/IA. +- refinamiento de UX responsive y accesibilidad. + +## 9. Plan de pruebas + +### Unit +- Formulas KPI M08/M09/M10. +- Reglas de estado (vencido, resuelto, escalado, activo). +- Scoring del simulador. +- Seleccion de ruta legal por diagnostico. + +### Integration +- Flujo completo: contrato (M08) -> caso (M09) -> expediente (M10). +- Extraccion IA de contrato -> creacion de entregables/pagos. +- Generacion de escrito IA + guardado en caso. + +### UI/E2E +- Paridad visual de tabs y KPI cards con referencias. +- Modales: Nuevo Contrato, Nuevo Entregable, Registrar Pago. +- Estados vacios y poblados. +- Responsive movil/escritorio. + +## 10. Criterios de aceptacion +- M08 permite crear contrato, registrar entregable y pago, y ver KPIs actualizados en tiempo real. +- M09 permite abrir caso desde diagnostico, escalarlo por pasos, y generar escritos IA reutilizables. +- M10 muestra simulaciones con score y expediente consolidado de evidencia documental. +- Navegacion lineal operativa M7 -> M8 -> M9 -> M10. +- Build, test y lint sin errores bloqueantes. + +## 11. Defaults V1 +- Sin firma digital de escritos dentro del sistema (solo generacion y exportacion). +- Sin integracion externa de tribunales/SFP para envio automatico (solo preparacion guiada). +- Directorio legal inicial federal + NL, extensible por seed. +- Clasificacion de riesgo legal conservadora si faltan evidencias. + +## 12. Master Checklist de Implementacion + +### 12.1 Preparacion +- [ ] Confirmar alcance funcional final con producto (M08, M09, M10 + integraciones). +- [ ] Validar naming final de rutas: `/gestion-contratos`, `/proteccion-legal`, `/expediente-preventivo`. +- [ ] Confirmar permisos por plan (Plan 3) y reglas de acceso admin/pago. + +### 12.2 Fase A - Data + Navegacion Base +- [ ] Crear modelos Prisma M08/M09/M10. +- [ ] Crear enums Prisma para estados de contratos, casos y auditorias. +- [ ] Crear migraciones SQL + seeds iniciales (directorio legal, catalogos base). +- [ ] Ejecutar `npm run prisma:generate`. +- [ ] Crear paginas base de rutas M08, M09 y M10 con layout y header de flujo. +- [ ] Conectar dashboard (tarjetas M08/M09/M10) a rutas reales. + +### 12.3 Fase B - M08 Operativo +- [ ] Implementar dominio `contracts` (`types.ts`, validadores, mappers). +- [ ] Implementar endpoints CRUD de contratos. +- [ ] Implementar endpoints de entregables y pagos. +- [ ] Implementar endpoint KPI de M08. +- [ ] Implementar tab Subir Contrato (upload PDF + persistencia de archivo). +- [ ] Implementar extraccion IA de contrato y guardado de `ContractExtractionHistory`. +- [ ] Implementar UI tabs M08 (Subir Contrato, Contratos, Entregables, Pagos). +- [ ] Implementar modales: Nuevo Contrato, Nuevo Entregable, Registrar Pago. +- [ ] Implementar reglas de negocio: activo, vencido, total cobrado, progreso. + +### 12.4 Fase C - M09 Operativo +- [ ] Implementar dominio `legal` (`types.ts`, enums, validaciones). +- [ ] Implementar wizard Diagnostico (4 pasos) y recomendacion de ruta. +- [ ] Implementar CRUD de casos legales. +- [ ] Implementar tab Casos con acciones de resolver/escalar. +- [ ] Implementar tab Escalada (pasos, checklist, estatus por caso). +- [ ] Implementar generador de escritos IA y persistencia. +- [ ] Implementar repositorio de plantillas predefinidas y accion copiar/exportar. +- [ ] Implementar tab Directorio con filtros de jurisdiccion/autoridad. + +### 12.5 Fase D - M10 Operativo +- [ ] Implementar dominio `audits` (`types.ts`, scoring, snapshots). +- [ ] Implementar CRUD de simulaciones de auditoria. +- [ ] Implementar motor de scoring por seccion. +- [ ] Implementar tab Simulador con historial y detalle de resultados. +- [ ] Implementar agregador de expediente institucional (M5 + M8 + M9 + M7). +- [ ] Implementar tab Expediente con estados vacios/poblados y trazabilidad. +- [ ] Implementar KPIs de M10. + +### 12.6 Fase E - Hardening +- [ ] Reglas de autorizacion por plan en rutas y APIs. +- [ ] Auditoria de cambios (logs funcionales por entidad critica). +- [ ] Manejo robusto de errores IA/OCR con mensajes accionables. +- [ ] Validacion de performance en listados y agregadores (paginacion/indices). +- [ ] Reforzar accesibilidad (labels, focus, teclado, contraste). + +## 13. Task Backlog (Orden de Ejecucion) + +### 13.1 Arquitectura y Persistencia +1. [ ] Crear schema Prisma para M08 (contratos, entregables, pagos, documentos, extraccion). +2. [ ] Crear schema Prisma para M09 (casos, diagnostico, escalada, documentos, directorio). +3. [ ] Crear schema Prisma para M10 (simulaciones, respuestas, snapshots). +4. [ ] Agregar indices y constraints de integridad. +5. [ ] Generar y revisar migraciones. +6. [ ] Ejecutar seeds base (directorio legal + catalogos). + +### 13.2 Backend APIs +1. [ ] Crear `src/app/api/contracts/*`. +2. [ ] Crear `src/app/api/legal/*`. +3. [ ] Crear `src/app/api/audits/*`. +4. [ ] Implementar servicios en `src/lib/contracts`, `src/lib/legal`, `src/lib/audits`. +5. [ ] Agregar validaciones de entrada/salida y manejo de errores uniforme. + +### 13.3 Frontend M08 +1. [ ] Crear pantalla principal M08 con 4 KPIs y tabs. +2. [ ] Implementar tab Contratos (cards, progreso, estado). +3. [ ] Implementar tab Entregables (pendiente/vencido/completado). +4. [ ] Implementar tab Pagos (registro e historial). +5. [ ] Implementar tab Subir Contrato + drag/drop + estado de analisis IA. +6. [ ] Conectar modales a APIs y refresco de dataset. + +### 13.4 Frontend M09 +1. [ ] Crear pantalla principal M09 con KPIs y tabs. +2. [ ] Implementar wizard Diagnostico con barra de progreso. +3. [ ] Implementar tab Casos con filtros por severidad/estado. +4. [ ] Implementar tab Escalada con timeline de pasos. +5. [ ] Implementar tab Escritos (formulario IA + plantillas). +6. [ ] Implementar tab Directorio (cards de entidades + acciones contacto). + +### 13.5 Frontend M10 +1. [ ] Crear pantalla principal M10 con KPIs y tabs. +2. [ ] Implementar tab Simulador (nueva simulacion, scoring y resultados). +3. [ ] Implementar tab Expediente (contratos, PDFs, propuestas, casos, escritos). +4. [ ] Implementar navegacion back/next M9 <-> M10 y Dashboard. + +### 13.6 Integraciones Cross-Module +1. [ ] Habilitar handoff M5 -> M8 para crear contrato desde propuesta adjudicada. +2. [ ] Habilitar handoff M8 -> M9 para abrir caso legal contextual. +3. [ ] Habilitar agregacion M8 + M9 en M10. +4. [ ] Publicar alertas M8/M9 relevantes para M7 (vencimientos y riesgo legal). + +## 14. Master Test Plan + +### 14.1 Unit Tests +- [ ] Reglas de calculo KPI M08. +- [ ] Reglas de calculo KPI M09. +- [ ] Reglas de calculo KPI M10. +- [ ] Motor de scoring del simulador. +- [ ] Clasificacion de severidad y rutas legales. +- [ ] Normalizacion de extraccion IA de contrato. + +### 14.2 Integration Tests +- [ ] Flujo M5 -> M8 (creacion de contrato desde propuesta). +- [ ] Flujo M08 completo (contrato -> entregable -> pago -> KPI). +- [ ] Flujo M8 -> M9 (apertura de caso legal). +- [ ] Flujo M9 -> M10 (caso/escrito visibles en expediente). +- [ ] Flujo M7 con alertas de entregables vencidos y casos severos. + +### 14.3 UI/E2E +- [ ] Paridad visual de M08 (tabs, cards, modales, empty/data states). +- [ ] Paridad visual de M09 (diagnostico, casos, escalada, escritos, directorio). +- [ ] Paridad visual de M10 (simulador y expediente). +- [ ] Responsive movil/escritorio en M08/M09/M10. +- [ ] Accesibilidad basica de formularios y modales. + +## 15. Release Gate (Definition of Done) + +### 15.1 Calidad Tecnica +- [ ] Migraciones aplicadas sin errores en entorno objetivo. +- [ ] Seeds ejecutados y datos base disponibles. +- [ ] Sin errores TypeScript en build. +- [ ] Sin errores de lint bloqueantes. +- [ ] Tests unitarios/integracion/UI en verde. + +### 15.2 Verificaciones por Comando (orden recomendado) +1. [ ] `npm run prisma:generate` +2. [ ] `npm run prisma:migrate` +3. [ ] `npm test` +4. [ ] `npm run lint` +5. [ ] `npm run build` (FINAL CHECK OBLIGATORIO) + +### 15.3 Aprobacion Funcional Final +- [ ] Demo E2E: M8 -> M9 -> M10 con datos reales de prueba. +- [ ] Validacion de KPIs contra datos de ejemplo. +- [ ] Validacion de guardado y recuperacion de evidencia/documentos. +- [ ] Sign-off final de producto. diff --git a/prisma/migrations/20260321193000_module5_gestion_licitaciones/migration.sql b/prisma/migrations/20260321193000_module5_gestion_licitaciones/migration.sql new file mode 100644 index 0000000..2dd0225 --- /dev/null +++ b/prisma/migrations/20260321193000_module5_gestion_licitaciones/migration.sql @@ -0,0 +1,58 @@ +-- CreateEnum +CREATE TYPE "ProposalStatus" AS ENUM ('DRAFT', 'IN_PROGRESS', 'SUBMITTED', 'ARCHIVED'); + +-- CreateTable +CREATE TABLE "Proposal" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "sourceLicitationId" TEXT, + "title" TEXT NOT NULL, + "issuingEntity" TEXT NOT NULL, + "summary" TEXT NOT NULL DEFAULT '', + "status" "ProposalStatus" NOT NULL DEFAULT 'DRAFT', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Proposal_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProposalDocument" ( + "id" TEXT NOT NULL, + "proposalId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "storedFileName" TEXT NOT NULL, + "filePath" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "sizeBytes" INTEGER NOT NULL, + "checksumSha256" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProposalDocument_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Proposal_userId_status_idx" ON "Proposal"("userId", "status"); + +-- CreateIndex +CREATE INDEX "Proposal_updatedAt_idx" ON "Proposal"("updatedAt"); + +-- CreateIndex +CREATE INDEX "Proposal_sourceLicitationId_idx" ON "Proposal"("sourceLicitationId"); + +-- CreateIndex +CREATE INDEX "ProposalDocument_proposalId_createdAt_idx" ON "ProposalDocument"("proposalId", "createdAt"); + +-- CreateIndex +CREATE INDEX "ProposalDocument_userId_createdAt_idx" ON "ProposalDocument"("userId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "Proposal" ADD CONSTRAINT "Proposal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProposalDocument" ADD CONSTRAINT "ProposalDocument_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProposalDocument" ADD CONSTRAINT "ProposalDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260323120000_licitaya_source/migration.sql b/prisma/migrations/20260323120000_licitaya_source/migration.sql new file mode 100644 index 0000000..6037648 --- /dev/null +++ b/prisma/migrations/20260323120000_licitaya_source/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "LicitationSource" ADD VALUE IF NOT EXISTS 'LICITAYA'; diff --git a/prisma/migrations/20260327120000_module3_preferences_module4_history/migration.sql b/prisma/migrations/20260327120000_module3_preferences_module4_history/migration.sql new file mode 100644 index 0000000..8ca4dbc --- /dev/null +++ b/prisma/migrations/20260327120000_module3_preferences_module4_history/migration.sql @@ -0,0 +1,81 @@ +-- CreateEnum +CREATE TYPE "LicitationReviewStatus" AS ENUM ('NEW', 'REVIEWED', 'INTERESTED', 'DISCARDED'); + +-- CreateEnum +CREATE TYPE "NormativeDocumentType" AS ENUM ('BASES_LICITACION', 'CONVOCATORIA', 'REGLAMENTO', 'LEY', 'OTRO'); + +-- CreateEnum +CREATE TYPE "NormativeAnalysisMethod" AS ENUM ('DIRECT', 'OCR'); + +-- CreateEnum +CREATE TYPE "NormativeConfidence" AS ENUM ('LOW', 'MEDIUM', 'HIGH'); + +-- CreateEnum +CREATE TYPE "NormativeRiskLevel" AS ENUM ('ALTO', 'MEDIO', 'BAJO'); + +-- CreateTable +CREATE TABLE "LicitationUserPreference" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "licitationId" TEXT NOT NULL, + "status" "LicitationReviewStatus" NOT NULL DEFAULT 'NEW', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LicitationUserPreference_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NormativeAnalysisHistory" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "sourceLicitationId" TEXT, + "fileName" TEXT NOT NULL, + "documentType" "NormativeDocumentType" NOT NULL, + "issuingEntity" TEXT, + "methodUsed" "NormativeAnalysisMethod" NOT NULL, + "numPages" INTEGER NOT NULL, + "warnings" JSONB, + "extractedChars" INTEGER NOT NULL, + "confidence" "NormativeConfidence" NOT NULL, + "viabilityScore" INTEGER NOT NULL, + "riskLevel" "NormativeRiskLevel" NOT NULL, + "executiveSummary" TEXT NOT NULL, + "result" JSONB NOT NULL, + "analyzedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NormativeAnalysisHistory_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "LicitationUserPreference_userId_licitationId_key" ON "LicitationUserPreference"("userId", "licitationId"); + +-- CreateIndex +CREATE INDEX "LicitationUserPreference_userId_status_idx" ON "LicitationUserPreference"("userId", "status"); + +-- CreateIndex +CREATE INDEX "LicitationUserPreference_licitationId_status_idx" ON "LicitationUserPreference"("licitationId", "status"); + +-- CreateIndex +CREATE INDEX "NormativeAnalysisHistory_userId_analyzedAt_idx" ON "NormativeAnalysisHistory"("userId", "analyzedAt"); + +-- CreateIndex +CREATE INDEX "NormativeAnalysisHistory_userId_deletedAt_idx" ON "NormativeAnalysisHistory"("userId", "deletedAt"); + +-- CreateIndex +CREATE INDEX "NormativeAnalysisHistory_sourceLicitationId_idx" ON "NormativeAnalysisHistory"("sourceLicitationId"); + +-- AddForeignKey +ALTER TABLE "LicitationUserPreference" ADD CONSTRAINT "LicitationUserPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LicitationUserPreference" ADD CONSTRAINT "LicitationUserPreference_licitationId_fkey" FOREIGN KEY ("licitationId") REFERENCES "Licitation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NormativeAnalysisHistory" ADD CONSTRAINT "NormativeAnalysisHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NormativeAnalysisHistory" ADD CONSTRAINT "NormativeAnalysisHistory_sourceLicitationId_fkey" FOREIGN KEY ("sourceLicitationId") REFERENCES "Licitation"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260330130000_module5_workflow_persistence/migration.sql b/prisma/migrations/20260330130000_module5_workflow_persistence/migration.sql new file mode 100644 index 0000000..2caf9dc --- /dev/null +++ b/prisma/migrations/20260330130000_module5_workflow_persistence/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Proposal" +ADD COLUMN "workflowDraft" JSONB, +ADD COLUMN "currentStep" INTEGER NOT NULL DEFAULT 1, +ADD COLUMN "completionPercent" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "readyForSubmissionAt" TIMESTAMP(3); diff --git a/prisma/migrations/20260406015826_compliance_regulations_persistence/migration.sql b/prisma/migrations/20260406015826_compliance_regulations_persistence/migration.sql new file mode 100644 index 0000000..56a8866 --- /dev/null +++ b/prisma/migrations/20260406015826_compliance_regulations_persistence/migration.sql @@ -0,0 +1,8 @@ +-- RenameIndex (safe guard for environments where the old truncated index never existed) +DO $$ +BEGIN + IF to_regclass('"OfficialNormativeSuggestion_stateCode_municipalityCode_createdA"') IS NOT NULL THEN + ALTER INDEX "OfficialNormativeSuggestion_stateCode_municipalityCode_createdA" + RENAME TO "OfficialNormativeSuggestion_stateCode_municipalityCode_crea_idx"; + END IF; +END $$; diff --git a/prisma/migrations/20260406183000_compliance_regulations_persistence/migration.sql b/prisma/migrations/20260406183000_compliance_regulations_persistence/migration.sql new file mode 100644 index 0000000..6b14c83 --- /dev/null +++ b/prisma/migrations/20260406183000_compliance_regulations_persistence/migration.sql @@ -0,0 +1,134 @@ +-- CreateEnum +CREATE TYPE "OfficialNormativeSourceType" AS ENUM ('LEY', 'REGLAMENTO', 'LINEAMIENTO', 'PORTAL'); + +-- CreateEnum +CREATE TYPE "NormativeVerificationStatus" AS ENUM ('SUCCESS', 'WARNING', 'FAILED'); + +-- CreateTable +CREATE TABLE "OfficialNormativeSource" ( + "id" TEXT NOT NULL, + "stateCode" TEXT NOT NULL, + "stateName" TEXT NOT NULL, + "municipalityCode" TEXT, + "municipalityName" TEXT, + "authorityName" TEXT NOT NULL, + "title" TEXT NOT NULL, + "officialUrl" TEXT NOT NULL, + "sourceType" "OfficialNormativeSourceType" NOT NULL, + "versionLabel" TEXT, + "isPilot" BOOLEAN NOT NULL DEFAULT false, + "lastKnownHash" TEXT, + "lastVerifiedAt" TIMESTAMP(3), + "nextCheckAt" TIMESTAMP(3), + "lastStatus" "NormativeVerificationStatus", + "lastMessage" TEXT, + "lastChangedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OfficialNormativeSource_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OfficialNormativeSuggestion" ( + "id" TEXT NOT NULL, + "stateCode" TEXT NOT NULL, + "municipalityCode" TEXT, + "authorityName" TEXT NOT NULL, + "title" TEXT NOT NULL, + "officialUrl" TEXT NOT NULL, + "sourceType" "OfficialNormativeSourceType" NOT NULL, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OfficialNormativeSuggestion_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "OfficialNormativeSource_stateCode_municipalityCode_isPilot_idx" ON "OfficialNormativeSource"("stateCode", "municipalityCode", "isPilot"); + +-- CreateIndex +CREATE INDEX "OfficialNormativeSource_nextCheckAt_idx" ON "OfficialNormativeSource"("nextCheckAt"); + +-- CreateIndex +CREATE INDEX "OfficialNormativeSource_lastStatus_lastVerifiedAt_idx" ON "OfficialNormativeSource"("lastStatus", "lastVerifiedAt"); + +-- CreateIndex +CREATE INDEX "OfficialNormativeSuggestion_stateCode_municipalityCode_createdAt_idx" ON "OfficialNormativeSuggestion"("stateCode", "municipalityCode", "createdAt"); + +-- Seed pilot sources (Nuevo Leon) +INSERT INTO "OfficialNormativeSource" ( + "id", + "stateCode", + "stateName", + "municipalityCode", + "municipalityName", + "authorityName", + "title", + "officialUrl", + "sourceType", + "versionLabel", + "isPilot", + "createdAt", + "updatedAt" +) +VALUES + ( + 'nl-ley-adquisiciones-estatal', + 'NL', + 'Nuevo Leon', + NULL, + NULL, + 'Gobierno del Estado de Nuevo Leon', + 'Ley de Adquisiciones, Arrendamientos y Contratacion de Servicios del Estado de Nuevo Leon', + 'https://www.hcnl.gob.mx/trabajo_legislativo/leyes/', + 'LEY', + NULL, + true, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ), + ( + 'nl-reglamento-adquisiciones-estatal', + 'NL', + 'Nuevo Leon', + NULL, + NULL, + 'Gobierno del Estado de Nuevo Leon', + 'Reglamento de la Ley de Adquisiciones del Estado de Nuevo Leon', + 'https://www.nl.gob.mx/', + 'REGLAMENTO', + NULL, + true, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ), + ( + 'spgg-reglamento-adquisiciones', + 'NL', + 'Nuevo Leon', + 'SPGG', + 'San Pedro Garza Garcia', + 'Municipio de San Pedro Garza Garcia', + 'Reglamento de Adquisiciones y Contratacion de Servicios de San Pedro Garza Garcia', + 'https://www.sanpedro.gob.mx/', + 'REGLAMENTO', + NULL, + true, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) +ON CONFLICT ("id") DO UPDATE +SET + "stateCode" = EXCLUDED."stateCode", + "stateName" = EXCLUDED."stateName", + "municipalityCode" = EXCLUDED."municipalityCode", + "municipalityName" = EXCLUDED."municipalityName", + "authorityName" = EXCLUDED."authorityName", + "title" = EXCLUDED."title", + "officialUrl" = EXCLUDED."officialUrl", + "sourceType" = EXCLUDED."sourceType", + "versionLabel" = EXCLUDED."versionLabel", + "isPilot" = EXCLUDED."isPilot", + "updatedAt" = CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260406200000_modules8_9_10/migration.sql b/prisma/migrations/20260406200000_modules8_9_10/migration.sql new file mode 100644 index 0000000..cd49cd2 --- /dev/null +++ b/prisma/migrations/20260406200000_modules8_9_10/migration.sql @@ -0,0 +1,376 @@ +-- CreateEnum +CREATE TYPE "ContractStatus" AS ENUM ('ACTIVE', 'COMPLETED', 'PAUSED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "ContractDeliverableStatus" AS ENUM ('PENDING', 'DELIVERED', 'APPROVED', 'REJECTED', 'OVERDUE'); + +-- CreateEnum +CREATE TYPE "ContractPaymentStatus" AS ENUM ('REGISTERED', 'CONFIRMED', 'DISPUTED'); + +-- CreateEnum +CREATE TYPE "ContractDocumentKind" AS ENUM ('SIGNED_CONTRACT', 'ADDENDUM', 'DELIVERABLE_EVIDENCE', 'PAYMENT_EVIDENCE', 'OTHER'); + +-- CreateEnum +CREATE TYPE "LegalCaseType" AS ENUM ('CONTRACT_BREACH', 'PAYMENT_RETENTION', 'UNJUST_SANCTION', 'CONTRACT_DISPUTE'); + +-- CreateEnum +CREATE TYPE "LegalCaseSeverity" AS ENUM ('LOW', 'MEDIUM', 'HIGH'); + +-- CreateEnum +CREATE TYPE "LegalCaseStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'ESCALATED', 'RESOLVED', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "LegalJurisdictionLevel" AS ENUM ('FEDERAL', 'STATE', 'MUNICIPAL'); + +-- CreateEnum +CREATE TYPE "AuditSimulationStatus" AS ENUM ('DRAFT', 'COMPLETED'); + +-- CreateEnum +CREATE TYPE "AuditSimulationSectionStatus" AS ENUM ('READY', 'WARNING', 'CRITICAL'); + +-- CreateTable +CREATE TABLE "ContractRecord" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "sourceProposalId" TEXT, + "title" TEXT NOT NULL, + "counterpartyEntity" TEXT NOT NULL, + "contractNumber" TEXT, + "contractType" TEXT NOT NULL, + "startDate" TIMESTAMP(3), + "endDate" TIMESTAMP(3), + "totalAmount" DECIMAL(14,2), + "currency" TEXT NOT NULL DEFAULT 'MXN', + "status" "ContractStatus" NOT NULL DEFAULT 'ACTIVE', + "description" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContractRecord_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ContractDeliverable" ( + "id" TEXT NOT NULL, + "contractId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "dueDate" TIMESTAMP(3), + "amountLinked" DECIMAL(14,2), + "status" "ContractDeliverableStatus" NOT NULL DEFAULT 'PENDING', + "deliveredAt" TIMESTAMP(3), + "approvedAt" TIMESTAMP(3), + "notes" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContractDeliverable_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ContractPayment" ( + "id" TEXT NOT NULL, + "contractId" TEXT NOT NULL, + "amount" DECIMAL(14,2) NOT NULL, + "paymentDate" TIMESTAMP(3) NOT NULL, + "invoiceNumber" TEXT, + "concept" TEXT NOT NULL DEFAULT '', + "status" "ContractPaymentStatus" NOT NULL DEFAULT 'REGISTERED', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContractPayment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ContractDocument" ( + "id" TEXT NOT NULL, + "contractId" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "filePath" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "sizeBytes" INTEGER NOT NULL, + "checksumSha256" TEXT, + "kind" "ContractDocumentKind" NOT NULL DEFAULT 'OTHER', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContractDocument_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ContractExtractionHistory" ( + "id" TEXT NOT NULL, + "contractId" TEXT, + "userId" TEXT NOT NULL, + "engine" TEXT NOT NULL, + "model" TEXT, + "resultJson" JSONB NOT NULL, + "warningsJson" JSONB, + "analyzedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ContractExtractionHistory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LegalCase" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "contractId" TEXT, + "caseType" "LegalCaseType" NOT NULL, + "severity" "LegalCaseSeverity" NOT NULL, + "counterparty" TEXT NOT NULL, + "description" TEXT NOT NULL, + "amountAtRisk" DECIMAL(14,2), + "status" "LegalCaseStatus" NOT NULL DEFAULT 'OPEN', + "openedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "resolvedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LegalCase_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LegalDiagnosis" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "legalCaseId" TEXT, + "stepIndex" INTEGER NOT NULL DEFAULT 1, + "totalSteps" INTEGER NOT NULL DEFAULT 4, + "answersJson" JSONB NOT NULL, + "recommendedRouteJson" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LegalDiagnosis_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LegalEscalationStepLog" ( + "id" TEXT NOT NULL, + "legalCaseId" TEXT NOT NULL, + "routeStepKey" TEXT NOT NULL, + "completedAt" TIMESTAMP(3), + "notes" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LegalEscalationStepLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LegalDocument" ( + "id" TEXT NOT NULL, + "legalCaseId" TEXT, + "userId" TEXT NOT NULL, + "templateKey" TEXT, + "aiGenerated" BOOLEAN NOT NULL DEFAULT false, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LegalDocument_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LegalDirectoryEntity" ( + "id" TEXT NOT NULL, + "jurisdictionLevel" "LegalJurisdictionLevel" NOT NULL, + "name" TEXT NOT NULL, + "scopeTagsJson" JSONB NOT NULL, + "websiteUrl" TEXT, + "phone" TEXT, + "email" TEXT, + "stateCode" TEXT, + "municipalityCode" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LegalDirectoryEntity_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuditSimulation" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "auditType" TEXT NOT NULL, + "status" "AuditSimulationStatus" NOT NULL DEFAULT 'DRAFT', + "overallScore" INTEGER, + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AuditSimulation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuditSimulationSection" ( + "id" TEXT NOT NULL, + "simulationId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "score" INTEGER, + "status" "AuditSimulationSectionStatus" NOT NULL DEFAULT 'READY', + "findingsJson" JSONB, + "recommendationsJson" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AuditSimulationSection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuditChecklistResponse" ( + "id" TEXT NOT NULL, + "simulationId" TEXT NOT NULL, + "questionKey" TEXT NOT NULL, + "answer" TEXT NOT NULL, + "evidenceRefsJson" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AuditChecklistResponse_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "InstitutionalDossierSnapshot" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "payloadJson" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "InstitutionalDossierSnapshot_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ContractRecord_userId_status_endDate_idx" ON "ContractRecord"("userId", "status", "endDate"); + +-- CreateIndex +CREATE INDEX "ContractRecord_sourceProposalId_idx" ON "ContractRecord"("sourceProposalId"); + +-- CreateIndex +CREATE INDEX "ContractRecord_updatedAt_idx" ON "ContractRecord"("updatedAt"); + +-- CreateIndex +CREATE INDEX "ContractDeliverable_contractId_status_dueDate_idx" ON "ContractDeliverable"("contractId", "status", "dueDate"); + +-- CreateIndex +CREATE INDEX "ContractDeliverable_dueDate_idx" ON "ContractDeliverable"("dueDate"); + +-- CreateIndex +CREATE INDEX "ContractPayment_contractId_paymentDate_idx" ON "ContractPayment"("contractId", "paymentDate"); + +-- CreateIndex +CREATE INDEX "ContractPayment_status_paymentDate_idx" ON "ContractPayment"("status", "paymentDate"); + +-- CreateIndex +CREATE INDEX "ContractDocument_contractId_kind_createdAt_idx" ON "ContractDocument"("contractId", "kind", "createdAt"); + +-- CreateIndex +CREATE INDEX "ContractExtractionHistory_userId_analyzedAt_idx" ON "ContractExtractionHistory"("userId", "analyzedAt"); + +-- CreateIndex +CREATE INDEX "ContractExtractionHistory_contractId_analyzedAt_idx" ON "ContractExtractionHistory"("contractId", "analyzedAt"); + +-- CreateIndex +CREATE INDEX "LegalCase_userId_status_severity_idx" ON "LegalCase"("userId", "status", "severity"); + +-- CreateIndex +CREATE INDEX "LegalCase_contractId_idx" ON "LegalCase"("contractId"); + +-- CreateIndex +CREATE INDEX "LegalDiagnosis_userId_updatedAt_idx" ON "LegalDiagnosis"("userId", "updatedAt"); + +-- CreateIndex +CREATE INDEX "LegalDiagnosis_legalCaseId_idx" ON "LegalDiagnosis"("legalCaseId"); + +-- CreateIndex +CREATE UNIQUE INDEX "LegalEscalationStepLog_legalCaseId_routeStepKey_key" ON "LegalEscalationStepLog"("legalCaseId", "routeStepKey"); + +-- CreateIndex +CREATE INDEX "LegalEscalationStepLog_legalCaseId_completedAt_idx" ON "LegalEscalationStepLog"("legalCaseId", "completedAt"); + +-- CreateIndex +CREATE INDEX "LegalDocument_userId_createdAt_idx" ON "LegalDocument"("userId", "createdAt"); + +-- CreateIndex +CREATE INDEX "LegalDocument_legalCaseId_createdAt_idx" ON "LegalDocument"("legalCaseId", "createdAt"); + +-- CreateIndex +CREATE INDEX "LegalDirectoryEntity_jurisdictionLevel_isActive_idx" ON "LegalDirectoryEntity"("jurisdictionLevel", "isActive"); + +-- CreateIndex +CREATE INDEX "LegalDirectoryEntity_stateCode_municipalityCode_isActive_idx" ON "LegalDirectoryEntity"("stateCode", "municipalityCode", "isActive"); + +-- CreateIndex +CREATE INDEX "AuditSimulation_userId_status_updatedAt_idx" ON "AuditSimulation"("userId", "status", "updatedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "AuditSimulationSection_simulationId_key_key" ON "AuditSimulationSection"("simulationId", "key"); + +-- CreateIndex +CREATE INDEX "AuditSimulationSection_status_idx" ON "AuditSimulationSection"("status"); + +-- CreateIndex +CREATE INDEX "AuditChecklistResponse_simulationId_questionKey_idx" ON "AuditChecklistResponse"("simulationId", "questionKey"); + +-- CreateIndex +CREATE INDEX "InstitutionalDossierSnapshot_userId_generatedAt_idx" ON "InstitutionalDossierSnapshot"("userId", "generatedAt"); + +-- AddForeignKey +ALTER TABLE "ContractRecord" ADD CONSTRAINT "ContractRecord_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractRecord" ADD CONSTRAINT "ContractRecord_sourceProposalId_fkey" FOREIGN KEY ("sourceProposalId") REFERENCES "Proposal"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractDeliverable" ADD CONSTRAINT "ContractDeliverable_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "ContractRecord"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractPayment" ADD CONSTRAINT "ContractPayment_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "ContractRecord"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractDocument" ADD CONSTRAINT "ContractDocument_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "ContractRecord"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractExtractionHistory" ADD CONSTRAINT "ContractExtractionHistory_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "ContractRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractExtractionHistory" ADD CONSTRAINT "ContractExtractionHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LegalCase" ADD CONSTRAINT "LegalCase_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LegalCase" ADD CONSTRAINT "LegalCase_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "ContractRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LegalDiagnosis" ADD CONSTRAINT "LegalDiagnosis_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LegalDiagnosis" ADD CONSTRAINT "LegalDiagnosis_legalCaseId_fkey" FOREIGN KEY ("legalCaseId") REFERENCES "LegalCase"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LegalEscalationStepLog" ADD CONSTRAINT "LegalEscalationStepLog_legalCaseId_fkey" FOREIGN KEY ("legalCaseId") REFERENCES "LegalCase"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LegalDocument" ADD CONSTRAINT "LegalDocument_legalCaseId_fkey" FOREIGN KEY ("legalCaseId") REFERENCES "LegalCase"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LegalDocument" ADD CONSTRAINT "LegalDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuditSimulation" ADD CONSTRAINT "AuditSimulation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuditSimulationSection" ADD CONSTRAINT "AuditSimulationSection_simulationId_fkey" FOREIGN KEY ("simulationId") REFERENCES "AuditSimulation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuditChecklistResponse" ADD CONSTRAINT "AuditChecklistResponse_simulationId_fkey" FOREIGN KEY ("simulationId") REFERENCES "AuditSimulation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "InstitutionalDossierSnapshot" ADD CONSTRAINT "InstitutionalDossierSnapshot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260406213000_normative_index_name_normalization/migration.sql b/prisma/migrations/20260406213000_normative_index_name_normalization/migration.sql new file mode 100644 index 0000000..8730cc9 --- /dev/null +++ b/prisma/migrations/20260406213000_normative_index_name_normalization/migration.sql @@ -0,0 +1,9 @@ +-- Normalize index name across environments where Postgres truncated long identifiers. +DO $$ +BEGIN + IF to_regclass('"OfficialNormativeSuggestion_stateCode_municipalityCode_createdA"') IS NOT NULL + AND to_regclass('"OfficialNormativeSuggestion_stateCode_municipalityCode_crea_idx"') IS NULL THEN + ALTER INDEX "OfficialNormativeSuggestion_stateCode_municipalityCode_createdA" + RENAME TO "OfficialNormativeSuggestion_stateCode_municipalityCode_crea_idx"; + END IF; +END $$; diff --git a/prisma/migrations/20260406224500_ai_assist_suggestions/migration.sql b/prisma/migrations/20260406224500_ai_assist_suggestions/migration.sql new file mode 100644 index 0000000..8229591 --- /dev/null +++ b/prisma/migrations/20260406224500_ai_assist_suggestions/migration.sql @@ -0,0 +1,42 @@ +-- CreateEnum +CREATE TYPE "AiSuggestionStatus" AS ENUM ('GENERATED', 'ACCEPTED', 'DISMISSED', 'EXPIRED'); + +-- CreateTable +CREATE TABLE "AiSuggestion" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "moduleKey" TEXT NOT NULL, + "featureKey" TEXT NOT NULL, + "subjectType" TEXT NOT NULL, + "subjectId" TEXT NOT NULL, + "inputHash" TEXT NOT NULL, + "requestJson" JSONB NOT NULL, + "responseJson" JSONB NOT NULL, + "confidence" DOUBLE PRECISION, + "engine" TEXT NOT NULL, + "model" TEXT, + "usageJson" JSONB, + "warningsJson" JSONB, + "promptVersion" TEXT NOT NULL, + "status" "AiSuggestionStatus" NOT NULL DEFAULT 'GENERATED', + "actedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AiSuggestion_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ai_suggestion_dedupe" ON "AiSuggestion"("userId", "moduleKey", "featureKey", "subjectType", "subjectId", "inputHash"); + +-- CreateIndex +CREATE INDEX "AiSuggestion_userId_moduleKey_featureKey_createdAt_idx" ON "AiSuggestion"("userId", "moduleKey", "featureKey", "createdAt"); + +-- CreateIndex +CREATE INDEX "AiSuggestion_status_updatedAt_idx" ON "AiSuggestion"("status", "updatedAt"); + +-- CreateIndex +CREATE INDEX "AiSuggestion_inputHash_idx" ON "AiSuggestion"("inputHash"); + +-- AddForeignKey +ALTER TABLE "AiSuggestion" ADD CONSTRAINT "AiSuggestion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260428153000_module_plans_mercadopago/migration.sql b/prisma/migrations/20260428153000_module_plans_mercadopago/migration.sql new file mode 100644 index 0000000..03689cf --- /dev/null +++ b/prisma/migrations/20260428153000_module_plans_mercadopago/migration.sql @@ -0,0 +1,69 @@ +-- CreateEnum +CREATE TYPE "ModulePlanKey" AS ENUM ('PLAN_2_4', 'PLAN_5_7', 'PLAN_8_10'); + +-- CreateEnum +CREATE TYPE "ModulePlanPurchaseStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED', 'EXPIRED'); + +-- CreateTable +CREATE TABLE "ModulePlanPurchase" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "planKey" "ModulePlanKey" NOT NULL, + "status" "ModulePlanPurchaseStatus" NOT NULL DEFAULT 'PENDING', + "externalReference" TEXT NOT NULL, + "mercadoPreferenceId" TEXT, + "mercadoPaymentId" TEXT, + "mercadoOrderId" TEXT, + "checkoutUrl" TEXT, + "amount" DECIMAL(14,2) NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'MXN', + "requestJson" JSONB, + "responseJson" JSONB, + "approvedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ModulePlanPurchase_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ModulePlanSubscription" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "planKey" "ModulePlanKey" NOT NULL, + "sourcePurchaseId" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "startsAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ModulePlanSubscription_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ModulePlanPurchase_externalReference_key" ON "ModulePlanPurchase"("externalReference"); + +-- CreateIndex +CREATE INDEX "ModulePlanPurchase_userId_planKey_createdAt_idx" ON "ModulePlanPurchase"("userId", "planKey", "createdAt"); + +-- CreateIndex +CREATE INDEX "ModulePlanPurchase_status_updatedAt_idx" ON "ModulePlanPurchase"("status", "updatedAt"); + +-- CreateIndex +CREATE INDEX "ModulePlanPurchase_mercadoPaymentId_idx" ON "ModulePlanPurchase"("mercadoPaymentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ModulePlanSubscription_userId_planKey_key" ON "ModulePlanSubscription"("userId", "planKey"); + +-- CreateIndex +CREATE INDEX "ModulePlanSubscription_userId_isActive_expiresAt_idx" ON "ModulePlanSubscription"("userId", "isActive", "expiresAt"); + +-- AddForeignKey +ALTER TABLE "ModulePlanPurchase" ADD CONSTRAINT "ModulePlanPurchase_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ModulePlanSubscription" ADD CONSTRAINT "ModulePlanSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ModulePlanSubscription" ADD CONSTRAINT "ModulePlanSubscription_sourcePurchaseId_fkey" FOREIGN KEY ("sourcePurchaseId") REFERENCES "ModulePlanPurchase"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3d3f828..e8200d5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,6 +60,7 @@ enum LicitationSource { MUNICIPAL_OPEN_PORTAL PNT MUNICIPAL_BACKUP + LICITAYA } enum LicitationProcedureType { @@ -83,11 +84,151 @@ enum SyncRunStatus { FAILED } +enum ProposalStatus { + DRAFT + IN_PROGRESS + SUBMITTED + ARCHIVED +} + +enum LicitationReviewStatus { + NEW + REVIEWED + INTERESTED + DISCARDED +} + +enum NormativeDocumentType { + BASES_LICITACION + CONVOCATORIA + REGLAMENTO + LEY + OTRO +} + +enum NormativeAnalysisMethod { + DIRECT + OCR +} + +enum NormativeConfidence { + LOW + MEDIUM + HIGH +} + +enum NormativeRiskLevel { + ALTO + MEDIO + BAJO +} + +enum OfficialNormativeSourceType { + LEY + REGLAMENTO + LINEAMIENTO + PORTAL +} + +enum NormativeVerificationStatus { + SUCCESS + WARNING + FAILED +} + enum MunicipalOpenPortalType { GENERIC SAN_PEDRO_ASPX } +enum ContractStatus { + ACTIVE + COMPLETED + PAUSED + CANCELLED +} + +enum ContractDeliverableStatus { + PENDING + DELIVERED + APPROVED + REJECTED + OVERDUE +} + +enum ContractPaymentStatus { + REGISTERED + CONFIRMED + DISPUTED +} + +enum ContractDocumentKind { + SIGNED_CONTRACT + ADDENDUM + DELIVERABLE_EVIDENCE + PAYMENT_EVIDENCE + OTHER +} + +enum LegalCaseType { + CONTRACT_BREACH + PAYMENT_RETENTION + UNJUST_SANCTION + CONTRACT_DISPUTE +} + +enum LegalCaseSeverity { + LOW + MEDIUM + HIGH +} + +enum LegalCaseStatus { + OPEN + IN_PROGRESS + ESCALATED + RESOLVED + CLOSED +} + +enum LegalJurisdictionLevel { + FEDERAL + STATE + MUNICIPAL +} + +enum AuditSimulationStatus { + DRAFT + COMPLETED +} + +enum AuditSimulationSectionStatus { + READY + WARNING + CRITICAL +} + +enum AiSuggestionStatus { + GENERATED + ACCEPTED + DISMISSED + EXPIRED +} + +enum ModulePlanKey { + PLAN_2_4 + PLAN_5_7 + PLAN_8_10 +} + +enum ModulePlanPurchaseStatus { + PENDING + APPROVED + REJECTED + CANCELLED + EXPIRED +} + model User { id String @id @default(cuid()) email String @unique @@ -103,9 +244,23 @@ model User { strategicDiagnosticEvidenceDocs StrategicDiagnosticEvidenceDocument[] workshopProgresses DevelopmentWorkshopProgress[] workshopEvidenceDocs DevelopmentWorkshopEvidence[] + proposals Proposal[] + proposalDocuments ProposalDocument[] + licitationPreferences LicitationUserPreference[] + normativeAnalyses NormativeAnalysisHistory[] + contracts ContractRecord[] + contractExtractionHistories ContractExtractionHistory[] + legalCases LegalCase[] + legalDiagnoses LegalDiagnosis[] + legalDocuments LegalDocument[] + auditSimulations AuditSimulation[] + institutionalDossierSnapshots InstitutionalDossierSnapshot[] verificationTokens EmailVerificationToken[] responses Response[] results AssessmentResult[] + aiSuggestions AiSuggestion[] + modulePlanPurchases ModulePlanPurchase[] + modulePlanSubscriptions ModulePlanSubscription[] } model Organization { @@ -192,13 +347,13 @@ model StrategicDiagnosticEvidenceDocument { } model DiagnosticModule { - id String @id @default(cuid()) - key String @unique + id String @id @default(cuid()) + key String @unique name String description String? - sortOrder Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt questions Question[] results AssessmentResult[] recommendations Recommendation[] @@ -206,39 +361,39 @@ model DiagnosticModule { } model DevelopmentWorkshop { - id String @id @default(cuid()) - key String @unique - moduleId String - title String - summary String - videoUrl String - durationMinutes Int @default(0) - evidenceRequired String + id String @id @default(cuid()) + key String @unique + moduleId String + title String + summary String + videoUrl String + durationMinutes Int @default(0) + evidenceRequired String learningObjectives Json? - sortOrder Int @default(0) - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - module DiagnosticModule @relation(fields: [moduleId], references: [id], onDelete: Cascade) - progresses DevelopmentWorkshopProgress[] - evidences DevelopmentWorkshopEvidence[] + sortOrder Int @default(0) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + module DiagnosticModule @relation(fields: [moduleId], references: [id], onDelete: Cascade) + progresses DevelopmentWorkshopProgress[] + evidences DevelopmentWorkshopEvidence[] @@index([moduleId, sortOrder]) @@index([isActive, sortOrder]) } model DevelopmentWorkshopProgress { - id String @id @default(cuid()) + id String @id @default(cuid()) workshopId String userId String status WorkshopProgressStatus @default(NOT_STARTED) watchedAt DateTime? skippedAt DateTime? completedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - workshop DevelopmentWorkshop @relation(fields: [workshopId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + workshop DevelopmentWorkshop @relation(fields: [workshopId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([workshopId, userId]) @@index([userId, status]) @@ -384,6 +539,48 @@ model EmailVerificationToken { @@index([expiresAt]) } +model ModulePlanPurchase { + id String @id @default(cuid()) + userId String + planKey ModulePlanKey + status ModulePlanPurchaseStatus @default(PENDING) + externalReference String @unique + mercadoPreferenceId String? + mercadoPaymentId String? + mercadoOrderId String? + checkoutUrl String? + amount Decimal @db.Decimal(14, 2) + currency String @default("MXN") + requestJson Json? + responseJson Json? + approvedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + subscriptions ModulePlanSubscription[] + + @@index([userId, planKey, createdAt]) + @@index([status, updatedAt]) + @@index([mercadoPaymentId]) +} + +model ModulePlanSubscription { + id String @id @default(cuid()) + userId String + planKey ModulePlanKey + sourcePurchaseId String? + isActive Boolean @default(true) + startsAt DateTime @default(now()) + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + sourcePurchase ModulePlanPurchase? @relation(fields: [sourcePurchaseId], references: [id], onDelete: SetNull) + + @@unique([userId, planKey]) + @@index([userId, isActive, expiresAt]) +} + model Municipality { id String @id @default(cuid()) stateCode String @@ -412,31 +609,33 @@ model Municipality { } model Licitation { - id String @id @default(cuid()) - municipalityId String - source LicitationSource - sourceRecordId String - tenderCode String? - procedureType LicitationProcedureType @default(UNKNOWN) - title String - description String? - category LicitationCategory? @default(UNKNOWN) - isOpen Boolean @default(true) - openingDate DateTime? - closingDate DateTime? - publishDate DateTime? - eventDates Json? - amount Decimal? @db.Decimal(14, 2) - currency String? - status String? - supplierAwarded String? - documents Json? - rawSourceUrl String? - rawPayload Json - lastSeenAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - municipality Municipality @relation(fields: [municipalityId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + municipalityId String + source LicitationSource + sourceRecordId String + tenderCode String? + procedureType LicitationProcedureType @default(UNKNOWN) + title String + description String? + category LicitationCategory? @default(UNKNOWN) + isOpen Boolean @default(true) + openingDate DateTime? + closingDate DateTime? + publishDate DateTime? + eventDates Json? + amount Decimal? @db.Decimal(14, 2) + currency String? + status String? + supplierAwarded String? + documents Json? + rawSourceUrl String? + rawPayload Json + lastSeenAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + municipality Municipality @relation(fields: [municipalityId], references: [id], onDelete: Cascade) + userPreferences LicitationUserPreference[] + normativeAnalyses NormativeAnalysisHistory[] @@unique([municipalityId, source, sourceRecordId]) @@index([municipalityId, isOpen, closingDate]) @@ -475,3 +674,400 @@ model CompanyProfile { user User @relation(fields: [userId], references: [id], onDelete: Cascade) organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull) } + +model LicitationUserPreference { + id String @id @default(cuid()) + userId String + licitationId String + status LicitationReviewStatus @default(NEW) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + licitation Licitation @relation(fields: [licitationId], references: [id], onDelete: Cascade) + + @@unique([userId, licitationId]) + @@index([userId, status]) + @@index([licitationId, status]) +} + +model NormativeAnalysisHistory { + id String @id @default(cuid()) + userId String + sourceLicitationId String? + fileName String + documentType NormativeDocumentType + issuingEntity String? + methodUsed NormativeAnalysisMethod + numPages Int + warnings Json? + extractedChars Int + confidence NormativeConfidence + viabilityScore Int + riskLevel NormativeRiskLevel + executiveSummary String + result Json + analyzedAt DateTime @default(now()) + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + sourceLicitation Licitation? @relation(fields: [sourceLicitationId], references: [id], onDelete: SetNull) + + @@index([userId, analyzedAt]) + @@index([userId, deletedAt]) + @@index([sourceLicitationId]) +} + +model Proposal { + id String @id @default(cuid()) + userId String + sourceLicitationId String? + title String + issuingEntity String + summary String @default("") + workflowDraft Json? + currentStep Int @default(1) + completionPercent Int @default(0) + readyForSubmissionAt DateTime? + status ProposalStatus @default(DRAFT) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + documents ProposalDocument[] + contracts ContractRecord[] + + @@index([userId, status]) + @@index([updatedAt]) + @@index([sourceLicitationId]) +} + +model ContractRecord { + id String @id @default(cuid()) + userId String + sourceProposalId String? + title String + counterpartyEntity String + contractNumber String? + contractType String + startDate DateTime? + endDate DateTime? + totalAmount Decimal? @db.Decimal(14, 2) + currency String @default("MXN") + status ContractStatus @default(ACTIVE) + description String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + sourceProposal Proposal? @relation(fields: [sourceProposalId], references: [id], onDelete: SetNull) + deliverables ContractDeliverable[] + payments ContractPayment[] + documents ContractDocument[] + extractions ContractExtractionHistory[] + legalCases LegalCase[] + + @@index([userId, status, endDate]) + @@index([sourceProposalId]) + @@index([updatedAt]) +} + +model ContractDeliverable { + id String @id @default(cuid()) + contractId String + title String + dueDate DateTime? + amountLinked Decimal? @db.Decimal(14, 2) + status ContractDeliverableStatus @default(PENDING) + deliveredAt DateTime? + approvedAt DateTime? + notes String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + contract ContractRecord @relation(fields: [contractId], references: [id], onDelete: Cascade) + + @@index([contractId, status, dueDate]) + @@index([dueDate]) +} + +model ContractPayment { + id String @id @default(cuid()) + contractId String + amount Decimal @db.Decimal(14, 2) + paymentDate DateTime + invoiceNumber String? + concept String @default("") + status ContractPaymentStatus @default(REGISTERED) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + contract ContractRecord @relation(fields: [contractId], references: [id], onDelete: Cascade) + + @@index([contractId, paymentDate]) + @@index([status, paymentDate]) +} + +model ContractDocument { + id String @id @default(cuid()) + contractId String + fileName String + filePath String + mimeType String + sizeBytes Int + checksumSha256 String? + kind ContractDocumentKind @default(OTHER) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + contract ContractRecord @relation(fields: [contractId], references: [id], onDelete: Cascade) + + @@index([contractId, kind, createdAt]) +} + +model ContractExtractionHistory { + id String @id @default(cuid()) + contractId String? + userId String + engine String + model String? + resultJson Json + warningsJson Json? + analyzedAt DateTime @default(now()) + createdAt DateTime @default(now()) + contract ContractRecord? @relation(fields: [contractId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, analyzedAt]) + @@index([contractId, analyzedAt]) +} + +model LegalCase { + id String @id @default(cuid()) + userId String + contractId String? + caseType LegalCaseType + severity LegalCaseSeverity + counterparty String + description String + amountAtRisk Decimal? @db.Decimal(14, 2) + status LegalCaseStatus @default(OPEN) + openedAt DateTime @default(now()) + resolvedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + contract ContractRecord? @relation(fields: [contractId], references: [id], onDelete: SetNull) + diagnoses LegalDiagnosis[] + escalationLogs LegalEscalationStepLog[] + documents LegalDocument[] + + @@index([userId, status, severity]) + @@index([contractId]) +} + +model LegalDiagnosis { + id String @id @default(cuid()) + userId String + legalCaseId String? + stepIndex Int @default(1) + totalSteps Int @default(4) + answersJson Json + recommendedRouteJson Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + legalCase LegalCase? @relation(fields: [legalCaseId], references: [id], onDelete: SetNull) + + @@index([userId, updatedAt]) + @@index([legalCaseId]) +} + +model LegalEscalationStepLog { + id String @id @default(cuid()) + legalCaseId String + routeStepKey String + completedAt DateTime? + notes String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + legalCase LegalCase @relation(fields: [legalCaseId], references: [id], onDelete: Cascade) + + @@unique([legalCaseId, routeStepKey]) + @@index([legalCaseId, completedAt]) +} + +model LegalDocument { + id String @id @default(cuid()) + legalCaseId String? + userId String + templateKey String? + aiGenerated Boolean @default(false) + title String + content String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + legalCase LegalCase? @relation(fields: [legalCaseId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@index([legalCaseId, createdAt]) +} + +model LegalDirectoryEntity { + id String @id @default(cuid()) + jurisdictionLevel LegalJurisdictionLevel + name String + scopeTagsJson Json + websiteUrl String? + phone String? + email String? + stateCode String? + municipalityCode String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([jurisdictionLevel, isActive]) + @@index([stateCode, municipalityCode, isActive]) +} + +model AuditSimulation { + id String @id @default(cuid()) + userId String + name String + auditType String + status AuditSimulationStatus @default(DRAFT) + overallScore Int? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + sections AuditSimulationSection[] + responses AuditChecklistResponse[] + + @@index([userId, status, updatedAt]) +} + +model AuditSimulationSection { + id String @id @default(cuid()) + simulationId String + key String + score Int? + status AuditSimulationSectionStatus @default(READY) + findingsJson Json? + recommendationsJson Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + simulation AuditSimulation @relation(fields: [simulationId], references: [id], onDelete: Cascade) + + @@unique([simulationId, key]) + @@index([status]) +} + +model AuditChecklistResponse { + id String @id @default(cuid()) + simulationId String + questionKey String + answer String + evidenceRefsJson Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + simulation AuditSimulation @relation(fields: [simulationId], references: [id], onDelete: Cascade) + + @@index([simulationId, questionKey]) +} + +model InstitutionalDossierSnapshot { + id String @id @default(cuid()) + userId String + generatedAt DateTime @default(now()) + payloadJson Json + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, generatedAt]) +} + +model ProposalDocument { + id String @id @default(cuid()) + proposalId String + userId String + fileName String + storedFileName String + filePath String + mimeType String + sizeBytes Int + checksumSha256 String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + proposal Proposal @relation(fields: [proposalId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([proposalId, createdAt]) + @@index([userId, createdAt]) +} + +model OfficialNormativeSource { + id String @id + stateCode String + stateName String + municipalityCode String? + municipalityName String? + authorityName String + title String + officialUrl String + sourceType OfficialNormativeSourceType + versionLabel String? + isPilot Boolean @default(false) + lastKnownHash String? + lastVerifiedAt DateTime? + nextCheckAt DateTime? + lastStatus NormativeVerificationStatus? + lastMessage String? + lastChangedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([stateCode, municipalityCode, isPilot]) + @@index([nextCheckAt]) + @@index([lastStatus, lastVerifiedAt]) +} + +model OfficialNormativeSuggestion { + id String @id @default(cuid()) + stateCode String + municipalityCode String? + authorityName String + title String + officialUrl String + sourceType OfficialNormativeSourceType + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([stateCode, municipalityCode, createdAt]) +} + +model AiSuggestion { + id String @id @default(cuid()) + userId String + moduleKey String + featureKey String + subjectType String + subjectId String + inputHash String + requestJson Json + responseJson Json + confidence Float? + engine String + model String? + usageJson Json? + warningsJson Json? + promptVersion String + status AiSuggestionStatus @default(GENERATED) + actedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, moduleKey, featureKey, subjectType, subjectId, inputHash], map: "ai_suggestion_dedupe") + @@index([userId, moduleKey, featureKey, createdAt]) + @@index([status, updatedAt]) + @@index([inputHash]) +} diff --git a/prisma/seed.mjs b/prisma/seed.mjs index ee9ee53..1514bc7 100644 --- a/prisma/seed.mjs +++ b/prisma/seed.mjs @@ -1,4 +1,4 @@ -import { PrismaClient, ContentPageType, OverallScoreMethod, PriorityLevel } from "@prisma/client"; +import { PrismaClient, ContentPageType, LegalJurisdictionLevel, OverallScoreMethod, PriorityLevel } from "@prisma/client"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -389,6 +389,99 @@ const contentPageSeeds = [ }, ]; +const legalDirectorySeeds = [ + { + jurisdictionLevel: LegalJurisdictionLevel.FEDERAL, + name: "Secretaria de la Funcion Publica (SFP)", + scopeTagsJson: ["inconformidades", "sanciones", "organo-interno-control"], + websiteUrl: "https://www.gob.mx/sfp", + phone: null, + email: null, + stateCode: null, + municipalityCode: null, + }, + { + jurisdictionLevel: LegalJurisdictionLevel.FEDERAL, + name: "Tribunal Federal de Justicia Administrativa (TFJA)", + scopeTagsJson: ["juicio-contencioso", "nulidad", "sanciones-administrativas"], + websiteUrl: "https://www.tfja.gob.mx", + phone: null, + email: null, + stateCode: null, + municipalityCode: null, + }, + { + jurisdictionLevel: LegalJurisdictionLevel.FEDERAL, + name: "Auditoria Superior de la Federacion (ASF)", + scopeTagsJson: ["auditoria", "fiscalizacion", "cuenta-publica"], + websiteUrl: "https://www.asf.gob.mx", + phone: null, + email: null, + stateCode: null, + municipalityCode: null, + }, + { + jurisdictionLevel: LegalJurisdictionLevel.FEDERAL, + name: "CompraNet", + scopeTagsJson: ["contrataciones", "licitaciones", "expedientes"], + websiteUrl: "https://compranet.hacienda.gob.mx", + phone: null, + email: null, + stateCode: null, + municipalityCode: null, + }, + { + jurisdictionLevel: LegalJurisdictionLevel.FEDERAL, + name: "Sistema Nacional Anticorrupcion", + scopeTagsJson: ["anticorrupcion", "denuncias", "integridad"], + websiteUrl: "https://www.sna.org.mx", + phone: null, + email: null, + stateCode: null, + municipalityCode: null, + }, + { + jurisdictionLevel: LegalJurisdictionLevel.STATE, + name: "Secretaria de la Contraloria y Transparencia Gubernamental de Nuevo Leon", + scopeTagsJson: ["inconformidades", "contraloria", "responsabilidades"], + websiteUrl: "https://www.nl.gob.mx/dependencias/contraloria", + phone: null, + email: null, + stateCode: "NL", + municipalityCode: null, + }, + { + jurisdictionLevel: LegalJurisdictionLevel.STATE, + name: "Tribunal de Justicia Administrativa de Nuevo Leon", + scopeTagsJson: ["juicio-administrativo", "sanciones", "nulidad"], + websiteUrl: "https://tjanl.gob.mx", + phone: null, + email: null, + stateCode: "NL", + municipalityCode: null, + }, + { + jurisdictionLevel: LegalJurisdictionLevel.STATE, + name: "Auditoria Superior del Estado de Nuevo Leon", + scopeTagsJson: ["auditoria", "fiscalizacion", "cuenta-publica-estatal"], + websiteUrl: "https://www.asenl.gob.mx", + phone: null, + email: null, + stateCode: "NL", + municipalityCode: null, + }, + { + jurisdictionLevel: LegalJurisdictionLevel.STATE, + name: "Sistema Estatal Anticorrupcion de Nuevo Leon", + scopeTagsJson: ["anticorrupcion", "politica-estatal", "coordinacion"], + websiteUrl: "https://www.seanl.mx", + phone: null, + email: null, + stateCode: "NL", + municipalityCode: null, + }, +]; + async function upsertDiagnosticStructure() { const moduleKeys = moduleSeeds.map((moduleSeed) => moduleSeed.key); @@ -605,6 +698,48 @@ async function upsertDefaultScoringConfig() { }); } +async function upsertLegalDirectory() { + for (const item of legalDirectorySeeds) { + const existing = await prisma.legalDirectoryEntity.findFirst({ + where: { + jurisdictionLevel: item.jurisdictionLevel, + name: item.name, + stateCode: item.stateCode, + municipalityCode: item.municipalityCode, + }, + select: { id: true }, + }); + + if (existing) { + await prisma.legalDirectoryEntity.update({ + where: { id: existing.id }, + data: { + scopeTagsJson: item.scopeTagsJson, + websiteUrl: item.websiteUrl, + phone: item.phone, + email: item.email, + isActive: true, + }, + }); + continue; + } + + await prisma.legalDirectoryEntity.create({ + data: { + jurisdictionLevel: item.jurisdictionLevel, + name: item.name, + scopeTagsJson: item.scopeTagsJson, + websiteUrl: item.websiteUrl, + phone: item.phone, + email: item.email, + stateCode: item.stateCode, + municipalityCode: item.municipalityCode, + isActive: true, + }, + }); + } +} + async function loadMunicipalitySeeds() { const filePath = path.join(__dirname, "data", "municipalities.json"); const content = await readFile(filePath, "utf-8"); @@ -697,6 +832,7 @@ async function main() { await upsertRecommendations(); await upsertContentPages(); await upsertDefaultScoringConfig(); + await upsertLegalDirectory(); const municipalitySeedCount = await upsertMunicipalities(); const moduleCount = await prisma.diagnosticModule.count(); @@ -705,6 +841,7 @@ async function main() { const workshopCount = await prisma.developmentWorkshop.count(); const recommendationCount = await prisma.recommendation.count(); const contentPageCount = await prisma.contentPage.count(); + const legalDirectoryCount = await prisma.legalDirectoryEntity.count({ where: { isActive: true } }); const municipalityCount = await prisma.municipality.count({ where: { isActive: true } }); console.log("Seed completed", { @@ -714,6 +851,7 @@ async function main() { workshops: workshopCount, recommendations: recommendationCount, contentPages: contentPageCount, + legalDirectory: legalDirectoryCount, municipalities: municipalityCount, municipalitySeedsProcessed: municipalitySeedCount, }); diff --git a/scripts/backfill-licitaya-history.mjs b/scripts/backfill-licitaya-history.mjs new file mode 100644 index 0000000..e3bfa4e --- /dev/null +++ b/scripts/backfill-licitaya-history.mjs @@ -0,0 +1,578 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { PrismaClient, LicitationCategory, LicitationProcedureType, LicitationSource, MunicipalOpenPortalType } from "@prisma/client"; + +const DEFAULT_ITEMS = 50; +const DEFAULT_MAX_PAGES = 5; +const DEFAULT_DAYS = 30; +const DEFAULT_DELAY_MS = 400; +const LICITAYA_MUNICIPALITY_CODE = "LICITAYA"; + +const LICITAYA_STATE_MAP = { + AGS: { stateCode: "1", stateName: "Aguascalientes" }, + BCN: { stateCode: "2", stateName: "Baja California" }, + BCS: { stateCode: "3", stateName: "Baja California Sur" }, + CAM: { stateCode: "4", stateName: "Campeche" }, + CHP: { stateCode: "5", stateName: "Chiapas" }, + CHH: { stateCode: "6", stateName: "Chihuahua" }, + COA: { stateCode: "7", stateName: "Coahuila" }, + COL: { stateCode: "8", stateName: "Colima" }, + CMX: { stateCode: "9", stateName: "Ciudad de Mexico" }, + CDMX: { stateCode: "9", stateName: "Ciudad de Mexico" }, + DUR: { stateCode: "10", stateName: "Durango" }, + GUA: { stateCode: "11", stateName: "Guanajuato" }, + GRO: { stateCode: "12", stateName: "Guerrero" }, + HID: { stateCode: "13", stateName: "Hidalgo" }, + JAL: { stateCode: "14", stateName: "Jalisco" }, + MEX: { stateCode: "15", stateName: "Estado de Mexico" }, + MIC: { stateCode: "16", stateName: "Michoacan" }, + MOR: { stateCode: "17", stateName: "Morelos" }, + NAY: { stateCode: "18", stateName: "Nayarit" }, + NLE: { stateCode: "19", stateName: "Nuevo Leon" }, + OAX: { stateCode: "20", stateName: "Oaxaca" }, + PUE: { stateCode: "21", stateName: "Puebla" }, + QUE: { stateCode: "22", stateName: "Queretaro" }, + ROO: { stateCode: "23", stateName: "Quintana Roo" }, + SLP: { stateCode: "24", stateName: "San Luis Potosi" }, + SIN: { stateCode: "25", stateName: "Sinaloa" }, + SON: { stateCode: "26", stateName: "Sonora" }, + TAB: { stateCode: "27", stateName: "Tabasco" }, + TAM: { stateCode: "28", stateName: "Tamaulipas" }, + TLA: { stateCode: "29", stateName: "Tlaxcala" }, + VER: { stateCode: "30", stateName: "Veracruz" }, + YUC: { stateCode: "31", stateName: "Yucatan" }, + ZAC: { stateCode: "32", stateName: "Zacatecas" }, +}; + +function loadDotenv(filePath) { + if (!existsSync(filePath)) { + return; + } + + const raw = readFileSync(filePath, "utf8"); + + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + + const eqIndex = trimmed.indexOf("="); + if (eqIndex === -1) { + continue; + } + + const key = trimmed.slice(0, eqIndex).trim(); + + if (!key || process.env[key] !== undefined) { + continue; + } + + let value = trimmed.slice(eqIndex + 1).trim(); + + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + process.env[key] = value; + } +} + +function parseInteger(value, fallback) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function parseArgs(argv) { + const args = { + days: parseInteger(process.env.LICITAYA_BACKFILL_DAYS, DEFAULT_DAYS), + items: parseInteger(process.env.LICITAYA_BACKFILL_ITEMS, DEFAULT_ITEMS), + maxPages: parseInteger(process.env.LICITAYA_BACKFILL_MAX_PAGES, DEFAULT_MAX_PAGES), + delayMs: parseInteger(process.env.LICITAYA_BACKFILL_DELAY_MS, DEFAULT_DELAY_MS), + from: process.env.LICITAYA_BACKFILL_FROM || null, + to: process.env.LICITAYA_BACKFILL_TO || null, + dryRun: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const current = argv[index]; + const next = argv[index + 1]; + + if (current === "--days" && next) { + args.days = parseInteger(next, args.days); + index += 1; + continue; + } + + if (current === "--items" && next) { + args.items = parseInteger(next, args.items); + index += 1; + continue; + } + + if (current === "--max-pages" && next) { + args.maxPages = parseInteger(next, args.maxPages); + index += 1; + continue; + } + + if (current === "--delay-ms" && next) { + args.delayMs = parseInteger(next, args.delayMs); + index += 1; + continue; + } + + if (current === "--from" && next) { + args.from = next; + index += 1; + continue; + } + + if (current === "--to" && next) { + args.to = next; + index += 1; + continue; + } + + if (current === "--dry-run") { + args.dryRun = true; + continue; + } + } + + args.days = Math.max(1, args.days); + args.items = Math.min(100, Math.max(1, args.items)); + args.maxPages = Math.max(1, args.maxPages); + args.delayMs = Math.max(0, args.delayMs); + + return args; +} + +function parseDateYmd(value) { + if (!value || !/^\d{8}$/.test(value)) { + return null; + } + + const year = Number.parseInt(value.slice(0, 4), 10); + const month = Number.parseInt(value.slice(4, 6), 10); + const day = Number.parseInt(value.slice(6, 8), 10); + const date = new Date(Date.UTC(year, month - 1, day)); + return Number.isNaN(date.getTime()) ? null : date; +} + +function formatDateYmd(date) { + const year = String(date.getUTCFullYear()); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + return `${year}${month}${day}`; +} + +function buildDateRange(args) { + const toDate = parseDateYmd(args.to) ?? new Date(); + + if (args.from) { + const fromDate = parseDateYmd(args.from); + + if (!fromDate) { + throw new Error("Invalid --from date. Use YYYYMMDD."); + } + + const dates = []; + const cursor = new Date(Date.UTC(toDate.getUTCFullYear(), toDate.getUTCMonth(), toDate.getUTCDate())); + const floor = new Date(Date.UTC(fromDate.getUTCFullYear(), fromDate.getUTCMonth(), fromDate.getUTCDate())); + + while (cursor >= floor) { + dates.push(formatDateYmd(cursor)); + cursor.setUTCDate(cursor.getUTCDate() - 1); + } + + return dates; + } + + const dates = []; + const cursor = new Date(Date.UTC(toDate.getUTCFullYear(), toDate.getUTCMonth(), toDate.getUTCDate())); + + for (let i = 0; i < args.days; i += 1) { + dates.push(formatDateYmd(cursor)); + cursor.setUTCDate(cursor.getUTCDate() - 1); + } + + return dates; +} + +function toText(value) { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + return null; +} + +function parseDateMaybe(value) { + const raw = toText(value); + + if (!raw) { + return null; + } + + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function mapProcedureType(value) { + const text = (toText(value) || "").toLowerCase(); + + if (text.includes("adjudicacion directa")) { + return LicitationProcedureType.ADJUDICACION_DIRECTA; + } + + if (text.includes("invitacion restringida") || text.includes("invitacion a cuando menos")) { + return LicitationProcedureType.INVITACION_RESTRINGIDA; + } + + if (text.includes("licitacion publica") || text.includes("licitacion")) { + return LicitationProcedureType.LICITACION_PUBLICA; + } + + return LicitationProcedureType.UNKNOWN; +} + +function mapCategory(title, description) { + const text = `${title || ""} ${description || ""}`.toLowerCase(); + const goods = text.includes("bien") || text.includes("insumo") || text.includes("material"); + const services = text.includes("servicio") || text.includes("consultoria"); + const works = text.includes("obra") || text.includes("construccion") || text.includes("infraestructura"); + const matched = [goods, services, works].filter(Boolean).length; + + if (matched > 1) { + return LicitationCategory.MIXED; + } + + if (goods) { + return LicitationCategory.GOODS; + } + + if (services) { + return LicitationCategory.SERVICES; + } + + if (works) { + return LicitationCategory.WORKS; + } + + return LicitationCategory.UNKNOWN; +} + +function sanitizeDocuments(record) { + const docs = []; + + if (toText(record.url)) { + docs.push({ name: "Ficha LicitaYa", url: String(record.url) }); + } + + if (toText(record.url2)) { + docs.push({ name: "Fuente alterna", url: String(record.url2) }); + } + + const seen = new Set(); + + return docs.filter((doc) => { + if (seen.has(doc.url)) { + return false; + } + + seen.add(doc.url); + return true; + }); +} + +function resolveLicitayaState(rawState, stateNamesByCode) { + const token = (toText(rawState) || "").toUpperCase().replace(/[^A-Z]/g, ""); + const mapped = LICITAYA_STATE_MAP[token] ?? null; + + if (!mapped) { + return { + stateCode: "00", + stateName: "Cobertura nacional", + }; + } + + return { + stateCode: mapped.stateCode, + stateName: stateNamesByCode.get(mapped.stateCode) || mapped.stateName, + }; +} + +async function delay(ms) { + if (ms <= 0) { + return; + } + + await new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + +async function main() { + loadDotenv(resolve(process.cwd(), ".env")); + + const args = parseArgs(process.argv.slice(2)); + const dates = buildDateRange(args); + const apiKey = process.env.LICITAYA_API_KEY?.trim(); + const baseUrl = (process.env.LICITAYA_BASE_URL?.trim() || "https://www.licitaya.com.mx/api/v1").replace(/\/+$/, ""); + + if (!apiKey) { + throw new Error("LICITAYA_API_KEY is required."); + } + + const prisma = new PrismaClient(); + + try { + const stateRows = await prisma.municipality.findMany({ + where: { + isActive: true, + municipalityCode: { + not: LICITAYA_MUNICIPALITY_CODE, + }, + }, + select: { + stateCode: true, + stateName: true, + }, + distinct: ["stateCode"], + }); + + const stateNamesByCode = new Map(stateRows.map((row) => [row.stateCode, row.stateName])); + const municipalityCache = new Map(); + + async function ensureLicitayaMunicipality(stateCode, stateName) { + const key = `${stateCode}|${stateName}`; + + if (municipalityCache.has(key)) { + return municipalityCache.get(key); + } + + const municipality = await prisma.municipality.upsert({ + where: { + stateCode_municipalityCode: { + stateCode, + municipalityCode: LICITAYA_MUNICIPALITY_CODE, + }, + }, + update: { + stateName, + municipalityName: `Cobertura LicitaYa (${stateName})`, + isActive: true, + scrapingEnabled: false, + openPortalType: MunicipalOpenPortalType.GENERIC, + openPortalUrl: null, + backupUrl: null, + pntEntryUrl: null, + pntEntityId: null, + pntSectorId: null, + pntSubjectId: null, + }, + create: { + stateCode, + stateName, + municipalityCode: LICITAYA_MUNICIPALITY_CODE, + municipalityName: `Cobertura LicitaYa (${stateName})`, + openPortalType: MunicipalOpenPortalType.GENERIC, + isActive: true, + scrapingEnabled: false, + }, + select: { + id: true, + }, + }); + + municipalityCache.set(key, municipality); + return municipality; + } + + const summary = { + datesAttempted: dates.length, + pagesAttempted: 0, + emptyDates: 0, + fetched: 0, + inserted: 0, + updated: 0, + skipped: 0, + errors: 0, + warnings: [], + dryRun: args.dryRun, + }; + + for (const date of dates) { + let dateHadResults = false; + + for (let page = 1; page <= args.maxPages; page += 1) { + const requestUrl = new URL("tender/search", `${baseUrl}/`); + requestUrl.searchParams.set("date", date); + requestUrl.searchParams.set("items", String(args.items)); + requestUrl.searchParams.set("page", String(page)); + requestUrl.searchParams.set("order", "1"); + + summary.pagesAttempted += 1; + + const response = await fetch(requestUrl, { + headers: { + "X-API-KEY": apiKey, + Accept: "application/json", + }, + }); + + if (response.status === 404) { + break; + } + + if (!response.ok) { + summary.errors += 1; + summary.warnings.push(`HTTP ${response.status} for ${requestUrl.toString()}`); + break; + } + + const payload = (await response.json().catch(() => null)); + + if (!payload || typeof payload !== "object") { + summary.errors += 1; + summary.warnings.push(`Invalid JSON for ${requestUrl.toString()}`); + break; + } + + const rows = Array.isArray(payload.results) ? payload.results : []; + + if (!rows.length) { + break; + } + + dateHadResults = true; + + for (const row of rows) { + if (!row || typeof row !== "object") { + continue; + } + + const record = row; + const tenderId = toText(record.tenderId); + const title = toText(record.tender_object) || toText(record.expanded_search) || (tenderId ? `Licitacion ${tenderId}` : null); + + if (!tenderId || !title) { + summary.skipped += 1; + continue; + } + + summary.fetched += 1; + + const description = toText(record.expanded_search) || toText(record.extra_info) || null; + const publishDate = parseDateMaybe(record.catalog_date); + const closingDate = parseDateMaybe(record.close_date); + const state = resolveLicitayaState(record.state, stateNamesByCode); + const municipality = await ensureLicitayaMunicipality(state.stateCode, state.stateName); + + if (args.dryRun) { + continue; + } + + const sourceRecordId = tenderId.slice(0, 255); + const existing = await prisma.licitation.findUnique({ + where: { + municipalityId_source_sourceRecordId: { + municipalityId: municipality.id, + source: LicitationSource.LICITAYA, + sourceRecordId, + }, + }, + select: { + id: true, + }, + }); + + await prisma.licitation.upsert({ + where: { + municipalityId_source_sourceRecordId: { + municipalityId: municipality.id, + source: LicitationSource.LICITAYA, + sourceRecordId, + }, + }, + update: { + tenderCode: toText(record.number) || toText(record.number2) || toText(record.reference) || toText(record.process) || null, + procedureType: mapProcedureType(record.type), + title, + description, + category: mapCategory(title, description), + isOpen: closingDate ? closingDate >= new Date() : true, + closingDate, + publishDate, + supplierAwarded: toText(record.agency), + documents: sanitizeDocuments(record), + rawSourceUrl: requestUrl.toString(), + rawPayload: { + source: "licitaya-backfill", + licitayaState: toText(record.state), + licitayaCity: toText(record.city), + record, + }, + lastSeenAt: new Date(), + }, + create: { + municipalityId: municipality.id, + source: LicitationSource.LICITAYA, + sourceRecordId, + tenderCode: toText(record.number) || toText(record.number2) || toText(record.reference) || toText(record.process) || null, + procedureType: mapProcedureType(record.type), + title, + description, + category: mapCategory(title, description), + isOpen: closingDate ? closingDate >= new Date() : true, + closingDate, + publishDate, + supplierAwarded: toText(record.agency), + documents: sanitizeDocuments(record), + rawSourceUrl: requestUrl.toString(), + rawPayload: { + source: "licitaya-backfill", + licitayaState: toText(record.state), + licitayaCity: toText(record.city), + record, + }, + lastSeenAt: new Date(), + }, + }); + + if (existing) { + summary.updated += 1; + } else { + summary.inserted += 1; + } + } + + const totalPages = Number.parseInt(toText(payload.total_pages) || "1", 10); + + if (!Number.isFinite(totalPages) || page >= totalPages || page >= args.maxPages) { + break; + } + + await delay(args.delayMs); + } + + if (!dateHadResults) { + summary.emptyDates += 1; + } + } + + console.log(JSON.stringify(summary, null, 2)); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/test-licitaya-api.mjs b/scripts/test-licitaya-api.mjs index 697ea58..4639620 100644 --- a/scripts/test-licitaya-api.mjs +++ b/scripts/test-licitaya-api.mjs @@ -2,6 +2,25 @@ import { readFileSync, existsSync } from "node:fs"; import { resolve } from "node:path"; const DEFAULT_TIMEOUT_MS = 20000; +const DEFAULT_ENDPOINT = "/tender/SCRZJ"; + +function parseBoolean(value, fallback = false) { + if (!value) { + return fallback; + } + + const normalized = value.trim().toLowerCase(); + + if (["1", "true", "yes", "si"].includes(normalized)) { + return true; + } + + if (["0", "false", "no"].includes(normalized)) { + return false; + } + + return fallback; +} function loadDotenv(filePath) { if (!existsSync(filePath)) { @@ -40,10 +59,11 @@ function loadDotenv(filePath) { function parseArgs(argv) { const args = { baseUrl: process.env.LICITAYA_BASE_URL, - endpoint: process.env.LICITAYA_TEST_ENDPOINT, + endpoint: process.env.LICITAYA_TEST_ENDPOINT || DEFAULT_ENDPOINT, accept: process.env.LICITAYA_ACCEPT || "application/json", method: "GET", timeoutMs: Number.parseInt(process.env.LICITAYA_TIMEOUT_MS || "", 10) || DEFAULT_TIMEOUT_MS, + allowEmptySearch: parseBoolean(process.env.LICITAYA_ALLOW_EMPTY_SEARCH, true), }; for (let i = 0; i < argv.length; i += 1) { @@ -82,6 +102,11 @@ function parseArgs(argv) { i += 1; continue; } + + if (current === "--allow-empty-search") { + args.allowEmptySearch = true; + continue; + } } return args; @@ -176,10 +201,17 @@ try { console.log("--- Response Preview ---"); console.log(bodyPreview); - if (response.status === 404 && url.pathname.endsWith("/tender/search")) { + const isEmptySearch404 = response.status === 404 && url.pathname.endsWith("/tender/search"); + + if (isEmptySearch404) { console.error("No tenders matched the current filters. Try a broader keyword or fewer filters."); } + if (isEmptySearch404 && args.allowEmptySearch) { + console.log("Connectivity check passed (search endpoint returned 404 with no matches)."); + process.exit(0); + } + if (!response.ok) { process.exit(1); } diff --git a/src/app/api/admin/sync/route.ts b/src/app/api/admin/sync/route.ts index a3e1888..6d0231b 100644 --- a/src/app/api/admin/sync/route.ts +++ b/src/app/api/admin/sync/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { requireAdminApiUser } from "@/lib/auth/admin"; +import { runPeriodicNormativeVerification } from "@/lib/compliance/regulations"; import { runDailyLicitationsSync } from "@/lib/licitations/sync"; export const runtime = "nodejs"; @@ -18,6 +19,8 @@ export async function POST(request: Request) { skip?: number; targetYear?: number; includePnt?: boolean; + includeLicitaya?: boolean; + includeRegulations?: boolean; force?: boolean; }; @@ -26,11 +29,23 @@ export async function POST(request: Request) { limit: typeof body.limit === "number" ? body.limit : undefined, skip: typeof body.skip === "number" ? body.skip : undefined, targetYear: typeof body.targetYear === "number" ? body.targetYear : undefined, - includePnt: body.includePnt === true, - force: body.force === true, + includePnt: typeof body.includePnt === "boolean" ? body.includePnt : undefined, + includeLicitaya: typeof body.includeLicitaya === "boolean" ? body.includeLicitaya : undefined, + force: typeof body.force === "boolean" ? body.force : undefined, }); + const includeRegulations = body.includeRegulations !== false; + let regulations = null; + let regulationsError: string | null = null; - return NextResponse.json({ ok: true, payload }); + if (includeRegulations) { + try { + regulations = await runPeriodicNormativeVerification(new Date()); + } catch (error) { + regulationsError = error instanceof Error ? error.message : "No fue posible verificar reglamentos oficiales."; + } + } + + return NextResponse.json({ ok: true, payload, regulations, regulationsError }); } catch (error) { return NextResponse.json( { diff --git a/src/app/api/ai/suggestions/[id]/decision/route.ts b/src/app/api/ai/suggestions/[id]/decision/route.ts new file mode 100644 index 0000000..7a95e0a --- /dev/null +++ b/src/app/api/ai/suggestions/[id]/decision/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { getSessionPayload } from "@/lib/auth/session"; +import { applyAiSuggestionDecision } from "@/lib/ai/suggestions"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +function parseDecision(value: unknown) { + return value === "accept" || value === "dismiss" ? value : null; +} + +export async function POST(request: Request, context: RouteContext) { + const session = await getSessionPayload(); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await context.params; + const body = (await request.json().catch(() => ({}))) as Record; + const decision = parseDecision(body.decision); + + if (!id || !decision) { + return NextResponse.json({ error: "Decision invalida." }, { status: 400 }); + } + + const updated = await applyAiSuggestionDecision(session.userId, id, decision); + + if (!updated) { + return NextResponse.json({ error: "Sugerencia no encontrada." }, { status: 404 }); + } + + return NextResponse.json({ ok: true, suggestion: updated }); +} diff --git a/src/app/api/audits/ai/findings/route.ts b/src/app/api/audits/ai/findings/route.ts new file mode 100644 index 0000000..3db51a3 --- /dev/null +++ b/src/app/api/audits/ai/findings/route.ts @@ -0,0 +1,188 @@ +import { z } from "zod"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { callOpenAiJsonSchema } from "@/lib/ai/openai"; +import { storeAiSuggestionFromEnvelope } from "@/lib/ai/suggestions"; +import { buildInstitutionalDossier, getAuditSimulationDetail, getLatestInstitutionalDossierSnapshot, listAuditSimulationsForUser } from "@/lib/audits/server"; + +const M10_PROMPT_VERSION = "m10_findings_v1"; + +const M10FindingsSchema = z.object({ + auditorLikelyFindings: z + .array( + z.object({ + area: z.string().min(4).max(120), + finding: z.string().min(8).max(500), + severity: z.enum(["alto", "medio", "bajo"]), + }), + ) + .max(20), + missingEvidence: z.array(z.string().min(6).max(280)).max(20), + topRisks: z.array(z.string().min(6).max(280)).max(12), + remediationPlan: z + .array( + z.object({ + step: z.string().min(8).max(420), + priority: z.enum(["alta", "media", "baja"]), + ownerSuggestion: z.string().min(3).max(120), + targetDate: z.string().min(4).max(40), + }), + ) + .max(20), + confidence: z.enum(["low", "medium", "high"]), +}); + +const M10FindingsJsonSchema = { + type: "object", + additionalProperties: false, + required: ["auditorLikelyFindings", "missingEvidence", "topRisks", "remediationPlan", "confidence"], + properties: { + auditorLikelyFindings: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["area", "finding", "severity"], + properties: { + area: { type: "string" }, + finding: { type: "string" }, + severity: { type: "string", enum: ["alto", "medio", "bajo"] }, + }, + }, + }, + missingEvidence: { + type: "array", + items: { type: "string" }, + }, + topRisks: { + type: "array", + items: { type: "string" }, + }, + remediationPlan: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["step", "priority", "ownerSuggestion", "targetDate"], + properties: { + step: { type: "string" }, + priority: { type: "string", enum: ["alta", "media", "baja"] }, + ownerSuggestion: { type: "string" }, + targetDate: { type: "string" }, + }, + }, + }, + confidence: { type: "string", enum: ["low", "medium", "high"] }, + }, +} as const; + +function parseString(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +async function resolveSimulation(userId: string, simulationId: string) { + if (simulationId) { + return getAuditSimulationDetail(userId, simulationId); + } + + const simulations = await listAuditSimulationsForUser(userId); + return simulations.find((simulation) => simulation.status === "COMPLETED") ?? simulations[0] ?? null; +} + +export async function POST(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json().catch(() => ({}))) as Record; + const simulationId = parseString(body.simulationId); + const simulation = await resolveSimulation(user.id, simulationId); + + if (!simulation) { + return NextResponse.json({ error: "No hay simulaciones disponibles para generar dictamen IA." }, { status: 400 }); + } + + const latestDossier = await getLatestInstitutionalDossierSnapshot(user.id); + const dossier = latestDossier?.payload ?? (await buildInstitutionalDossier(user.id)); + + const condensedContext = { + simulation: { + id: simulation.id, + name: simulation.name, + auditType: simulation.auditType, + status: simulation.status, + overallScore: simulation.overallScore, + completedAt: simulation.completedAt, + sections: simulation.sections, + }, + dossier: { + generatedAt: dossier.generatedAt, + summary: dossier.summary, + components: dossier.components, + }, + }; + + const systemPrompt = [ + "Eres auditor preventivo especializado en contratacion publica y cumplimiento documental en Mexico.", + "Simula hallazgos probables de una revision formal sin modificar resultados deterministas existentes.", + "Debes responder solo JSON valido en espanol.", + ].join(" "); + + const userPrompt = [ + "Contexto actual de simulacion y expediente institucional:", + JSON.stringify(condensedContext), + "", + "Genera:", + "- auditorLikelyFindings: hallazgos probables por area y severidad.", + "- missingEvidence: evidencia concreta faltante.", + "- topRisks: riesgos principales priorizados.", + "- remediationPlan: plan de remediacion con prioridad, responsable sugerido y fecha objetivo.", + ].join("\n"); + + const envelope = await callOpenAiJsonSchema({ + promptVersion: M10_PROMPT_VERSION, + systemPrompt, + userPrompt, + outputSchema: M10FindingsSchema, + schemaName: "m10_audit_findings", + jsonSchema: M10FindingsJsonSchema as unknown as Record, + model: process.env.OPENAI_M10_MODEL?.trim() || undefined, + }); + + const payload = + envelope.data ?? + ({ + auditorLikelyFindings: [], + missingEvidence: [], + topRisks: [], + remediationPlan: [], + confidence: envelope.confidence ?? "low", + } satisfies z.infer); + + const persisted = await storeAiSuggestionFromEnvelope({ + userId: user.id, + moduleKey: "M10", + featureKey: "audit_findings", + subjectType: "audit_simulation", + subjectId: simulation.id, + inputForHash: condensedContext, + envelope, + responsePayload: payload, + }); + + return NextResponse.json({ + ok: true, + simulationId: simulation.id, + ...payload, + suggestionId: persisted.suggestionId, + meta: { + engine: envelope.engine, + model: envelope.model, + usage: envelope.usage, + warnings: envelope.warnings, + confidence: envelope.confidence, + }, + }); +} diff --git a/src/app/api/audits/expediente/refresh/route.ts b/src/app/api/audits/expediente/refresh/route.ts new file mode 100644 index 0000000..03f7683 --- /dev/null +++ b/src/app/api/audits/expediente/refresh/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { refreshInstitutionalDossierSnapshot } from "@/lib/audits/server"; + +export async function POST() { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const snapshot = await refreshInstitutionalDossierSnapshot(user.id); + + return NextResponse.json({ + ok: true, + snapshotId: snapshot.id, + generatedAt: snapshot.generatedAt, + dossier: snapshot.payload, + freshness: snapshot.freshness, + }); +} diff --git a/src/app/api/audits/expediente/route.ts b/src/app/api/audits/expediente/route.ts new file mode 100644 index 0000000..251faad --- /dev/null +++ b/src/app/api/audits/expediente/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { getInstitutionalDossierForUser } from "@/lib/audits/server"; + +function parseStrategy(value: string | null) { + return value === "snapshot" ? "snapshot" : "live"; +} + +export async function GET(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const strategy = parseStrategy(searchParams.get("strategy")); + const resolved = await getInstitutionalDossierForUser(user.id, strategy); + + return NextResponse.json({ + ok: true, + dossier: resolved.dossier, + freshness: resolved.freshness, + }); +} diff --git a/src/app/api/audits/simulations/[id]/route.ts b/src/app/api/audits/simulations/[id]/route.ts new file mode 100644 index 0000000..304736c --- /dev/null +++ b/src/app/api/audits/simulations/[id]/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { getAuditSimulationDetail } from "@/lib/audits/server"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +export async function GET(_request: Request, context: RouteContext) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await context.params; + const simulation = await getAuditSimulationDetail(user.id, id); + + if (!simulation) { + return NextResponse.json({ error: "Simulacion no encontrada." }, { status: 404 }); + } + + return NextResponse.json({ ok: true, simulation }); +} diff --git a/src/app/api/audits/simulations/[id]/score/route.ts b/src/app/api/audits/simulations/[id]/score/route.ts new file mode 100644 index 0000000..8deaa02 --- /dev/null +++ b/src/app/api/audits/simulations/[id]/score/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { AuditSimulationScoreInputSchema } from "@/lib/audits/types"; +import { scoreAuditSimulationForUser } from "@/lib/audits/server"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +function parseJsonBody(body: unknown) { + return typeof body === "object" && body !== null ? (body as Record) : {}; +} + +export async function POST(request: Request, context: RouteContext) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await context.params; + const body = parseJsonBody(await request.json().catch(() => ({}))); + const parsed = AuditSimulationScoreInputSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Payload de scoring invalido." }, { status: 400 }); + } + + const simulation = await scoreAuditSimulationForUser(user.id, id, parsed.data); + + if (!simulation) { + return NextResponse.json({ error: "Simulacion no encontrada." }, { status: 404 }); + } + + return NextResponse.json({ ok: true, simulation }); +} diff --git a/src/app/api/audits/simulations/route.ts b/src/app/api/audits/simulations/route.ts new file mode 100644 index 0000000..50063e3 --- /dev/null +++ b/src/app/api/audits/simulations/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { + AuditSimulationCreateInputSchema, +} from "@/lib/audits/types"; +import { createAuditSimulationForUser, getAuditKpisForUser, listAuditSimulationsForUser } from "@/lib/audits/server"; + +function parseJsonBody(body: unknown) { + return typeof body === "object" && body !== null ? (body as Record) : {}; +} + +export async function GET() { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [simulations, kpis] = await Promise.all([listAuditSimulationsForUser(user.id), getAuditKpisForUser(user.id)]); + return NextResponse.json({ ok: true, simulations, kpis }); +} + +export async function POST(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = parseJsonBody(await request.json().catch(() => ({}))); + const parsed = AuditSimulationCreateInputSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Datos de simulacion invalidos." }, { status: 400 }); + } + + const simulation = await createAuditSimulationForUser(user.id, parsed.data); + return NextResponse.json({ ok: true, simulation }); +} diff --git a/src/app/api/compliance/m7/ai/playbook/route.ts b/src/app/api/compliance/m7/ai/playbook/route.ts new file mode 100644 index 0000000..7470999 --- /dev/null +++ b/src/app/api/compliance/m7/ai/playbook/route.ts @@ -0,0 +1,169 @@ +import { z } from "zod"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { callOpenAiJsonSchema } from "@/lib/ai/openai"; +import { storeAiSuggestionFromEnvelope } from "@/lib/ai/suggestions"; +import { getM7DatasetForUser } from "@/lib/compliance/server"; +import type { M7Dataset } from "@/lib/compliance/types"; + +const M7_PROMPT_VERSION = "m7_playbook_v1"; + +const M7PlaybookSchema = z.object({ + predictedIncidents: z + .array( + z.object({ + title: z.string().min(8).max(220), + likelihood: z.enum(["alta", "media", "baja"]), + impact: z.enum(["alto", "medio", "bajo"]), + timeHorizon: z.string().min(4).max(80), + }), + ) + .max(12), + priorityOrder: z.array(z.string().min(8).max(220)).max(12), + preventiveActions: z + .array( + z.object({ + action: z.string().min(8).max(400), + ownerSuggestion: z.string().min(3).max(120), + targetDate: z.string().min(4).max(40), + }), + ) + .max(20), + escalationAdvice: z.array(z.string().min(8).max(420)).max(8), + confidence: z.enum(["low", "medium", "high"]), +}); + +const M7PlaybookJsonSchema = { + type: "object", + additionalProperties: false, + required: ["predictedIncidents", "priorityOrder", "preventiveActions", "escalationAdvice", "confidence"], + properties: { + predictedIncidents: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["title", "likelihood", "impact", "timeHorizon"], + properties: { + title: { type: "string" }, + likelihood: { type: "string", enum: ["alta", "media", "baja"] }, + impact: { type: "string", enum: ["alto", "medio", "bajo"] }, + timeHorizon: { type: "string" }, + }, + }, + }, + priorityOrder: { + type: "array", + items: { type: "string" }, + }, + preventiveActions: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["action", "ownerSuggestion", "targetDate"], + properties: { + action: { type: "string" }, + ownerSuggestion: { type: "string" }, + targetDate: { type: "string" }, + }, + }, + }, + escalationAdvice: { + type: "array", + items: { type: "string" }, + }, + confidence: { type: "string", enum: ["low", "medium", "high"] }, + }, +} as const; + +function parseDataset(value: unknown): M7Dataset | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + return value as M7Dataset; +} + +export async function POST(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json().catch(() => ({}))) as Record; + const providedDataset = parseDataset(body.dataset); + const dataset = providedDataset ?? (await getM7DatasetForUser(user.id)); + + const condensedDataset = { + generatedAt: dataset.generatedAt, + kpis: dataset.kpis, + m3States: dataset.m3States, + deadlines: dataset.tabs.plazos.slice(0, 25), + alerts: dataset.tabs.alertas.slice(0, 40), + checklist: dataset.tabs.checklist.slice(0, 25), + }; + + const systemPrompt = [ + "Eres un especialista en cumplimiento para contratacion publica en Mexico.", + "Construye un playbook preventivo con enfoque operativo para los proximos 30 dias.", + "No cambies severidades ni estados existentes: solo sugiere acciones.", + "Responde solo JSON valido en espanol.", + ].join(" "); + + const userPrompt = [ + "Dataset actual de M7:", + JSON.stringify(condensedDataset), + "", + "Genera:", + "- predictedIncidents: incidentes probables (sin inventar datos externos).", + "- priorityOrder: orden de atencion recomendado.", + "- preventiveActions: acciones con ownerSuggestion y targetDate.", + "- escalationAdvice: criterios breves para escalar a legal/direccion.", + ].join("\n"); + + const envelope = await callOpenAiJsonSchema({ + promptVersion: M7_PROMPT_VERSION, + systemPrompt, + userPrompt, + outputSchema: M7PlaybookSchema, + schemaName: "m7_playbook", + jsonSchema: M7PlaybookJsonSchema as unknown as Record, + model: process.env.OPENAI_M7_MODEL?.trim() || undefined, + }); + + const payload = + envelope.data ?? + ({ + predictedIncidents: [], + priorityOrder: [], + preventiveActions: [], + escalationAdvice: [], + confidence: envelope.confidence ?? "low", + } satisfies z.infer); + + const persisted = await storeAiSuggestionFromEnvelope({ + userId: user.id, + moduleKey: "M7", + featureKey: "compliance_playbook", + subjectType: "m7_dataset", + subjectId: dataset.generatedAt, + inputForHash: condensedDataset, + envelope, + responsePayload: payload, + }); + + return NextResponse.json({ + ok: true, + ...payload, + suggestionId: persisted.suggestionId, + meta: { + engine: envelope.engine, + model: envelope.model, + usage: envelope.usage, + warnings: envelope.warnings, + confidence: envelope.confidence, + }, + }); +} diff --git a/src/app/api/compliance/m7/route.ts b/src/app/api/compliance/m7/route.ts new file mode 100644 index 0000000..8f8d00a --- /dev/null +++ b/src/app/api/compliance/m7/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { getM7DatasetForUser } from "@/lib/compliance/server"; + +export async function GET() { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const dataset = await getM7DatasetForUser(user.id); + return NextResponse.json({ ok: true, dataset }); +} diff --git a/src/app/api/contracts/[id]/deliverables/route.ts b/src/app/api/contracts/[id]/deliverables/route.ts new file mode 100644 index 0000000..8484778 --- /dev/null +++ b/src/app/api/contracts/[id]/deliverables/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { DeliverableCreateInputSchema } from "@/lib/contracts/types"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +function parseJsonBody(body: unknown) { + return typeof body === "object" && body !== null ? (body as Record) : {}; +} + +export async function POST(request: Request, context: RouteContext) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id: contractId } = await context.params; + + try { + const body = parseJsonBody(await request.json().catch(() => ({}))); + const parsed = DeliverableCreateInputSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Datos de entregable invalidos." }, { status: 400 }); + } + + const contract = await prisma.contractRecord.findFirst({ + where: { + id: contractId, + userId: user.id, + }, + select: { + id: true, + }, + }); + + if (!contract) { + return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 }); + } + + const input = parsed.data; + + const deliverable = await prisma.contractDeliverable.create({ + data: { + contractId, + title: input.title, + dueDate: input.dueDate ? new Date(input.dueDate) : null, + amountLinked: input.amountLinked ?? null, + notes: input.notes ?? "", + status: "PENDING", + }, + }); + + return NextResponse.json({ + ok: true, + deliverable: { + id: deliverable.id, + contractId: deliverable.contractId, + title: deliverable.title, + dueDate: deliverable.dueDate ? deliverable.dueDate.toISOString() : null, + amountLinked: deliverable.amountLinked ? Number(deliverable.amountLinked) : null, + status: deliverable.status, + deliveredAt: deliverable.deliveredAt ? deliverable.deliveredAt.toISOString() : null, + approvedAt: deliverable.approvedAt ? deliverable.approvedAt.toISOString() : null, + notes: deliverable.notes, + }, + }); + } catch { + return NextResponse.json({ error: "No fue posible crear el entregable." }, { status: 400 }); + } +} diff --git a/src/app/api/contracts/[id]/documents/[documentId]/route.test.ts b/src/app/api/contracts/[id]/documents/[documentId]/route.test.ts new file mode 100644 index 0000000..edc2a6c --- /dev/null +++ b/src/app/api/contracts/[id]/documents/[documentId]/route.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { requireAdminApiUserMock, readStoredContractDocumentFileMock, prismaMock } = vi.hoisted(() => ({ + requireAdminApiUserMock: vi.fn(), + readStoredContractDocumentFileMock: vi.fn(), + prismaMock: { + contractDocument: { + findFirst: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/admin", () => ({ + requireAdminApiUser: requireAdminApiUserMock, +})); + +vi.mock("@/lib/contracts/storage", async () => { + const actual = await vi.importActual("@/lib/contracts/storage"); + return { + ...actual, + readStoredContractDocumentFile: readStoredContractDocumentFileMock, + }; +}); + +vi.mock("@/lib/prisma", () => ({ + prisma: prismaMock, +})); + +import { GET } from "@/app/api/contracts/[id]/documents/[documentId]/route"; + +describe("contract document download API security", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 404 for non-owned or missing contract document", async () => { + requireAdminApiUserMock.mockResolvedValue({ id: "user-1" }); + prismaMock.contractDocument.findFirst.mockResolvedValue(null); + + const response = await GET(new Request("http://localhost/api/contracts/c1/documents/d1"), { + params: Promise.resolve({ id: "c1", documentId: "d1" }), + }); + const payload = (await response.json()) as { error?: string }; + + expect(response.status).toBe(404); + expect(payload.error).toContain("Documento no encontrado"); + expect(readStoredContractDocumentFileMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when contract storage path is invalid", async () => { + requireAdminApiUserMock.mockResolvedValue({ id: "user-1" }); + prismaMock.contractDocument.findFirst.mockResolvedValue({ + fileName: "contrato.pdf", + filePath: "../../escape.pdf", + mimeType: "application/pdf", + }); + readStoredContractDocumentFileMock.mockRejectedValue(new Error("CONTRACT_DOCUMENT_INVALID_PATH")); + + const response = await GET(new Request("http://localhost/api/contracts/c1/documents/d1"), { + params: Promise.resolve({ id: "c1", documentId: "d1" }), + }); + const payload = (await response.json()) as { error?: string }; + + expect(response.status).toBe(400); + expect(payload.error).toContain("Ruta de almacenamiento invalida"); + }); +}); diff --git a/src/app/api/contracts/[id]/documents/[documentId]/route.ts b/src/app/api/contracts/[id]/documents/[documentId]/route.ts new file mode 100644 index 0000000..235d635 --- /dev/null +++ b/src/app/api/contracts/[id]/documents/[documentId]/route.ts @@ -0,0 +1,150 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { readStoredContractDocumentFile, removeStoredContractDocumentFile } from "@/lib/contracts/storage"; + +export const runtime = "nodejs"; + +function isSchemaNotReadyError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022"); +} + +function safeContentDispositionFilename(value: string) { + const normalized = value.trim().replace(/[\r\n"]/g, "_"); + return normalized || "contrato-documento"; +} + +function mapDocumentReadError(error: unknown) { + if (!(error instanceof Error)) { + return null; + } + + if (error.message === "CONTRACT_DOCUMENT_NOT_FOUND") { + return { status: 404, message: "Archivo no encontrado en almacenamiento." }; + } + + if (error.message === "CONTRACT_DOCUMENT_INVALID_PATH") { + return { status: 400, message: "Ruta de almacenamiento invalida." }; + } + + if (error.message === "CONTRACT_DOCUMENT_EMPTY") { + return { status: 400, message: "Archivo vacio en almacenamiento." }; + } + + if (error.message === "CONTRACT_DOCUMENT_TOO_LARGE") { + return { status: 400, message: "Archivo excede el limite permitido." }; + } + + return null; +} + +export async function GET(_: Request, context: { params: Promise<{ id: string; documentId: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id, documentId } = await context.params; + + const document = await prisma.contractDocument.findFirst({ + where: { + id: documentId, + contractId: id, + contract: { + userId: user.id, + }, + }, + select: { + fileName: true, + filePath: true, + mimeType: true, + }, + }); + + if (!document) { + return NextResponse.json({ error: "Documento no encontrado." }, { status: 404 }); + } + + let fileBuffer: Buffer; + try { + fileBuffer = await readStoredContractDocumentFile(document.filePath); + } catch (error) { + const mapped = mapDocumentReadError(error); + if (mapped) { + return NextResponse.json({ error: mapped.message }, { status: mapped.status }); + } + + throw error; + } + + const encodedFilename = encodeURIComponent(document.fileName); + return new NextResponse(new Uint8Array(fileBuffer), { + status: 200, + headers: { + "Content-Type": document.mimeType || "application/octet-stream", + "Content-Disposition": `attachment; filename="${safeContentDispositionFilename(document.fileName)}"; filename*=UTF-8''${encodedFilename}`, + "Cache-Control": "no-store", + }, + }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene las tablas de Gestion Estrategica de Contratos. Ejecuta prisma migrate." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible descargar el documento." }, { status: 400 }); + } +} + +export async function DELETE(_: Request, context: { params: Promise<{ id: string; documentId: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id, documentId } = await context.params; + + const document = await prisma.contractDocument.findFirst({ + where: { + id: documentId, + contractId: id, + contract: { + userId: user.id, + }, + }, + select: { + id: true, + filePath: true, + }, + }); + + if (!document) { + return NextResponse.json({ error: "Documento no encontrado." }, { status: 404 }); + } + + await removeStoredContractDocumentFile(document.filePath); + await prisma.contractDocument.delete({ + where: { + id: document.id, + }, + }); + + return NextResponse.json({ ok: true }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene las tablas de Gestion Estrategica de Contratos. Ejecuta prisma migrate." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible eliminar el documento." }, { status: 400 }); + } +} diff --git a/src/app/api/contracts/[id]/payments/route.ts b/src/app/api/contracts/[id]/payments/route.ts new file mode 100644 index 0000000..496cd00 --- /dev/null +++ b/src/app/api/contracts/[id]/payments/route.ts @@ -0,0 +1,116 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { PaymentCreateInputSchema } from "@/lib/contracts/types"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +function parseJsonBody(body: unknown) { + return typeof body === "object" && body !== null ? (body as Record) : {}; +} + +export async function GET(_request: Request, context: RouteContext) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id: contractId } = await context.params; + + const contract = await prisma.contractRecord.findFirst({ + where: { + id: contractId, + userId: user.id, + }, + select: { id: true }, + }); + + if (!contract) { + return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 }); + } + + const payments = await prisma.contractPayment.findMany({ + where: { + contractId, + }, + orderBy: [{ paymentDate: "desc" }, { createdAt: "desc" }], + }); + + return NextResponse.json({ + ok: true, + payments: payments.map((item) => ({ + id: item.id, + contractId: item.contractId, + amount: Number(item.amount), + paymentDate: item.paymentDate.toISOString(), + invoiceNumber: item.invoiceNumber, + concept: item.concept, + status: item.status, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + })), + }); +} + +export async function POST(request: Request, context: RouteContext) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id: contractId } = await context.params; + + try { + const body = parseJsonBody(await request.json().catch(() => ({}))); + const parsed = PaymentCreateInputSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Datos de pago invalidos." }, { status: 400 }); + } + + const contract = await prisma.contractRecord.findFirst({ + where: { + id: contractId, + userId: user.id, + }, + select: { id: true }, + }); + + if (!contract) { + return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 }); + } + + const input = parsed.data; + const payment = await prisma.contractPayment.create({ + data: { + contractId, + amount: input.amount, + paymentDate: new Date(input.paymentDate), + invoiceNumber: input.invoiceNumber ?? null, + concept: input.concept, + status: input.status ?? "REGISTERED", + }, + }); + + return NextResponse.json({ + ok: true, + payment: { + id: payment.id, + contractId: payment.contractId, + amount: Number(payment.amount), + paymentDate: payment.paymentDate.toISOString(), + invoiceNumber: payment.invoiceNumber, + concept: payment.concept, + status: payment.status, + createdAt: payment.createdAt.toISOString(), + updatedAt: payment.updatedAt.toISOString(), + }, + }); + } catch { + return NextResponse.json({ error: "No fue posible registrar el pago." }, { status: 400 }); + } +} diff --git a/src/app/api/contracts/[id]/route.ts b/src/app/api/contracts/[id]/route.ts new file mode 100644 index 0000000..f3c3e5a --- /dev/null +++ b/src/app/api/contracts/[id]/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { ContractUpdateInputSchema } from "@/lib/contracts/types"; +import { listContractsForUser } from "@/lib/contracts/server"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +function parseJsonBody(body: unknown) { + return typeof body === "object" && body !== null ? (body as Record) : {}; +} + +export async function PATCH(request: Request, context: RouteContext) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await context.params; + + try { + const body = parseJsonBody(await request.json().catch(() => ({}))); + const parsed = ContractUpdateInputSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Datos de actualizacion invalidos." }, { status: 400 }); + } + + const current = await prisma.contractRecord.findFirst({ + where: { + id, + userId: user.id, + }, + select: { id: true }, + }); + + if (!current) { + return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 }); + } + + const input = parsed.data; + + await prisma.contractRecord.update({ + where: { id }, + data: { + title: input.title, + counterpartyEntity: input.counterpartyEntity, + contractNumber: input.contractNumber === undefined ? undefined : input.contractNumber ?? null, + contractType: input.contractType, + startDate: input.startDate === undefined ? undefined : input.startDate ? new Date(input.startDate) : null, + endDate: input.endDate === undefined ? undefined : input.endDate ? new Date(input.endDate) : null, + totalAmount: input.totalAmount === undefined ? undefined : input.totalAmount ?? null, + currency: input.currency ? input.currency.toUpperCase() : undefined, + status: input.status, + description: input.description, + sourceProposalId: input.sourceProposalId === undefined ? undefined : input.sourceProposalId ?? null, + }, + }); + + const contracts = await listContractsForUser(user.id, { q: id }); + const contract = contracts.find((item) => item.id === id) ?? null; + + return NextResponse.json({ ok: true, contract }); + } catch { + return NextResponse.json({ error: "No fue posible actualizar el contrato." }, { status: 400 }); + } +} diff --git a/src/app/api/contracts/extract/route.ts b/src/app/api/contracts/extract/route.ts new file mode 100644 index 0000000..1480425 --- /dev/null +++ b/src/app/api/contracts/extract/route.ts @@ -0,0 +1,297 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { analyzePdf } from "@/lib/pdf/analyzePdf"; +import { prisma } from "@/lib/prisma"; +import { contractCreateInputFromExtraction } from "@/lib/contracts/server"; +import { extractContractWithAi } from "@/lib/contracts/extraction"; +import { MAX_CONTRACT_PDF_BYTES, storeContractDocumentFile } from "@/lib/contracts/storage"; +import { + ensureProposalPdfLinkedToContract, + getLatestProposalPdfSourceForUser, +} from "@/lib/contracts/proposal-continuity"; + +export const runtime = "nodejs"; + +function isPdfFile(file: File) { + return file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf"); +} + +function removePdfExtension(fileName: string) { + return fileName.replace(/\.pdf$/i, "").trim(); +} + +function logContinuityHandoff(event: string, data: Record) { + console.info( + "[continuity_handoff]", + JSON.stringify({ + event, + at: new Date().toISOString(), + ...data, + }), + ); +} + +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 contractIdRaw = formData.get("contractId"); + const contractId = typeof contractIdRaw === "string" ? contractIdRaw.trim() : ""; + const sourceProposalIdRaw = formData.get("sourceProposalId"); + const sourceProposalId = typeof sourceProposalIdRaw === "string" ? sourceProposalIdRaw.trim() : ""; + const fileValue = formData.get("file"); + const uploadedFile = fileValue instanceof File ? fileValue : null; + + logContinuityHandoff("m5_to_m8_extract_requested", { + userId: user.id, + contractId: contractId || null, + sourceProposalId: sourceProposalId || null, + hasUploadedFile: Boolean(uploadedFile), + }); + + let sourceProposalLookup: Awaited> | null = null; + let resolvedSourceProposalId: string | null = null; + if (sourceProposalId) { + try { + sourceProposalLookup = await getLatestProposalPdfSourceForUser(user.id, sourceProposalId); + } catch { + return NextResponse.json({ error: "No fue posible leer el PDF de la propuesta fuente." }, { status: 400 }); + } + + if (sourceProposalLookup.status === "proposal_not_found") { + return NextResponse.json({ error: "Propuesta fuente no encontrada." }, { status: 404 }); + } + + resolvedSourceProposalId = sourceProposalId; + logContinuityHandoff("m5_to_m8_source_proposal_resolved", { + userId: user.id, + sourceProposalId: resolvedSourceProposalId, + lookupStatus: sourceProposalLookup.status, + }); + } + + let fileName = ""; + let mimeType = "application/pdf"; + let fileBuffer: Buffer; + + if (uploadedFile) { + if (uploadedFile.size <= 0) { + return NextResponse.json({ error: "El archivo esta vacio." }, { status: 400 }); + } + + if (uploadedFile.size > MAX_CONTRACT_PDF_BYTES) { + return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 }); + } + + if (!isPdfFile(uploadedFile)) { + return NextResponse.json({ error: "Solo se permite PDF en extraccion de contrato." }, { status: 400 }); + } + + fileName = uploadedFile.name; + mimeType = uploadedFile.type || "application/pdf"; + fileBuffer = Buffer.from(await uploadedFile.arrayBuffer()); + } else { + if (!sourceProposalLookup) { + return NextResponse.json({ error: "Debes adjuntar un contrato PDF o seleccionar una propuesta de Modulo 5." }, { status: 400 }); + } + + if (sourceProposalLookup.status !== "ok") { + return NextResponse.json({ error: "La propuesta fuente no tiene un PDF disponible para reutilizar." }, { status: 400 }); + } + + fileName = sourceProposalLookup.source.document.fileName; + mimeType = sourceProposalLookup.source.document.mimeType; + fileBuffer = sourceProposalLookup.source.fileBuffer; + } + + const analyzed = await analyzePdf(fileBuffer); + const extraction = await extractContractWithAi(analyzed.text); + + let contract = null as { id: string } | null; + + if (contractId) { + const existing = await prisma.contractRecord.findFirst({ + where: { + id: contractId, + userId: user.id, + }, + select: { + id: true, + }, + }); + + if (!existing) { + return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 }); + } + + const base = contractCreateInputFromExtraction({ + extraction, + fallbackTitle: removePdfExtension(fileName) || "Contrato sin titulo", + }); + + await prisma.contractRecord.update({ + where: { + id: existing.id, + }, + data: { + title: extraction.fields.title ? base.title : undefined, + counterpartyEntity: extraction.fields.counterpartyEntity ? base.counterpartyEntity : undefined, + contractNumber: extraction.fields.contractNumber === null ? undefined : base.contractNumber, + contractType: extraction.fields.contractType ? base.contractType : undefined, + startDate: extraction.fields.startDate ? new Date(extraction.fields.startDate) : undefined, + endDate: extraction.fields.endDate ? new Date(extraction.fields.endDate) : undefined, + totalAmount: extraction.fields.totalAmount === null ? undefined : base.totalAmount, + currency: base.currency, + description: extraction.fields.description ? base.description : undefined, + sourceProposalId: resolvedSourceProposalId ?? undefined, + }, + }); + + contract = existing; + } else { + const base = contractCreateInputFromExtraction({ + extraction, + fallbackTitle: removePdfExtension(fileName) || "Contrato sin titulo", + }); + + contract = await prisma.contractRecord.create({ + data: { + userId: user.id, + sourceProposalId: resolvedSourceProposalId, + title: base.title, + counterpartyEntity: base.counterpartyEntity, + contractNumber: base.contractNumber, + contractType: base.contractType, + startDate: base.startDate ? new Date(base.startDate) : null, + endDate: base.endDate ? new Date(base.endDate) : null, + totalAmount: base.totalAmount, + currency: base.currency, + status: base.status, + description: base.description, + }, + select: { + id: true, + }, + }); + } + + const targetContractId = contract.id; + logContinuityHandoff("m5_to_m8_contract_target_resolved", { + userId: user.id, + targetContractId, + sourceProposalId: resolvedSourceProposalId, + mode: contractId ? "update" : "create", + }); + + const [deliverableCount, paymentCount] = await Promise.all([ + prisma.contractDeliverable.count({ + where: { + contractId: targetContractId, + }, + }), + prisma.contractPayment.count({ + where: { + contractId: targetContractId, + }, + }), + ]); + + if (deliverableCount === 0 && extraction.deliverables.length > 0) { + await prisma.contractDeliverable.createMany({ + data: extraction.deliverables.map((item) => ({ + contractId: targetContractId, + title: item.title, + dueDate: item.dueDate ? new Date(item.dueDate) : null, + amountLinked: item.amountLinked, + notes: item.notes ?? "Detectado automaticamente por IA.", + status: "PENDING", + })), + }); + } + + if (paymentCount === 0 && extraction.paymentMilestones.length > 0) { + await prisma.contractPayment.createMany({ + data: extraction.paymentMilestones + .filter((item) => item.paymentDate) + .map((item) => ({ + contractId: targetContractId, + amount: item.amount ?? 0, + paymentDate: new Date(item.paymentDate as string), + concept: item.concept, + status: "REGISTERED", + })), + }); + } + + let uploadedDocumentId: string | null = null; + if (uploadedFile) { + const stored = await storeContractDocumentFile(user.id, targetContractId, fileName, mimeType, fileBuffer); + const createdDocument = await prisma.contractDocument.create({ + data: { + contractId: targetContractId, + fileName: stored.fileName, + filePath: stored.filePath, + mimeType: stored.mimeType, + sizeBytes: stored.sizeBytes, + checksumSha256: stored.checksumSha256, + kind: "SIGNED_CONTRACT", + }, + select: { + id: true, + }, + }); + uploadedDocumentId = createdDocument.id; + } else if (sourceProposalLookup?.status === "ok") { + const linked = await ensureProposalPdfLinkedToContract({ + userId: user.id, + contractId: targetContractId, + source: sourceProposalLookup.source, + }); + uploadedDocumentId = linked.documentId; + } + + await prisma.contractExtractionHistory.create({ + data: { + contractId: targetContractId, + userId: user.id, + engine: extraction.engine, + model: extraction.model, + resultJson: extraction as unknown as Prisma.InputJsonValue, + warningsJson: extraction.warnings as unknown as Prisma.InputJsonValue, + }, + }); + + const fullContract = await prisma.contractRecord.findUnique({ + where: { id: targetContractId }, + include: { + deliverables: { + orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }], + }, + payments: { + orderBy: [{ paymentDate: "desc" }, { createdAt: "desc" }], + }, + documents: { + orderBy: [{ createdAt: "desc" }], + }, + }, + }); + + return NextResponse.json({ + ok: true, + contract: fullContract, + extraction: { + ...extraction, + analyzed: { + methodUsed: analyzed.methodUsed, + numPages: analyzed.numPages, + warnings: analyzed.warnings, + }, + }, + uploadedDocumentId, + }); +} diff --git a/src/app/api/contracts/kpis/route.ts b/src/app/api/contracts/kpis/route.ts new file mode 100644 index 0000000..f2e9ab0 --- /dev/null +++ b/src/app/api/contracts/kpis/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { getContractsKpisForUser } from "@/lib/contracts/server"; + +export async function GET() { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const kpis = await getContractsKpisForUser(user.id); + return NextResponse.json({ ok: true, kpis }); +} diff --git a/src/app/api/contracts/route.ts b/src/app/api/contracts/route.ts new file mode 100644 index 0000000..8daa1fe --- /dev/null +++ b/src/app/api/contracts/route.ts @@ -0,0 +1,100 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { + ContractCreateInputSchema, + ContractListFiltersSchema, +} from "@/lib/contracts/types"; +import { listContractsForUser } from "@/lib/contracts/server"; + +function isSchemaNotReadyError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022"); +} + +function parseJsonBody(body: unknown) { + return typeof body === "object" && body !== null ? (body as Record) : {}; +} + +export async function GET(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { searchParams } = new URL(request.url); + const parsed = ContractListFiltersSchema.safeParse({ + q: searchParams.get("q") ?? undefined, + status: searchParams.get("status") ?? undefined, + sort: searchParams.get("sort") ?? undefined, + }); + + if (!parsed.success) { + return NextResponse.json({ error: "Filtros invalidos." }, { status: 400 }); + } + + const contracts = await listContractsForUser(user.id, parsed.data); + return NextResponse.json({ ok: true, contracts }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene las tablas de Gestion Estrategica de Contratos. Ejecuta prisma migrate." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible obtener contratos." }, { status: 400 }); + } +} + +export async function POST(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = parseJsonBody(await request.json().catch(() => ({}))); + const parsed = ContractCreateInputSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Datos de contrato invalidos." }, { status: 400 }); + } + + const input = parsed.data; + const created = await prisma.contractRecord.create({ + data: { + userId: user.id, + sourceProposalId: input.sourceProposalId ?? null, + title: input.title, + counterpartyEntity: input.counterpartyEntity, + contractNumber: input.contractNumber ?? null, + contractType: input.contractType, + startDate: input.startDate ? new Date(input.startDate) : null, + endDate: input.endDate ? new Date(input.endDate) : null, + totalAmount: input.totalAmount ?? null, + currency: (input.currency ?? "MXN").toUpperCase(), + status: input.status ?? "ACTIVE", + description: input.description ?? "", + }, + select: { id: true }, + }); + + const contracts = await listContractsForUser(user.id, { q: created.id }); + const contract = contracts.find((item) => item.id === created.id) ?? null; + + return NextResponse.json({ ok: true, contract }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene las tablas de Gestion Estrategica de Contratos. Ejecuta prisma migrate." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible crear el contrato." }, { status: 400 }); + } +} diff --git a/src/app/api/contracts/upload/route.ts b/src/app/api/contracts/upload/route.ts new file mode 100644 index 0000000..7c478f6 --- /dev/null +++ b/src/app/api/contracts/upload/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { MAX_CONTRACT_PDF_BYTES, storeContractDocumentFile } from "@/lib/contracts/storage"; + +export const runtime = "nodejs"; + +function isPdfFile(file: File) { + return file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf"); +} + +function parseKind(value: FormDataEntryValue | null) { + const asString = typeof value === "string" ? value.trim().toUpperCase() : ""; + + if ( + asString === "SIGNED_CONTRACT" || + asString === "ADDENDUM" || + asString === "DELIVERABLE_EVIDENCE" || + asString === "PAYMENT_EVIDENCE" || + asString === "OTHER" + ) { + return asString; + } + + return "SIGNED_CONTRACT"; +} + +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 contractIdValue = formData.get("contractId"); + const fileValue = formData.get("file"); + + if (!(fileValue instanceof File)) { + return NextResponse.json({ error: "Debes adjuntar un archivo." }, { status: 400 }); + } + + if (fileValue.size <= 0) { + return NextResponse.json({ error: "El archivo esta vacio." }, { status: 400 }); + } + + if (fileValue.size > MAX_CONTRACT_PDF_BYTES) { + return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 }); + } + + if (!isPdfFile(fileValue)) { + return NextResponse.json({ error: "Solo se permiten archivos PDF para este flujo." }, { status: 400 }); + } + + const contractId = typeof contractIdValue === "string" ? contractIdValue.trim() : ""; + + if (!contractId) { + return NextResponse.json({ error: "contractId es requerido." }, { status: 400 }); + } + + const contract = await prisma.contractRecord.findFirst({ + where: { + id: contractId, + userId: user.id, + }, + select: { id: true }, + }); + + if (!contract) { + return NextResponse.json({ error: "Contrato no encontrado." }, { status: 404 }); + } + + const fileBuffer = Buffer.from(await fileValue.arrayBuffer()); + const stored = await storeContractDocumentFile(user.id, contractId, fileValue.name, fileValue.type, fileBuffer); + const kind = parseKind(formData.get("kind")); + + const document = await prisma.contractDocument.create({ + data: { + contractId, + fileName: stored.fileName, + filePath: stored.filePath, + mimeType: stored.mimeType, + sizeBytes: stored.sizeBytes, + checksumSha256: stored.checksumSha256, + kind, + }, + }); + + return NextResponse.json({ + ok: true, + document: { + id: document.id, + contractId: document.contractId, + fileName: document.fileName, + filePath: document.filePath, + mimeType: document.mimeType, + sizeBytes: document.sizeBytes, + checksumSha256: document.checksumSha256, + kind: document.kind, + createdAt: document.createdAt.toISOString(), + }, + }); +} diff --git a/src/app/api/cron/licitations-sync/route.ts b/src/app/api/cron/licitations-sync/route.ts index 15ba550..c1d0fea 100644 --- a/src/app/api/cron/licitations-sync/route.ts +++ b/src/app/api/cron/licitations-sync/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; -import { licitationsConfig } from "@/lib/licitations/config"; +import { runPeriodicNormativeVerification } from "@/lib/compliance/regulations"; +import { isAuthorizedCronRequest } from "@/lib/cron/auth"; import { runDailyLicitationsSync } from "@/lib/licitations/sync"; export const runtime = "nodejs"; @@ -31,10 +32,8 @@ function parseBoolean(value: string | null) { return undefined; } -export async function POST(request: Request) { - const token = request.headers.get("x-sync-token") ?? ""; - - if (!licitationsConfig.syncCronToken || token !== licitationsConfig.syncCronToken) { +async function runSync(request: Request) { + if (!isAuthorizedCronRequest(request)) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -44,11 +43,33 @@ export async function POST(request: Request) { const skip = parsePositiveInt(url.searchParams.get("skip")); const targetYear = parsePositiveInt(url.searchParams.get("target_year")); const includePnt = parseBoolean(url.searchParams.get("include_pnt")); + const includeLicitaya = parseBoolean(url.searchParams.get("include_licitaya")); + const includeRegulations = parseBoolean(url.searchParams.get("include_regulations")); const force = parseBoolean(url.searchParams.get("force")); - const payload = await runDailyLicitationsSync({ municipalityId, limit, skip, targetYear, includePnt, force }); + const payload = await runDailyLicitationsSync({ municipalityId, limit, skip, targetYear, includePnt, includeLicitaya, force }); + let regulations = null; + let regulationsError: string | null = null; + + if (includeRegulations !== false) { + try { + regulations = await runPeriodicNormativeVerification(new Date()); + } catch (error) { + regulationsError = error instanceof Error ? error.message : "No fue posible verificar reglamentos oficiales."; + } + } return NextResponse.json({ ok: true, payload, + regulations, + regulationsError, }); } + +export async function GET(request: Request) { + return runSync(request); +} + +export async function POST(request: Request) { + return runSync(request); +} diff --git a/src/app/api/cron/regulations-verify/route.ts b/src/app/api/cron/regulations-verify/route.ts new file mode 100644 index 0000000..71f1035 --- /dev/null +++ b/src/app/api/cron/regulations-verify/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { runPeriodicNormativeVerification } from "@/lib/compliance/regulations"; +import { isAuthorizedCronRequest } from "@/lib/cron/auth"; + +export const runtime = "nodejs"; + +async function runVerification(request: Request) { + if (!isAuthorizedCronRequest(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const payload = await runPeriodicNormativeVerification(new Date()); + return NextResponse.json({ + ok: true, + payload, + }); +} + +export async function GET(request: Request) { + return runVerification(request); +} + +export async function POST(request: Request) { + return runVerification(request); +} diff --git a/src/app/api/deliverables/[id]/route.ts b/src/app/api/deliverables/[id]/route.ts new file mode 100644 index 0000000..99e7fb0 --- /dev/null +++ b/src/app/api/deliverables/[id]/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { DeliverableUpdateInputSchema } from "@/lib/contracts/types"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +function parseJsonBody(body: unknown) { + return typeof body === "object" && body !== null ? (body as Record) : {}; +} + +export async function PATCH(request: Request, context: RouteContext) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await context.params; + + try { + const body = parseJsonBody(await request.json().catch(() => ({}))); + const parsed = DeliverableUpdateInputSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Datos de actualizacion invalidos." }, { status: 400 }); + } + + const deliverable = await prisma.contractDeliverable.findFirst({ + where: { + id, + contract: { + userId: user.id, + }, + }, + select: { id: true }, + }); + + if (!deliverable) { + return NextResponse.json({ error: "Entregable no encontrado." }, { status: 404 }); + } + + const input = parsed.data; + + const updated = await prisma.contractDeliverable.update({ + where: { id }, + data: { + title: input.title, + status: input.status, + dueDate: input.dueDate === undefined ? undefined : input.dueDate ? new Date(input.dueDate) : null, + deliveredAt: input.deliveredAt === undefined ? undefined : input.deliveredAt ? new Date(input.deliveredAt) : null, + approvedAt: input.approvedAt === undefined ? undefined : input.approvedAt ? new Date(input.approvedAt) : null, + notes: input.notes, + amountLinked: input.amountLinked === undefined ? undefined : input.amountLinked ?? null, + }, + }); + + return NextResponse.json({ + ok: true, + deliverable: { + id: updated.id, + contractId: updated.contractId, + title: updated.title, + dueDate: updated.dueDate ? updated.dueDate.toISOString() : null, + amountLinked: updated.amountLinked ? Number(updated.amountLinked) : null, + status: updated.status, + deliveredAt: updated.deliveredAt ? updated.deliveredAt.toISOString() : null, + approvedAt: updated.approvedAt ? updated.approvedAt.toISOString() : null, + notes: updated.notes, + }, + }); + } catch { + return NextResponse.json({ error: "No fue posible actualizar el entregable." }, { status: 400 }); + } +} diff --git a/src/app/api/diagnostic/ai/suggestions/route.ts b/src/app/api/diagnostic/ai/suggestions/route.ts new file mode 100644 index 0000000..eec3851 --- /dev/null +++ b/src/app/api/diagnostic/ai/suggestions/route.ts @@ -0,0 +1,309 @@ +import { z } from "zod"; +import { NextResponse } from "next/server"; +import { getSessionPayload } from "@/lib/auth/session"; +import { prisma } from "@/lib/prisma"; +import { callOpenAiJsonSchema } from "@/lib/ai/openai"; +import { storeAiSuggestionFromEnvelope } from "@/lib/ai/suggestions"; + +const M1_PROMPT_VERSION = "m1_assist_v1"; + +const M1AiSuggestionItemSchema = z.object({ + questionId: z.string().min(1), + suggestedAnswerOptionId: z.string().min(1), + rationale: z.string().min(10).max(1_500), + missingEvidence: z.array(z.string().min(2).max(240)).max(8).default([]), + confidence: z.number().min(0).max(1).optional(), +}); + +const M1AiSuggestionResponseSchema = z.object({ + suggestions: z.array(M1AiSuggestionItemSchema).max(50), + confidence: z.enum(["low", "medium", "high"]).optional(), +}); + +const M1AiSuggestionJsonSchema = { + type: "object", + additionalProperties: false, + required: ["suggestions"], + properties: { + suggestions: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["questionId", "suggestedAnswerOptionId", "rationale", "missingEvidence"], + properties: { + questionId: { type: "string" }, + suggestedAnswerOptionId: { type: "string" }, + rationale: { type: "string" }, + missingEvidence: { + type: "array", + items: { type: "string" }, + }, + confidence: { type: "number", minimum: 0, maximum: 1 }, + }, + }, + }, + confidence: { type: "string", enum: ["low", "medium", "high"] }, + }, +} as const; + +function parseString(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function parseResponseEvidence(rawValue: unknown): { notes: string; links: string[] } | null { + if (!rawValue || typeof rawValue !== "object" || Array.isArray(rawValue)) { + return null; + } + + const value = rawValue as Record; + const notes = typeof value.notes === "string" ? value.notes.trim() : ""; + const links = Array.isArray(value.links) + ? value.links + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + : []; + + if (!notes && links.length === 0) { + return null; + } + + return { + notes, + links, + }; +} + +function toConfidenceNumber(confidence: number | undefined, fallback: "low" | "medium" | "high" | null) { + if (typeof confidence === "number" && Number.isFinite(confidence)) { + return Math.max(0, Math.min(1, confidence)); + } + + if (fallback === "high") { + return 0.9; + } + + if (fallback === "medium") { + return 0.65; + } + + if (fallback === "low") { + return 0.35; + } + + return null; +} + +export async function POST(request: Request) { + const session = await getSessionPayload(); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json().catch(() => ({}))) as Record; + const moduleKey = parseString(body.moduleKey); + const requestedQuestionId = parseString(body.questionId); + + if (!moduleKey) { + return NextResponse.json({ error: "moduleKey es requerido." }, { status: 400 }); + } + + const moduleRecord = await prisma.diagnosticModule.findUnique({ + where: { key: moduleKey }, + select: { + id: true, + key: true, + name: true, + questions: { + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], + select: { + id: true, + prompt: true, + helpText: true, + answerOptions: { + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], + select: { + id: true, + label: true, + }, + }, + }, + }, + }, + }); + + if (!moduleRecord) { + return NextResponse.json({ error: "Modulo no encontrado." }, { status: 404 }); + } + + const questionIds = moduleRecord.questions.map((question) => question.id); + const responses = questionIds.length + ? await prisma.response.findMany({ + where: { + userId: session.userId, + questionId: { + in: questionIds, + }, + }, + select: { + questionId: true, + answerOptionId: true, + evidence: true, + }, + }) + : []; + + const responseByQuestionId = new Map(responses.map((item) => [item.questionId, item])); + const targetQuestions = moduleRecord.questions.filter((question) => { + if (requestedQuestionId) { + return question.id === requestedQuestionId; + } + + return !responseByQuestionId.has(question.id); + }); + + if (requestedQuestionId && !targetQuestions.length) { + return NextResponse.json({ error: "questionId no pertenece al modulo solicitado." }, { status: 400 }); + } + + if (!targetQuestions.length) { + return NextResponse.json({ + ok: true, + moduleKey, + suggestions: [], + warnings: [{ code: "no_unanswered_questions", message: "No hay preguntas pendientes para sugerir en este modulo." }], + }); + } + + const answeredContext = moduleRecord.questions + .map((question) => { + const response = responseByQuestionId.get(question.id); + if (!response) { + return null; + } + + const selectedOption = question.answerOptions.find((option) => option.id === response.answerOptionId) ?? null; + const evidence = parseResponseEvidence(response.evidence); + + return { + questionId: question.id, + prompt: question.prompt, + selectedAnswerOptionId: selectedOption?.id ?? response.answerOptionId, + selectedAnswerLabel: selectedOption?.label ?? "No identificada", + evidenceNotes: evidence?.notes ?? "", + evidenceLinks: evidence?.links ?? [], + }; + }) + .filter((item): item is NonNullable => Boolean(item)); + + const targetContext = targetQuestions.map((question) => ({ + questionId: question.id, + prompt: question.prompt, + helpText: question.helpText ?? "", + options: question.answerOptions.map((option) => ({ + id: option.id, + label: option.label, + })), + })); + + const systemPrompt = [ + "Eres un asistente experto en diagnostico organizacional para contratacion publica en Mexico.", + "Genera sugerencias de respuesta para preguntas no contestadas sin inventar evidencia.", + "Debes responder solamente JSON valido.", + "El idioma de salida debe ser espanol.", + ].join(" "); + + const userPrompt = [ + `Modulo: ${moduleRecord.name} (${moduleRecord.key})`, + "Respuestas ya contestadas por la organizacion:", + JSON.stringify(answeredContext), + "", + "Preguntas objetivo a sugerir:", + JSON.stringify(targetContext), + "", + "Instrucciones:", + "- Para cada pregunta objetivo devuelve questionId y suggestedAnswerOptionId usando solo IDs listados en options.", + "- rationale debe explicar por que esa opcion es la mas probable segun contexto.", + "- missingEvidence debe listar evidencia que ayudaria a confirmar o corregir la sugerencia.", + "- confidence debe ir de 0 a 1.", + ].join("\n"); + + const envelope = await callOpenAiJsonSchema({ + promptVersion: M1_PROMPT_VERSION, + systemPrompt, + userPrompt, + outputSchema: M1AiSuggestionResponseSchema, + schemaName: "m1_question_suggestions", + jsonSchema: M1AiSuggestionJsonSchema as unknown as Record, + model: process.env.OPENAI_M1_MODEL?.trim() || undefined, + }); + + const targetQuestionById = new Map( + targetQuestions.map((question) => [ + question.id, + { + id: question.id, + optionIds: new Set(question.answerOptions.map((option) => option.id)), + }, + ]), + ); + + const aiSuggestions = envelope.data?.suggestions ?? []; + const normalizedSuggestions = aiSuggestions + .map((item) => { + const question = targetQuestionById.get(item.questionId); + if (!question || !question.optionIds.has(item.suggestedAnswerOptionId)) { + return null; + } + + return item; + }) + .filter((item): item is z.infer => Boolean(item)); + + const persisted = await Promise.all( + normalizedSuggestions.map(async (item) => { + const saved = await storeAiSuggestionFromEnvelope({ + userId: session.userId, + moduleKey: "M1", + featureKey: "diagnostic_answer_suggestion", + subjectType: "question", + subjectId: item.questionId, + inputForHash: { + moduleKey, + questionId: item.questionId, + answeredContext, + targetQuestion: targetContext.find((question) => question.questionId === item.questionId) ?? null, + }, + envelope, + responsePayload: item, + }); + + return { + ...item, + suggestionId: saved.suggestionId, + }; + }), + ); + + return NextResponse.json({ + ok: true, + moduleKey, + suggestions: persisted.map((item) => ({ + questionId: item.questionId, + suggestedAnswerOptionId: item.suggestedAnswerOptionId, + rationale: item.rationale, + missingEvidence: item.missingEvidence, + confidence: toConfidenceNumber(item.confidence, envelope.confidence), + suggestionId: item.suggestionId, + })), + meta: { + engine: envelope.engine, + model: envelope.model, + usage: envelope.usage, + warnings: envelope.warnings, + confidence: envelope.confidence, + }, + }); +} diff --git a/src/app/api/legal/cases/[id]/route.ts b/src/app/api/legal/cases/[id]/route.ts new file mode 100644 index 0000000..5126bf2 --- /dev/null +++ b/src/app/api/legal/cases/[id]/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { LegalCaseUpdateInputSchema } from "@/lib/legal/types"; +import { listLegalCasesForUser } from "@/lib/legal/server"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +function parseJsonBody(body: unknown) { + return typeof body === "object" && body !== null ? (body as Record) : {}; +} + +export async function PATCH(request: Request, context: RouteContext) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await context.params; + + try { + const body = parseJsonBody(await request.json().catch(() => ({}))); + const parsed = LegalCaseUpdateInputSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Datos de actualizacion invalidos." }, { status: 400 }); + } + + const owned = await prisma.legalCase.findFirst({ + where: { + id, + userId: user.id, + }, + select: { id: true }, + }); + + if (!owned) { + return NextResponse.json({ error: "Caso no encontrado." }, { status: 404 }); + } + + const input = parsed.data; + await prisma.legalCase.update({ + where: { id }, + data: { + severity: input.severity, + status: input.status, + resolvedAt: input.resolvedAt === undefined ? undefined : input.resolvedAt ? new Date(input.resolvedAt) : null, + description: input.description, + amountAtRisk: input.amountAtRisk === undefined ? undefined : input.amountAtRisk ?? null, + counterparty: input.counterparty, + }, + }); + + const cases = await listLegalCasesForUser(user.id); + const legalCase = cases.find((item) => item.id === id) ?? null; + + return NextResponse.json({ ok: true, case: legalCase }); + } catch { + return NextResponse.json({ error: "No fue posible actualizar el caso legal." }, { status: 400 }); + } +} diff --git a/src/app/api/legal/cases/route.ts b/src/app/api/legal/cases/route.ts new file mode 100644 index 0000000..8bb8789 --- /dev/null +++ b/src/app/api/legal/cases/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { LegalCaseCreateInputSchema } from "@/lib/legal/types"; +import { listLegalCasesForUser } from "@/lib/legal/server"; + +function parseJsonBody(body: unknown) { + return typeof body === "object" && body !== null ? (body as Record) : {}; +} + +export async function GET() { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const cases = await listLegalCasesForUser(user.id); + return NextResponse.json({ ok: true, cases }); +} + +export async function POST(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = parseJsonBody(await request.json().catch(() => ({}))); + const parsed = LegalCaseCreateInputSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Datos de caso legal invalidos." }, { status: 400 }); + } + + const input = parsed.data; + + const created = await prisma.legalCase.create({ + data: { + userId: user.id, + contractId: input.contractId ?? null, + caseType: input.caseType, + severity: input.severity, + counterparty: input.counterparty, + description: input.description, + amountAtRisk: input.amountAtRisk ?? null, + status: input.status ?? "OPEN", + }, + select: { + id: true, + }, + }); + + const cases = await listLegalCasesForUser(user.id); + const legalCase = cases.find((item) => item.id === created.id) ?? null; + + return NextResponse.json({ ok: true, case: legalCase }); + } catch { + return NextResponse.json({ error: "No fue posible crear el caso legal." }, { status: 400 }); + } +} diff --git a/src/app/api/legal/diagnosis/answer/route.test.ts b/src/app/api/legal/diagnosis/answer/route.test.ts new file mode 100644 index 0000000..40cced4 --- /dev/null +++ b/src/app/api/legal/diagnosis/answer/route.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { requireAdminApiUserMock, parseDiagnosisAnswersMock, evaluateDiagnosisMock, prismaMock } = vi.hoisted(() => ({ + requireAdminApiUserMock: vi.fn(), + parseDiagnosisAnswersMock: vi.fn(), + evaluateDiagnosisMock: vi.fn(), + prismaMock: { + legalDiagnosis: { + findFirst: vi.fn(), + update: vi.fn(), + }, + contractRecord: { + findFirst: vi.fn(), + }, + legalCase: { + create: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/admin", () => ({ + requireAdminApiUser: requireAdminApiUserMock, +})); + +vi.mock("@/lib/legal/diagnosis", () => ({ + parseDiagnosisAnswers: parseDiagnosisAnswersMock, + evaluateDiagnosis: evaluateDiagnosisMock, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: prismaMock, +})); + +import { POST } from "@/app/api/legal/diagnosis/answer/route"; + +describe("M9 diagnosis contract continuity regression", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("keeps persisted contractId when finalizing diagnosis into legal case", async () => { + const now = new Date("2026-04-15T11:00:00.000Z"); + + requireAdminApiUserMock.mockResolvedValue({ id: "user-1" }); + prismaMock.legalDiagnosis.findFirst.mockResolvedValue({ + id: "diag-1", + totalSteps: 4, + legalCaseId: null, + answersJson: { + contractId: "contract-1", + counterparty: "Proveedor SA", + description: "Incumplimiento de entregable", + }, + }); + parseDiagnosisAnswersMock.mockReturnValue({ amountAtRisk: 5000 }); + evaluateDiagnosisMock.mockReturnValue({ + suggestedSeverity: "HIGH", + suggestedCaseType: "CONTRACT_BREACH", + }); + prismaMock.contractRecord.findFirst.mockResolvedValue({ id: "contract-1" }); + prismaMock.legalCase.create.mockResolvedValue({ id: "case-1" }); + prismaMock.legalDiagnosis.update.mockResolvedValue({ + id: "diag-1", + legalCaseId: "case-1", + stepIndex: 4, + totalSteps: 4, + answersJson: { + contractId: "contract-1", + }, + recommendedRouteJson: { + suggestedSeverity: "HIGH", + suggestedCaseType: "CONTRACT_BREACH", + }, + updatedAt: now, + }); + + const request = new Request("http://localhost/api/legal/diagnosis/answer", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + diagnosisId: "diag-1", + stepIndex: 3, + answers: {}, + finalize: true, + }), + }); + + const response = await POST(request); + const payload = (await response.json()) as { + ok?: boolean; + diagnosis?: { + legalCaseId: string | null; + }; + }; + + expect(response.status).toBe(200); + expect(payload.ok).toBe(true); + expect(payload.diagnosis?.legalCaseId).toBe("case-1"); + expect(prismaMock.contractRecord.findFirst).toHaveBeenCalledWith({ + where: { + id: "contract-1", + userId: "user-1", + }, + select: { + id: true, + }, + }); + expect(prismaMock.legalCase.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + contractId: "contract-1", + }), + }), + ); + }); +}); diff --git a/src/app/api/legal/diagnosis/answer/route.ts b/src/app/api/legal/diagnosis/answer/route.ts new file mode 100644 index 0000000..bfbd296 --- /dev/null +++ b/src/app/api/legal/diagnosis/answer/route.ts @@ -0,0 +1,172 @@ +import type { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { evaluateDiagnosis, parseDiagnosisAnswers } from "@/lib/legal/diagnosis"; +import { LegalDiagnosisAnswerInputSchema } from "@/lib/legal/types"; + +function parseJsonBody(body: unknown) { + return typeof body === "object" && body !== null ? (body as Record) : {}; +} + +function stringOrEmpty(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function extractContractId(value: unknown) { + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim(); + return normalized ? normalized : null; +} + +function logContinuityHandoff(event: string, data: Record) { + console.info( + "[continuity_handoff]", + JSON.stringify({ + event, + at: new Date().toISOString(), + ...data, + }), + ); +} + +export async function POST(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = parseJsonBody(await request.json().catch(() => ({}))); + const parsed = LegalDiagnosisAnswerInputSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Datos de respuesta invalidos." }, { status: 400 }); + } + + const input = parsed.data; + logContinuityHandoff("m9_diagnosis_answer_received", { + userId: user.id, + diagnosisId: input.diagnosisId, + finalize: Boolean(input.finalize), + }); + + const diagnosis = await prisma.legalDiagnosis.findFirst({ + where: { + id: input.diagnosisId, + userId: user.id, + }, + select: { + id: true, + answersJson: true, + totalSteps: true, + legalCaseId: true, + }, + }); + + if (!diagnosis) { + return NextResponse.json({ error: "Diagnostico no encontrado." }, { status: 404 }); + } + + const currentAnswers = + diagnosis.answersJson && typeof diagnosis.answersJson === "object" && !Array.isArray(diagnosis.answersJson) + ? (diagnosis.answersJson as Record) + : {}; + + const mergedAnswers = { + ...currentAnswers, + ...input.answers, + }; + + const normalizedAnswers = parseDiagnosisAnswers(mergedAnswers); + const recommendation = evaluateDiagnosis(normalizedAnswers); + const nextStep = input.finalize ? diagnosis.totalSteps : Math.min(diagnosis.totalSteps, input.stepIndex + 1); + + let legalCaseId = diagnosis.legalCaseId; + const persistedContractId = extractContractId(mergedAnswers.contractId); + logContinuityHandoff("m9_diagnosis_contract_context", { + userId: user.id, + diagnosisId: diagnosis.id, + persistedContractId, + hasLegalCaseAlready: Boolean(legalCaseId), + }); + + if (input.finalize && !legalCaseId) { + if (persistedContractId) { + const ownedContract = await prisma.contractRecord.findFirst({ + where: { + id: persistedContractId, + userId: user.id, + }, + select: { + id: true, + }, + }); + + if (!ownedContract) { + return NextResponse.json({ error: "El contrato vinculado al diagnostico ya no esta disponible." }, { status: 400 }); + } + } + + const createdCase = await prisma.legalCase.create({ + data: { + userId: user.id, + contractId: persistedContractId, + caseType: recommendation.suggestedCaseType, + severity: recommendation.suggestedSeverity, + counterparty: stringOrEmpty(mergedAnswers.counterparty) || "Contraparte por validar", + description: stringOrEmpty(mergedAnswers.description) || "Caso generado desde diagnostico legal asistido.", + amountAtRisk: normalizedAnswers.amountAtRisk, + status: "OPEN", + }, + select: { + id: true, + }, + }); + + legalCaseId = createdCase.id; + logContinuityHandoff("m9_case_autocreated_from_diagnosis", { + userId: user.id, + diagnosisId: diagnosis.id, + legalCaseId, + contractId: persistedContractId, + }); + } + + const updated = await prisma.legalDiagnosis.update({ + where: { + id: diagnosis.id, + }, + data: { + stepIndex: nextStep, + answersJson: mergedAnswers as Prisma.InputJsonValue, + recommendedRouteJson: recommendation as unknown as Prisma.InputJsonValue, + legalCaseId, + }, + select: { + id: true, + legalCaseId: true, + stepIndex: true, + totalSteps: true, + answersJson: true, + recommendedRouteJson: true, + updatedAt: true, + }, + }); + + return NextResponse.json({ + ok: true, + diagnosis: { + id: updated.id, + legalCaseId: updated.legalCaseId, + stepIndex: updated.stepIndex, + totalSteps: updated.totalSteps, + answers: updated.answersJson, + recommendation: updated.recommendedRouteJson, + updatedAt: updated.updatedAt.toISOString(), + }, + }); +} diff --git a/src/app/api/legal/diagnosis/start/route.ts b/src/app/api/legal/diagnosis/start/route.ts new file mode 100644 index 0000000..c74644f --- /dev/null +++ b/src/app/api/legal/diagnosis/start/route.ts @@ -0,0 +1,105 @@ +import type { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { evaluateDiagnosis, parseDiagnosisAnswers } from "@/lib/legal/diagnosis"; +import { LegalDiagnosisStartInputSchema } from "@/lib/legal/types"; + +function parseJsonBody(body: unknown) { + return typeof body === "object" && body !== null ? (body as Record) : {}; +} + +function logContinuityHandoff(event: string, data: Record) { + console.info( + "[continuity_handoff]", + JSON.stringify({ + event, + at: new Date().toISOString(), + ...data, + }), + ); +} + +export async function POST(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = parseJsonBody(await request.json().catch(() => ({}))); + const parsed = LegalDiagnosisStartInputSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Datos de inicio de diagnostico invalidos." }, { status: 400 }); + } + + const input = parsed.data; + logContinuityHandoff("m8_to_m9_diagnosis_start_requested", { + userId: user.id, + requestedContractId: input.contractId ?? null, + }); + + let contractId: string | null = null; + if (input.contractId) { + const ownedContract = await prisma.contractRecord.findFirst({ + where: { + id: input.contractId, + userId: user.id, + }, + select: { + id: true, + }, + }); + + if (!ownedContract) { + return NextResponse.json({ error: "Contrato no encontrado para iniciar diagnostico." }, { status: 404 }); + } + + contractId = ownedContract.id; + } + + logContinuityHandoff("m8_to_m9_diagnosis_started", { + userId: user.id, + persistedContractId: contractId, + }); + + const normalizedAnswers = parseDiagnosisAnswers({}); + const recommendation = evaluateDiagnosis(normalizedAnswers); + const answersJson: Record = { + ...normalizedAnswers, + }; + + if (contractId) { + answersJson.contractId = contractId; + } + + const diagnosis = await prisma.legalDiagnosis.create({ + data: { + userId: user.id, + answersJson: answersJson as Prisma.InputJsonValue, + recommendedRouteJson: recommendation as unknown as Prisma.InputJsonValue, + stepIndex: 1, + totalSteps: 4, + }, + select: { + id: true, + stepIndex: true, + totalSteps: true, + answersJson: true, + recommendedRouteJson: true, + }, + }); + + return NextResponse.json({ + ok: true, + diagnosis: { + id: diagnosis.id, + stepIndex: diagnosis.stepIndex, + totalSteps: diagnosis.totalSteps, + answers: diagnosis.answersJson, + contractId, + recommendation: diagnosis.recommendedRouteJson, + }, + }); +} diff --git a/src/app/api/legal/directory/route.ts b/src/app/api/legal/directory/route.ts new file mode 100644 index 0000000..d897e4c --- /dev/null +++ b/src/app/api/legal/directory/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { LegalDirectoryFiltersSchema } from "@/lib/legal/types"; +import { listLegalDirectory } from "@/lib/legal/server"; + +export async function GET(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const parsed = LegalDirectoryFiltersSchema.safeParse({ + jurisdictionLevel: searchParams.get("jurisdictionLevel") ?? undefined, + stateCode: searchParams.get("stateCode") ?? undefined, + q: searchParams.get("q") ?? undefined, + }); + + if (!parsed.success) { + return NextResponse.json({ error: "Filtros invalidos." }, { status: 400 }); + } + + const entities = await listLegalDirectory(parsed.data); + return NextResponse.json({ ok: true, entities }); +} diff --git a/src/app/api/legal/documents/generate/route.ts b/src/app/api/legal/documents/generate/route.ts new file mode 100644 index 0000000..05c112f --- /dev/null +++ b/src/app/api/legal/documents/generate/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { generateLegalDocument } from "@/lib/legal/ai-documents"; +import { LegalDocumentGenerateInputSchema } from "@/lib/legal/types"; + +function parseJsonBody(body: unknown) { + return typeof body === "object" && body !== null ? (body as Record) : {}; +} + +export async function POST(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = parseJsonBody(await request.json().catch(() => ({}))); + const parsed = LegalDocumentGenerateInputSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "Datos para generar escrito invalidos." }, { status: 400 }); + } + + const input = parsed.data; + + let legalCaseId: string | null = input.legalCaseId ?? null; + if (legalCaseId) { + const ownedCase = await prisma.legalCase.findFirst({ + where: { + id: legalCaseId, + userId: user.id, + }, + select: { id: true }, + }); + + if (!ownedCase) { + return NextResponse.json({ error: "Caso legal no encontrado." }, { status: 404 }); + } + + legalCaseId = ownedCase.id; + } + + const generated = await generateLegalDocument({ + legalCaseId, + caseType: input.caseType, + severity: input.severity, + templateKey: input.templateKey ?? null, + counterparty: input.counterparty, + companyName: input.companyName, + amountAtRisk: input.amountAtRisk ?? null, + description: input.description, + objective: input.objective, + }); + + const title = input.title?.trim() || generated.title; + + const document = await prisma.legalDocument.create({ + data: { + legalCaseId, + userId: user.id, + templateKey: input.templateKey ?? null, + aiGenerated: generated.engine === "openai_json_object", + title, + content: generated.content, + }, + }); + + return NextResponse.json({ + ok: true, + document: { + id: document.id, + legalCaseId: document.legalCaseId, + templateKey: document.templateKey, + aiGenerated: document.aiGenerated, + title: document.title, + content: document.content, + createdAt: document.createdAt.toISOString(), + }, + generation: { + engine: generated.engine, + model: generated.model, + warnings: generated.warnings, + usage: generated.usage, + }, + }); + } catch { + return NextResponse.json({ error: "No fue posible generar el escrito legal." }, { status: 400 }); + } +} diff --git a/src/app/api/legal/escalation/[caseId]/route.ts b/src/app/api/legal/escalation/[caseId]/route.ts new file mode 100644 index 0000000..1471638 --- /dev/null +++ b/src/app/api/legal/escalation/[caseId]/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { getEscalationForCase } from "@/lib/legal/server"; + +type RouteContext = { + params: Promise<{ caseId: string }>; +}; + +const VALID_STEP_KEYS = new Set([ + "formal_requirement", + "administrative_conciliation", + "oic_complaint", + "sfp_or_state_challenge", + "jurisdictional_path", +]); + +function parseJsonBody(body: unknown) { + return typeof body === "object" && body !== null ? (body as Record) : {}; +} + +export async function GET(_request: Request, context: RouteContext) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { caseId } = await context.params; + const escalation = await getEscalationForCase(user.id, caseId); + + if (!escalation) { + return NextResponse.json({ error: "Caso no encontrado." }, { status: 404 }); + } + + return NextResponse.json({ ok: true, escalation }); +} + +export async function POST(request: Request, context: RouteContext) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { caseId } = await context.params; + const body = parseJsonBody(await request.json().catch(() => ({}))); + + const routeStepKey = typeof body.routeStepKey === "string" ? body.routeStepKey.trim() : ""; + const notes = typeof body.notes === "string" ? body.notes.trim() : ""; + const completed = body.completed === false ? false : true; + + if (!VALID_STEP_KEYS.has(routeStepKey)) { + return NextResponse.json({ error: "routeStepKey invalido." }, { status: 400 }); + } + + const legalCase = await prisma.legalCase.findFirst({ + where: { + id: caseId, + userId: user.id, + }, + select: { id: true }, + }); + + if (!legalCase) { + return NextResponse.json({ error: "Caso no encontrado." }, { status: 404 }); + } + + await prisma.legalEscalationStepLog.upsert({ + where: { + legalCaseId_routeStepKey: { + legalCaseId: caseId, + routeStepKey, + }, + }, + update: { + completedAt: completed ? new Date() : null, + notes, + }, + create: { + legalCaseId: caseId, + routeStepKey, + completedAt: completed ? new Date() : null, + notes, + }, + }); + + const escalation = await getEscalationForCase(user.id, caseId); + return NextResponse.json({ ok: true, escalation }); +} diff --git a/src/app/api/legal/kpis/route.ts b/src/app/api/legal/kpis/route.ts new file mode 100644 index 0000000..35872ac --- /dev/null +++ b/src/app/api/legal/kpis/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { getLegalKpisForUser } from "@/lib/legal/server"; + +export async function GET() { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const kpis = await getLegalKpisForUser(user.id); + return NextResponse.json({ ok: true, kpis }); +} diff --git a/src/app/api/legal/templates/route.ts b/src/app/api/legal/templates/route.ts new file mode 100644 index 0000000..9db1b08 --- /dev/null +++ b/src/app/api/legal/templates/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { listLegalTemplates } from "@/lib/legal/ai-documents"; + +export async function GET() { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return NextResponse.json({ ok: true, templates: listLegalTemplates() }); +} diff --git a/src/app/api/licitations/ai/recommendations/route.ts b/src/app/api/licitations/ai/recommendations/route.ts new file mode 100644 index 0000000..b501b7c --- /dev/null +++ b/src/app/api/licitations/ai/recommendations/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { getSessionPayload } from "@/lib/auth/session"; +import { getAiEnhancedLicitationRecommendationsForUser } from "@/lib/licitations/ai-recommendations"; + +function parseTop(value: string | null) { + if (!value) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +export async function GET(request: Request) { + const session = await getSessionPayload(); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(request.url); + const profileId = url.searchParams.get("profileId") ?? url.searchParams.get("profile_id"); + const top = parseTop(url.searchParams.get("top")); + const payload = await getAiEnhancedLicitationRecommendationsForUser(session.userId, { + profileId, + top, + }); + + return NextResponse.json({ + ok: true, + ...payload, + }); +} diff --git a/src/app/api/licitations/preferences/[id]/route.ts b/src/app/api/licitations/preferences/[id]/route.ts new file mode 100644 index 0000000..85e30b6 --- /dev/null +++ b/src/app/api/licitations/preferences/[id]/route.ts @@ -0,0 +1,78 @@ +import { Prisma, type LicitationReviewStatus } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { parseLicitationReviewStatus } from "@/lib/licitations/preferences"; +import { prisma } from "@/lib/prisma"; + +function isSchemaNotReadyError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022"); +} + +export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id } = await context.params; + const body = (await request.json()) as Record; + const status = parseLicitationReviewStatus(body.status); + + if (!status) { + return NextResponse.json({ error: "Estatus invalido." }, { status: 400 }); + } + + const licitation = await prisma.licitation.findUnique({ + where: { id }, + select: { id: true }, + }); + + if (!licitation) { + return NextResponse.json({ error: "Licitacion no encontrada." }, { status: 404 }); + } + + if (status === "NEW") { + await prisma.licitationUserPreference.deleteMany({ + where: { + userId: user.id, + licitationId: id, + }, + }); + + return NextResponse.json({ ok: true, status: "NEW" }); + } + + const updated = await prisma.licitationUserPreference.upsert({ + where: { + userId_licitationId: { + userId: user.id, + licitationId: id, + }, + }, + update: { + status: status as LicitationReviewStatus, + }, + create: { + userId: user.id, + licitationId: id, + status: status as LicitationReviewStatus, + }, + select: { + status: true, + }, + }); + + return NextResponse.json({ ok: true, status: updated.status }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene las tablas de estado de oportunidades. Ejecuta prisma migrate para continuar." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible actualizar el estado." }, { status: 400 }); + } +} diff --git a/src/app/api/normative-analysis/history/[id]/route.ts b/src/app/api/normative-analysis/history/[id]/route.ts new file mode 100644 index 0000000..db5870b --- /dev/null +++ b/src/app/api/normative-analysis/history/[id]/route.ts @@ -0,0 +1,46 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; + +function isSchemaNotReadyError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022"); +} + +export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id } = await context.params; + + const deleted = await prisma.normativeAnalysisHistory.updateMany({ + where: { + id, + userId: user.id, + deletedAt: null, + }, + data: { + deletedAt: new Date(), + }, + }); + + if (deleted.count === 0) { + return NextResponse.json({ error: "Analisis no encontrado." }, { status: 404 }); + } + + return NextResponse.json({ ok: true }); + } 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 }, + ); + } + + return NextResponse.json({ error: "No fue posible eliminar el analisis." }, { status: 400 }); + } +} diff --git a/src/app/api/normative-analysis/history/route.ts b/src/app/api/normative-analysis/history/route.ts new file mode 100644 index 0000000..b8be7bf --- /dev/null +++ b/src/app/api/normative-analysis/history/route.ts @@ -0,0 +1,98 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { toNormativeHistoryView } from "@/lib/normative-analysis/history"; +import { prisma } from "@/lib/prisma"; + +function parsePositiveInt(value: string | null, fallback: number) { + if (!value) { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + + return parsed; +} + +function isSchemaNotReadyError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022"); +} + +export async function GET(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const url = new URL(request.url); + const take = Math.min(parsePositiveInt(url.searchParams.get("take"), 50), 100); + const proposalId = url.searchParams.get("proposalId")?.trim() || null; + let sourceLicitationId = url.searchParams.get("sourceId")?.trim() || null; + + if (!sourceLicitationId && proposalId) { + const proposal = await prisma.proposal.findFirst({ + where: { + id: proposalId, + userId: user.id, + }, + select: { + sourceLicitationId: true, + }, + }); + + if (!proposal?.sourceLicitationId) { + return NextResponse.json({ + ok: true, + items: [], + }); + } + + sourceLicitationId = proposal.sourceLicitationId; + } + + const history = await prisma.normativeAnalysisHistory.findMany({ + where: { + userId: user.id, + deletedAt: null, + ...(sourceLicitationId ? { sourceLicitationId } : {}), + }, + orderBy: [{ analyzedAt: "desc" }], + take, + 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, + items: history.map((entry) => toNormativeHistoryView(entry)), + }); + } 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 }, + ); + } + + return NextResponse.json({ error: "No fue posible consultar el historial." }, { status: 400 }); + } +} diff --git a/src/app/api/normative-analysis/route.ts b/src/app/api/normative-analysis/route.ts new file mode 100644 index 0000000..9a2e5cc --- /dev/null +++ b/src/app/api/normative-analysis/route.ts @@ -0,0 +1,612 @@ +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 }); + } +} diff --git a/src/app/api/payments/checkout/return/route.ts b/src/app/api/payments/checkout/return/route.ts new file mode 100644 index 0000000..4dc400b --- /dev/null +++ b/src/app/api/payments/checkout/return/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { buildAppUrl } from "@/lib/http/url"; +import { fetchMercadoPaymentById, hasMercadoPagoConfig } from "@/lib/payments/mercadopago"; +import { syncPurchaseFromPayment } from "@/lib/payments/subscriptions"; +import { prisma } from "@/lib/prisma"; + +function pickFirst(url: URL, keys: string[]) { + for (const key of keys) { + const value = url.searchParams.get(key)?.trim(); + + if (value) { + return value; + } + } + + return ""; +} + +export async function GET(request: Request) { + const requestUrl = new URL(request.url); + const result = requestUrl.searchParams.get("result")?.trim() || "pending"; + const paymentId = pickFirst(requestUrl, ["payment_id", "collection_id"]); + const externalReference = pickFirst(requestUrl, ["external_reference"]); + const fallbackStatus = pickFirst(requestUrl, ["status", "collection_status"]) || result; + const merchantOrderId = pickFirst(requestUrl, ["merchant_order_id"]); + + if (paymentId && externalReference && hasMercadoPagoConfig()) { + try { + const payment = await fetchMercadoPaymentById(paymentId); + const resolvedExternalReference = payment.external_reference || externalReference; + + await syncPurchaseFromPayment(prisma, { + externalReference: resolvedExternalReference, + paymentId, + paymentStatus: payment.status, + mercadoOrderId: payment.order?.id || merchantOrderId || null, + rawPayload: payment, + }); + } catch { + // Callback reconciliation is best-effort; webhook still updates asynchronously. + } + } + + return NextResponse.redirect( + buildAppUrl(request, "/dashboard", { + mp_result: result, + mp_payment_status: fallbackStatus, + }), + ); +} diff --git a/src/app/api/payments/checkout/route.ts b/src/app/api/payments/checkout/route.ts new file mode 100644 index 0000000..147107e --- /dev/null +++ b/src/app/api/payments/checkout/route.ts @@ -0,0 +1,104 @@ +import { randomUUID } from "crypto"; +import type { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { getSessionPayload } from "@/lib/auth/session"; +import { buildAppUrl, getRequestOrigin } from "@/lib/http/url"; +import { createMercadoPreference, hasMercadoPagoConfig } from "@/lib/payments/mercadopago"; +import { getModulePlanByUiKey } from "@/lib/payments/plans"; +import { prisma } from "@/lib/prisma"; + +function redirectToDashboard(request: Request, params: Record) { + return NextResponse.redirect(buildAppUrl(request, "/dashboard", params)); +} + +export async function GET(request: Request) { + const session = await getSessionPayload(); + + if (!session) { + return NextResponse.redirect(buildAppUrl(request, "/login", { error: "auth_required" })); + } + + const requestUrl = new URL(request.url); + const planUiKey = requestUrl.searchParams.get("plan")?.trim() || ""; + const selectedPlan = getModulePlanByUiKey(planUiKey); + + if (!selectedPlan) { + return redirectToDashboard(request, { mp_error: "invalid_plan" }); + } + + if (!hasMercadoPagoConfig()) { + return redirectToDashboard(request, { mp_error: "missing_mp_config" }); + } + + const user = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { + id: true, + email: true, + }, + }); + + if (!user) { + return NextResponse.redirect(buildAppUrl(request, "/login", { error: "auth_required" })); + } + + const externalReference = `${selectedPlan.key}_${user.id}_${Date.now()}_${randomUUID().slice(0, 8)}`; + const origin = getRequestOrigin(request); + const returnBase = `${origin}/api/payments/checkout/return`; + + const createdPurchase = await prisma.modulePlanPurchase.create({ + data: { + userId: user.id, + planKey: selectedPlan.key, + amount: selectedPlan.monthlyPrice, + currency: selectedPlan.currency, + externalReference, + requestJson: { + uiPlan: selectedPlan.uiKey, + name: selectedPlan.name, + }, + }, + select: { + id: true, + }, + }); + + try { + const preference = await createMercadoPreference({ + externalReference, + payerEmail: user.email, + items: [ + { + id: selectedPlan.uiKey, + title: `${selectedPlan.name} Modulos ${selectedPlan.moduleStart}-${selectedPlan.moduleEnd}`, + quantity: 1, + unit_price: selectedPlan.monthlyPrice, + currency_id: selectedPlan.currency, + }, + ], + successUrl: `${returnBase}?result=success`, + failureUrl: `${returnBase}?result=failure`, + pendingUrl: `${returnBase}?result=pending`, + notificationUrl: `${origin}/api/payments/mercadopago/webhook`, + }); + + const checkoutUrl = preference.initPoint || preference.sandboxInitPoint; + + await prisma.modulePlanPurchase.update({ + where: { id: createdPurchase.id }, + data: { + mercadoPreferenceId: preference.id || null, + checkoutUrl: checkoutUrl || null, + responseJson: preference.rawPayload as Prisma.InputJsonValue, + }, + }); + + if (!checkoutUrl) { + return redirectToDashboard(request, { mp_error: "missing_checkout_url" }); + } + + return NextResponse.redirect(checkoutUrl); + } catch { + return redirectToDashboard(request, { mp_error: "checkout_create_failed" }); + } +} diff --git a/src/app/api/payments/mercadopago/webhook/route.ts b/src/app/api/payments/mercadopago/webhook/route.ts new file mode 100644 index 0000000..84a8c63 --- /dev/null +++ b/src/app/api/payments/mercadopago/webhook/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { fetchMercadoPaymentById, hasMercadoPagoConfig } from "@/lib/payments/mercadopago"; +import { syncPurchaseFromPayment } from "@/lib/payments/subscriptions"; +import { prisma } from "@/lib/prisma"; + +function parseBody(body: unknown) { + return typeof body === "object" && body !== null ? (body as Record) : {}; +} + +function parseWebhookPaymentId(url: URL, body: Record) { + const fromQuery = url.searchParams.get("data.id")?.trim() || url.searchParams.get("id")?.trim() || ""; + + if (fromQuery) { + return fromQuery; + } + + const data = typeof body.data === "object" && body.data !== null ? (body.data as Record) : null; + return typeof data?.id === "string" || typeof data?.id === "number" ? String(data.id) : ""; +} + +async function processWebhook(request: Request, body: Record) { + if (!hasMercadoPagoConfig()) { + return NextResponse.json({ ok: true }); + } + + const requestUrl = new URL(request.url); + const eventType = (typeof body.type === "string" ? body.type : requestUrl.searchParams.get("type") || requestUrl.searchParams.get("topic") || "").toLowerCase(); + const paymentId = parseWebhookPaymentId(requestUrl, body); + + if (eventType && eventType !== "payment") { + return NextResponse.json({ ok: true }); + } + + if (!paymentId) { + return NextResponse.json({ ok: true }); + } + + try { + const payment = await fetchMercadoPaymentById(paymentId); + + if (!payment.external_reference) { + return NextResponse.json({ ok: true }); + } + + await syncPurchaseFromPayment(prisma, { + externalReference: payment.external_reference, + paymentId, + paymentStatus: payment.status, + mercadoOrderId: payment.order?.id || null, + rawPayload: payment, + }); + + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json({ ok: true }); + } +} + +export async function POST(request: Request) { + const body = parseBody(await request.json().catch(() => ({}))); + return processWebhook(request, body); +} + +export async function GET(request: Request) { + return processWebhook(request, {}); +} diff --git a/src/app/api/proposals/[id]/documents/[documentId]/route.test.ts b/src/app/api/proposals/[id]/documents/[documentId]/route.test.ts new file mode 100644 index 0000000..bb7c4df --- /dev/null +++ b/src/app/api/proposals/[id]/documents/[documentId]/route.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { requireAdminApiUserMock, readStoredProposalDocumentMock, prismaMock } = vi.hoisted(() => ({ + requireAdminApiUserMock: vi.fn(), + readStoredProposalDocumentMock: vi.fn(), + prismaMock: { + proposalDocument: { + findFirst: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/admin", () => ({ + requireAdminApiUser: requireAdminApiUserMock, +})); + +vi.mock("@/lib/proposals/storage", async () => { + const actual = await vi.importActual("@/lib/proposals/storage"); + return { + ...actual, + readStoredProposalDocument: readStoredProposalDocumentMock, + }; +}); + +vi.mock("@/lib/prisma", () => ({ + prisma: prismaMock, +})); + +import { GET } from "@/app/api/proposals/[id]/documents/[documentId]/route"; + +describe("proposal document download API security", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 404 for non-owned or missing proposal document", async () => { + requireAdminApiUserMock.mockResolvedValue({ id: "user-1" }); + prismaMock.proposalDocument.findFirst.mockResolvedValue(null); + + const response = await GET(new Request("http://localhost/api/proposals/p1/documents/d1"), { + params: Promise.resolve({ id: "p1", documentId: "d1" }), + }); + const payload = (await response.json()) as { error?: string }; + + expect(response.status).toBe(404); + expect(payload.error).toContain("Documento no encontrado"); + expect(readStoredProposalDocumentMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when storage path is invalid", async () => { + requireAdminApiUserMock.mockResolvedValue({ id: "user-1" }); + prismaMock.proposalDocument.findFirst.mockResolvedValue({ + id: "d1", + fileName: "propuesta.pdf", + filePath: "../outside.pdf", + mimeType: "application/pdf", + }); + readStoredProposalDocumentMock.mockRejectedValue(new Error("PROPOSAL_DOCUMENT_INVALID_PATH")); + + const response = await GET(new Request("http://localhost/api/proposals/p1/documents/d1"), { + params: Promise.resolve({ id: "p1", documentId: "d1" }), + }); + const payload = (await response.json()) as { error?: string }; + + expect(response.status).toBe(400); + expect(payload.error).toContain("Ruta de almacenamiento invalida"); + }); +}); diff --git a/src/app/api/proposals/[id]/documents/[documentId]/route.ts b/src/app/api/proposals/[id]/documents/[documentId]/route.ts new file mode 100644 index 0000000..687fc08 --- /dev/null +++ b/src/app/api/proposals/[id]/documents/[documentId]/route.ts @@ -0,0 +1,145 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { readStoredProposalDocument, removeStoredProposalDocument } from "@/lib/proposals/storage"; + +export const runtime = "nodejs"; + +function isSchemaNotReadyError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022"); +} + +function safeContentDispositionFilename(value: string) { + const normalized = value.trim().replace(/[\r\n"]/g, "_"); + return normalized || "documento"; +} + +function mapDocumentReadError(error: unknown) { + if (!(error instanceof Error)) { + return null; + } + + if (error.message === "PROPOSAL_DOCUMENT_NOT_FOUND") { + return { status: 404, message: "Archivo no encontrado en almacenamiento." }; + } + + if (error.message === "PROPOSAL_DOCUMENT_INVALID_PATH") { + return { status: 400, message: "Ruta de almacenamiento invalida." }; + } + + if (error.message === "PROPOSAL_DOCUMENT_EMPTY") { + return { status: 400, message: "Archivo vacio en almacenamiento." }; + } + + if (error.message === "PROPOSAL_DOCUMENT_TOO_LARGE") { + return { status: 400, message: "Archivo excede el limite permitido." }; + } + + return null; +} + +async function getOwnedProposalDocument(params: { userId: string; proposalId: string; documentId: string }) { + return prisma.proposalDocument.findFirst({ + where: { + id: params.documentId, + proposalId: params.proposalId, + userId: params.userId, + }, + select: { + id: true, + fileName: true, + filePath: true, + mimeType: true, + }, + }); +} + +export async function GET(_: Request, context: { params: Promise<{ id: string; documentId: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id, documentId } = await context.params; + + const document = await getOwnedProposalDocument({ + userId: user.id, + proposalId: id, + documentId, + }); + + if (!document) { + return NextResponse.json({ error: "Documento no encontrado." }, { status: 404 }); + } + + let fileBuffer: Buffer; + try { + fileBuffer = await readStoredProposalDocument(document.filePath); + } catch (error) { + const mapped = mapDocumentReadError(error); + if (mapped) { + return NextResponse.json({ error: mapped.message }, { status: mapped.status }); + } + + throw error; + } + + const encodedFilename = encodeURIComponent(document.fileName); + return new NextResponse(new Uint8Array(fileBuffer), { + status: 200, + headers: { + "Content-Type": document.mimeType || "application/octet-stream", + "Content-Disposition": `attachment; filename="${safeContentDispositionFilename(document.fileName)}"; filename*=UTF-8''${encodedFilename}`, + "Cache-Control": "no-store", + }, + }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible descargar el documento." }, { status: 400 }); + } +} + +export async function DELETE(_: Request, context: { params: Promise<{ id: string; documentId: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id, documentId } = await context.params; + + const document = await getOwnedProposalDocument({ + userId: user.id, + proposalId: id, + documentId, + }); + + if (!document) { + return NextResponse.json({ error: "Documento no encontrado." }, { status: 404 }); + } + + await removeStoredProposalDocument(document.filePath); + await prisma.proposalDocument.delete({ where: { id: document.id } }); + + return NextResponse.json({ ok: true }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible eliminar el documento." }, { status: 400 }); + } +} diff --git a/src/app/api/proposals/[id]/documents/route.ts b/src/app/api/proposals/[id]/documents/route.ts new file mode 100644 index 0000000..d986297 --- /dev/null +++ b/src/app/api/proposals/[id]/documents/route.ts @@ -0,0 +1,170 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { + MAX_PROPOSAL_DOCUMENT_BYTES, + isAllowedProposalMimeType, + storeProposalDocument, +} from "@/lib/proposals/storage"; + +function isSchemaNotReadyError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022"); +} + +function toProposalPayload( + proposal: { + id: string; + title: string; + issuingEntity: string; + summary: string; + status: string; + sourceLicitationId: string | null; + createdAt: Date; + updatedAt: Date; + documents: Array<{ + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; + createdAt: Date; + }>; + } & { + currentStep?: number; + completionPercent?: number; + readyForSubmissionAt?: Date | null; + }, +) { + return { + id: proposal.id, + title: proposal.title, + issuingEntity: proposal.issuingEntity, + summary: proposal.summary, + currentStep: proposal.currentStep ?? 1, + completionPercent: proposal.completionPercent ?? 0, + readyForSubmissionAt: proposal.readyForSubmissionAt ? proposal.readyForSubmissionAt.toISOString() : null, + status: proposal.status, + sourceLicitationId: proposal.sourceLicitationId, + createdAt: proposal.createdAt.toISOString(), + updatedAt: proposal.updatedAt.toISOString(), + documents: proposal.documents.map((document) => ({ + id: document.id, + fileName: document.fileName, + mimeType: document.mimeType, + sizeBytes: document.sizeBytes, + createdAt: document.createdAt.toISOString(), + })), + }; +} + +export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id } = await context.params; + const formData = await request.formData(); + const file = formData.get("file"); + + if (!(file instanceof File)) { + return NextResponse.json({ error: "Archivo requerido." }, { status: 400 }); + } + + if (file.size <= 0) { + return NextResponse.json({ error: "El archivo esta vacio." }, { status: 400 }); + } + + if (file.size > MAX_PROPOSAL_DOCUMENT_BYTES) { + return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 }); + } + + if (!isAllowedProposalMimeType(file.type)) { + return NextResponse.json({ error: "Tipo de archivo no permitido (PDF, DOC, DOCX, XLSX, JPG o PNG)." }, { status: 400 }); + } + + const proposal = await prisma.proposal.findFirst({ + where: { + id, + userId: user.id, + }, + select: { + id: true, + status: true, + }, + }); + + if (!proposal) { + return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 }); + } + + const fileBuffer = Buffer.from(await file.arrayBuffer()); + const stored = await storeProposalDocument(user.id, proposal.id, file.name, file.type, fileBuffer); + + await prisma.proposalDocument.create({ + data: { + proposalId: proposal.id, + userId: user.id, + fileName: stored.fileName, + storedFileName: stored.storedFileName, + filePath: stored.filePath, + mimeType: stored.mimeType, + sizeBytes: stored.sizeBytes, + checksumSha256: stored.checksumSha256, + }, + }); + + if (proposal.status === "DRAFT") { + await prisma.proposal.update({ + where: { id: proposal.id }, + data: { + status: "IN_PROGRESS", + }, + }); + } + + const updated = await prisma.proposal.findUnique({ + where: { id: proposal.id }, + select: { + id: true, + title: true, + issuingEntity: true, + summary: true, + status: true, + sourceLicitationId: true, + createdAt: true, + updatedAt: true, + documents: { + orderBy: [{ createdAt: "desc" }], + select: { + id: true, + fileName: true, + mimeType: true, + sizeBytes: true, + createdAt: true, + }, + }, + }, + }); + + if (!updated) { + return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 }); + } + + return NextResponse.json({ + ok: true, + proposal: toProposalPayload(updated), + }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible subir el documento." }, { status: 400 }); + } +} diff --git a/src/app/api/proposals/[id]/pdf/[kind]/route.ts b/src/app/api/proposals/[id]/pdf/[kind]/route.ts new file mode 100644 index 0000000..41dbfc6 --- /dev/null +++ b/src/app/api/proposals/[id]/pdf/[kind]/route.ts @@ -0,0 +1,106 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { buildPdfFilename, generateProposalPdf, type ProposalPdfKind } from "@/lib/proposals/pdf"; +import { ProposalWorkflowStateSchema } from "@/lib/proposals/workflow-state"; + +export const runtime = "nodejs"; + +function isSchemaNotReadyError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022"); +} + +function parseKind(value: string): ProposalPdfKind | null { + if (value === "technical" || value === "economic" || value === "summary") { + return value; + } + + return null; +} + +export async function GET(_: Request, context: { params: Promise<{ id: string; kind: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id, kind: kindParam } = await context.params; + const kind = parseKind(kindParam); + if (!kind) { + return NextResponse.json({ error: "Tipo de PDF invalido." }, { status: 400 }); + } + + const proposal = await prisma.proposal.findFirst({ + where: { + id, + userId: user.id, + }, + select: { + id: true, + title: true, + issuingEntity: true, + summary: true, + status: true, + currentStep: true, + completionPercent: true, + updatedAt: true, + workflowDraft: true, + }, + }); + + if (!proposal) { + return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 }); + } + + const workflowParsed = ProposalWorkflowStateSchema.safeParse(proposal.workflowDraft); + if (!workflowParsed.success) { + return NextResponse.json({ error: "No hay flujo persistido valido para generar PDF." }, { status: 400 }); + } + + const buffer = await generateProposalPdf({ + kind, + proposal: { + title: proposal.title, + issuingEntity: proposal.issuingEntity, + summary: proposal.summary, + status: proposal.status, + currentStep: proposal.currentStep, + completionPercent: proposal.completionPercent, + updatedAtIso: proposal.updatedAt.toISOString(), + }, + workflow: workflowParsed.data, + }); + + const filename = buildPdfFilename({ + kind, + title: proposal.title, + }); + const body = new Uint8Array(buffer); + + return new NextResponse(body, { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="${filename}"`, + "Cache-Control": "no-store", + }, + }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." }, + { status: 503 }, + ); + } + + return NextResponse.json( + { + error: "No fue posible generar el PDF. Verifica que Playwright y Chromium esten disponibles en el entorno.", + }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/proposals/[id]/route.ts b/src/app/api/proposals/[id]/route.ts new file mode 100644 index 0000000..7928159 --- /dev/null +++ b/src/app/api/proposals/[id]/route.ts @@ -0,0 +1,262 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { canTransitionProposalStatus, parseProposalStatus } from "@/lib/proposals/status"; +import { removeStoredProposalDocument } from "@/lib/proposals/storage"; + +function isSchemaNotReadyError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022"); +} + +function parseString(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function toProposalPayload( + proposal: { + id: string; + title: string; + issuingEntity: string; + summary: string; + status: string; + sourceLicitationId: string | null; + createdAt: Date; + updatedAt: Date; + documents: Array<{ + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; + createdAt: Date; + filePath?: string; + }>; + } & { + currentStep?: number; + completionPercent?: number; + readyForSubmissionAt?: Date | null; + }, +) { + return { + id: proposal.id, + title: proposal.title, + issuingEntity: proposal.issuingEntity, + summary: proposal.summary, + currentStep: proposal.currentStep ?? 1, + completionPercent: proposal.completionPercent ?? 0, + readyForSubmissionAt: proposal.readyForSubmissionAt ? proposal.readyForSubmissionAt.toISOString() : null, + status: proposal.status, + sourceLicitationId: proposal.sourceLicitationId, + createdAt: proposal.createdAt.toISOString(), + updatedAt: proposal.updatedAt.toISOString(), + documents: proposal.documents.map((document) => ({ + id: document.id, + fileName: document.fileName, + mimeType: document.mimeType, + sizeBytes: document.sizeBytes, + createdAt: document.createdAt.toISOString(), + })), + }; +} + +export async function GET(_: Request, context: { params: Promise<{ id: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id } = await context.params; + + const proposal = await prisma.proposal.findFirst({ + where: { + id, + userId: user.id, + }, + select: { + id: true, + title: true, + issuingEntity: true, + summary: true, + status: true, + sourceLicitationId: true, + createdAt: true, + updatedAt: true, + documents: { + orderBy: [{ createdAt: "desc" }], + select: { + id: true, + fileName: true, + mimeType: true, + sizeBytes: true, + createdAt: true, + }, + }, + }, + }); + + if (!proposal) { + return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 }); + } + + return NextResponse.json({ + ok: true, + proposal: toProposalPayload(proposal), + }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible obtener la propuesta." }, { status: 400 }); + } +} + +export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id } = await context.params; + const body = (await request.json()) as Record; + + const proposal = await prisma.proposal.findFirst({ + where: { + id, + userId: user.id, + }, + select: { + id: true, + title: true, + issuingEntity: true, + summary: true, + status: true, + sourceLicitationId: true, + createdAt: true, + updatedAt: true, + documents: { + orderBy: [{ createdAt: "desc" }], + select: { + id: true, + fileName: true, + mimeType: true, + sizeBytes: true, + createdAt: true, + }, + }, + }, + }); + + if (!proposal) { + return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 }); + } + + const title = parseString(body.title); + const issuingEntity = parseString(body.issuingEntity); + const summary = parseString(body.summary); + const nextStatus = parseProposalStatus(body.status); + + if (body.status != null && !nextStatus) { + return NextResponse.json({ error: "Estatus invalido." }, { status: 400 }); + } + + if (nextStatus && !canTransitionProposalStatus(proposal.status, nextStatus)) { + return NextResponse.json({ error: "La transicion de estatus no es valida." }, { status: 400 }); + } + + const updated = await prisma.proposal.update({ + where: { id: proposal.id }, + data: { + ...(title ? { title } : {}), + ...(issuingEntity ? { issuingEntity } : {}), + ...(summary || body.summary === "" ? { summary } : {}), + ...(nextStatus ? { status: nextStatus } : {}), + }, + select: { + id: true, + title: true, + issuingEntity: true, + summary: true, + status: true, + sourceLicitationId: true, + createdAt: true, + updatedAt: true, + documents: { + orderBy: [{ createdAt: "desc" }], + select: { + id: true, + fileName: true, + mimeType: true, + sizeBytes: true, + createdAt: true, + }, + }, + }, + }); + + return NextResponse.json({ + ok: true, + proposal: toProposalPayload(updated), + }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible actualizar la propuesta." }, { status: 400 }); + } +} + +export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id } = await context.params; + + const proposal = await prisma.proposal.findFirst({ + where: { + id, + userId: user.id, + }, + select: { + id: true, + documents: true, + }, + }); + + if (!proposal) { + return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 }); + } + + for (const document of proposal.documents) { + await removeStoredProposalDocument(document.filePath); + } + + await prisma.proposal.delete({ where: { id: proposal.id } }); + + return NextResponse.json({ ok: true }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible eliminar la propuesta." }, { status: 400 }); + } +} diff --git a/src/app/api/proposals/[id]/workflow/extract/route.test.ts b/src/app/api/proposals/[id]/workflow/extract/route.test.ts new file mode 100644 index 0000000..f0b0719 --- /dev/null +++ b/src/app/api/proposals/[id]/workflow/extract/route.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { requireAdminApiUserMock, prismaMock } = vi.hoisted(() => ({ + requireAdminApiUserMock: vi.fn(), + prismaMock: { + licitation: { + findUnique: vi.fn(), + }, + normativeAnalysisHistory: { + findFirst: vi.fn(), + create: vi.fn(), + }, + proposal: { + findFirst: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/admin", () => ({ + requireAdminApiUser: requireAdminApiUserMock, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: prismaMock, +})); + +import { POST } from "@/app/api/proposals/[id]/workflow/extract/route"; + +describe("M4 -> M5 extract continuity regression", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("blocks extraction when proposal has no M4 source and no uploaded PDF", async () => { + requireAdminApiUserMock.mockResolvedValue({ id: "user-1" }); + prismaMock.proposal.findFirst.mockResolvedValue({ + id: "proposal-1", + title: "Propuesta sin fuente", + issuingEntity: "Entidad", + summary: "Resumen", + status: "DRAFT", + sourceLicitationId: null, + workflowDraft: null, + }); + + const request = new Request("http://localhost/api/proposals/proposal-1/workflow/extract", { + method: "POST", + body: new FormData(), + }); + + const response = await POST(request, { + params: Promise.resolve({ id: "proposal-1" }), + }); + const payload = (await response.json()) as { error?: string }; + + expect(response.status).toBe(400); + expect(payload.error).toContain("no tiene fuente de licitacion"); + expect(prismaMock.normativeAnalysisHistory.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/proposals/[id]/workflow/extract/route.ts b/src/app/api/proposals/[id]/workflow/extract/route.ts new file mode 100644 index 0000000..417b8b3 --- /dev/null +++ b/src/app/api/proposals/[id]/workflow/extract/route.ts @@ -0,0 +1,740 @@ +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 { evaluateSignaturePolicy } from "@/lib/compliance/signature-policy"; +import { parseLicitationDocumentLinks, rankLicitationDocumentsForBundle, type LicitationBundleDocumentKind } from "@/lib/licitations/documents"; +import { type NormativeDocumentType } from "@/lib/normative-analysis/analyze"; +import { analyzeNormativeTextWithAi } from "@/lib/normative-analysis/ai-analyze"; +import { mergeNormativeBundleSources, type NormativeBundleSourceAnalysis } from "@/lib/normative-analysis/bundle"; +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"; +import { canTransitionProposalStatus } from "@/lib/proposals/status"; +import { buildDefaultWorkflowDraft } from "@/lib/proposals/workflow-default"; +import { applyAiSeedsToWorkflow, buildMilestoneSeedsFromAnalysis, buildRequirementSeeds, buildTechnicalSectionSeeds, type WorkflowMilestoneSeed } from "@/lib/proposals/workflow"; +import { ProposalWorkflowStateSchema, computeWorkflowCompletionPercent, computeWorkflowGate } from "@/lib/proposals/workflow-state"; + +export const runtime = "nodejs"; + +const MAX_NORMATIVE_PDF_BYTES = 20 * 1024 * 1024; +const SOURCE_FETCH_TIMEOUT_MS = 45_000; + +type BundleCandidate = { + sourceId: string; + fileName: string; + documentType: NormativeDocumentType; + fileBuffer: Buffer; +}; + +function logContinuityHandoff(event: string, data: Record) { + console.info( + "[continuity_handoff]", + JSON.stringify({ + event, + at: new Date().toISOString(), + ...data, + }), + ); +} + +function isSchemaNotReadyError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022"); +} + +function parseDocumentType(value: unknown): NormativeDocumentType { + if (typeof value !== "string") { + return "BASES_LICITACION"; + } + + const normalized = value.trim().toUpperCase(); + if (normalized === "BASES_LICITACION" || normalized === "CONVOCATORIA" || normalized === "ANEXOS" || normalized === "REGLAMENTO" || normalized === "LEY") { + return normalized; + } + + return "OTRO"; +} + +function mapBundleKindToDocumentType(kind: LicitationBundleDocumentKind): NormativeDocumentType { + if (kind === "BASES_LICITACION") { + return "BASES_LICITACION"; + } + + if (kind === "CONVOCATORIA") { + return "CONVOCATORIA"; + } + + if (kind === "ANEXOS") { + return "ANEXOS"; + } + + return "OTRO"; +} + +function inferDocumentTypeFromName(fileName: string): NormativeDocumentType { + const lower = fileName + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase(); + + if (/\bbase(s)?\b|\bpliego\b|\bterminos\b/.test(lower)) { + return "BASES_LICITACION"; + } + + if (/\bconvocatoria\b|\binvitacion\b/.test(lower)) { + return "CONVOCATORIA"; + } + + if (/\banexo(s)?\b|\bformato(s)?\b/.test(lower)) { + return "ANEXOS"; + } + + return "OTRO"; +} + +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"; + } +} + +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 hasPdfSignature(buffer: Buffer) { + return buffer.subarray(0, 5).toString("utf8") === "%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"); + } + + if (!hasPdfSignature(buffer)) { + throw new Error("SOURCE_PDF_INVALID"); + } + + return buffer; + } finally { + clearTimeout(timer); + } +} + +function parseDateCandidate(value: unknown) { + if (typeof value !== "string") { + return null; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + + return parsed; +} + +function toMilestoneSeeds(args: { + openingDate: Date | null; + closingDate: Date | null; + eventDates: Prisma.JsonValue | null; +}): WorkflowMilestoneSeed[] { + const milestones: WorkflowMilestoneSeed[] = []; + + if (args.openingDate) { + milestones.push({ + id: "opening-date", + title: "Apertura del proceso", + dateIso: args.openingDate.toISOString(), + location: "", + note: "", + source: "licitation", + }); + } + + if (args.closingDate) { + milestones.push({ + id: "closing-date", + title: "Presentacion de propuestas", + dateIso: args.closingDate.toISOString(), + location: "", + note: "", + source: "licitation", + }); + } + + if (args.eventDates && typeof args.eventDates === "object" && !Array.isArray(args.eventDates)) { + const entries = Object.entries(args.eventDates as Record); + for (let index = 0; index < entries.length; index += 1) { + const [key, value] = entries[index]; + const parsed = parseDateCandidate(value); + milestones.push({ + id: `event-date-${index + 1}`, + title: key || `Evento ${index + 1}`, + dateIso: parsed ? parsed.toISOString() : null, + location: "", + note: "", + source: "licitation", + }); + } + } + + return milestones; +} + +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", + }; +} + +export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id } = await context.params; + const proposal = await prisma.proposal.findFirst({ + where: { + id, + userId: user.id, + }, + select: { + id: true, + title: true, + issuingEntity: true, + summary: true, + status: true, + sourceLicitationId: true, + workflowDraft: true, + }, + }); + + if (!proposal) { + return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 }); + } + + const linkedSource = proposal.sourceLicitationId + ? await prisma.licitation.findUnique({ + where: { id: proposal.sourceLicitationId }, + include: { + municipality: { + select: { + stateName: true, + municipalityName: true, + }, + }, + }, + }) + : null; + + const formData = await request.formData().catch(() => null); + const filesFromMulti = formData + ? formData.getAll("files").filter((item): item is File => item instanceof File && item.size > 0) + : []; + const maybeSingleFile = formData ? formData.get("file") : null; + const singleFile = maybeSingleFile instanceof File && maybeSingleFile.size > 0 ? maybeSingleFile : null; + const uploadedFiles = filesFromMulti.length > 0 ? filesFromMulti : singleFile ? [singleFile] : []; + const documentType = parseDocumentType(formData?.get("documentType")); + const issuingEntityFromRequest = typeof formData?.get("issuingEntity") === "string" ? String(formData?.get("issuingEntity")).trim() : ""; + const issuingEntity = issuingEntityFromRequest || proposal.issuingEntity || linkedSource?.supplierAwarded || null; + const requestedDocumentTypes = formData + ? formData + .getAll("documentTypes") + .map((value) => parseDocumentType(value)) + : []; + + logContinuityHandoff("m4_to_m5_extract_requested", { + userId: user.id, + proposalId: proposal.id, + sourceLicitationId: proposal.sourceLicitationId, + uploadedFiles: uploadedFiles.length, + }); + + let analysisResult: ReturnType["result"] | null = null; + let analysisMeta: { + source: "module4_history" | "uploaded_pdf"; + engine: string | null; + model: string | null; + warnings: string[]; + historyId: string | null; + bundle?: { + sources: Array<{ sourceId: string; fileName: string; documentType: NormativeDocumentType; historyId: string | null }>; + conflicts: Array<{ title: string; field: string; keptDocumentType: NormativeDocumentType; replacedDocumentType: NormativeDocumentType }>; + }; + } = { + source: "module4_history", + engine: null, + model: null, + warnings: [], + historyId: null, + bundle: { + sources: [], + conflicts: [], + }, + }; + + const bundleCandidates: BundleCandidate[] = []; + + if (uploadedFiles.length > 0) { + for (let index = 0; index < uploadedFiles.length; index += 1) { + const file = uploadedFiles[index]; + if (!file.name.toLowerCase().endsWith(".pdf") && file.type !== "application/pdf") { + return NextResponse.json({ error: "Solo se permiten archivos PDF para extraccion IA." }, { status: 400 }); + } + + if (file.size > MAX_NORMATIVE_PDF_BYTES) { + return NextResponse.json({ error: "El archivo excede el limite de 20MB." }, { status: 400 }); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + if (!hasPdfSignature(buffer)) { + return NextResponse.json({ error: "El archivo no parece ser un PDF valido." }, { status: 400 }); + } + + const inferredType = inferDocumentTypeFromName(file.name); + const resolvedType = + requestedDocumentTypes[index] ?? + (uploadedFiles.length === 1 && documentType !== "OTRO" ? documentType : inferredType !== "OTRO" ? inferredType : "OTRO"); + + bundleCandidates.push({ + sourceId: `upload-${index + 1}`, + fileName: file.name, + fileBuffer: buffer, + documentType: resolvedType, + }); + } + } else if (linkedSource) { + const sourceDocuments = parseLicitationDocumentLinks(linkedSource.documents); + const ranked = rankLicitationDocumentsForBundle(sourceDocuments, 1); + const ordered = [...ranked.bases, ...ranked.convocatoria, ...ranked.anexos, ...ranked.extras]; + const rawSourceUrl = linkedSource.rawSourceUrl?.trim() ?? ""; + const candidateDocuments = [...ordered]; + + if (rawSourceUrl && !candidateDocuments.some((item) => item.url === rawSourceUrl)) { + candidateDocuments.push({ + name: guessFileNameFromUrl(rawSourceUrl), + url: rawSourceUrl, + type: null, + bundleKind: "OTRO", + bundleScore: -10_000, + }); + } + + for (let index = 0; index < candidateDocuments.length && index < 6; index += 1) { + const document = candidateDocuments[index]; + try { + const buffer = await fetchSourcePdf(document.url); + bundleCandidates.push({ + sourceId: `source-${index + 1}`, + fileName: document.name, + fileBuffer: buffer, + documentType: mapBundleKindToDocumentType(document.bundleKind), + }); + } catch (error) { + analysisMeta.warnings.push(error instanceof Error ? `No se pudo leer ${document.name}: ${error.message}` : `No se pudo leer ${document.name}.`); + } + } + } + + if (bundleCandidates.length > 0) { + const bundleAnalyses: NormativeBundleSourceAnalysis[] = []; + + for (const candidate of bundleCandidates.slice(0, 3)) { + try { + const analyzed = await analyzePdf(candidate.fileBuffer); + const aiAnalysis = await analyzeNormativeTextWithAi({ + text: analyzed.text, + fileName: candidate.fileName, + documentType: candidate.documentType, + issuingEntity, + methodUsed: analyzed.methodUsed, + numPages: analyzed.numPages, + warnings: analyzed.warnings, + }); + + const combinedWarnings = [ + ...analyzed.warnings, + ...aiAnalysis.warnings, + `Motor de analisis: ${aiAnalysis.engine}${aiAnalysis.model ? ` (${aiAnalysis.model})` : ""}`, + ]; + + const createdHistory = await prisma.normativeAnalysisHistory.create({ + data: { + userId: user.id, + sourceLicitationId: linkedSource?.id ?? null, + fileName: candidate.fileName, + documentType: toPrismaDocumentType(candidate.documentType), + issuingEntity, + methodUsed: analyzed.methodUsed === "ocr" ? "OCR" : "DIRECT", + numPages: analyzed.numPages, + warnings: combinedWarnings, + extractedChars: analyzed.text.length, + confidence: toPrismaConfidence(aiAnalysis.result.confidence), + viabilityScore: aiAnalysis.result.participationViability.score, + riskLevel: toPrismaRiskLevel(aiAnalysis.result.risk.level), + executiveSummary: aiAnalysis.result.executiveSummary, + result: aiAnalysis.result, + analyzedAt: new Date(), + }, + select: { + id: true, + }, + }); + + bundleAnalyses.push({ + sourceId: candidate.sourceId, + fileName: candidate.fileName, + documentType: candidate.documentType, + result: aiAnalysis.result, + }); + + analysisMeta.source = "uploaded_pdf"; + analysisMeta.engine = analysisMeta.engine ?? aiAnalysis.engine; + analysisMeta.model = analysisMeta.model ?? aiAnalysis.model; + analysisMeta.historyId = analysisMeta.historyId ?? createdHistory.id; + analysisMeta.warnings.push(...combinedWarnings); + analysisMeta.bundle?.sources.push({ + sourceId: candidate.sourceId, + fileName: candidate.fileName, + documentType: candidate.documentType, + historyId: createdHistory.id, + }); + } catch (error) { + const mapped = mapAnalysisError(error); + analysisMeta.warnings.push(`${candidate.fileName}: ${mapped.error}`); + } + } + + if (bundleAnalyses.length === 0) { + return NextResponse.json({ error: "No fue posible analizar los documentos del bundle." }, { status: 422 }); + } + + if (bundleAnalyses.length === 1) { + analysisResult = bundleAnalyses[0]?.result ?? null; + } else { + const merged = mergeNormativeBundleSources(bundleAnalyses); + analysisResult = merged.result; + analysisMeta.bundle = { + sources: merged.sources.map((item) => ({ + ...item, + historyId: analysisMeta.bundle?.sources.find((source) => source.sourceId === item.sourceId)?.historyId ?? null, + })), + conflicts: merged.conflicts.map((conflict) => ({ + title: conflict.title, + field: conflict.field, + keptDocumentType: conflict.keptDocumentType, + replacedDocumentType: conflict.replacedDocumentType, + })), + }; + } + } else { + if (!proposal.sourceLicitationId) { + logContinuityHandoff("m4_to_m5_extract_blocked_no_source_context", { + userId: user.id, + proposalId: proposal.id, + }); + return NextResponse.json( + { error: "La propuesta no tiene fuente de licitacion vinculada de Modulo 4. Sube un PDF para extraer requisitos con IA." }, + { status: 400 }, + ); + } + + const historyEntry = await prisma.normativeAnalysisHistory.findFirst({ + where: { + userId: user.id, + deletedAt: null, + sourceLicitationId: proposal.sourceLicitationId, + }, + orderBy: [{ analyzedAt: "desc" }], + 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, + }, + }); + + if (!historyEntry) { + logContinuityHandoff("m4_to_m5_extract_blocked_no_history", { + userId: user.id, + proposalId: proposal.id, + sourceLicitationId: proposal.sourceLicitationId, + }); + return NextResponse.json( + { error: "No hay analisis previo de Modulo 4 vinculado. Sube un PDF para extraer requisitos con IA." }, + { status: 400 }, + ); + } + + const historyView = toNormativeHistoryView(historyEntry); + analysisResult = historyView.result; + analysisMeta = { + source: "module4_history", + engine: "module4_history", + model: null, + warnings: historyView.warnings, + historyId: historyView.id, + bundle: { + sources: [ + { + sourceId: `history-${historyView.id}`, + fileName: historyView.fileName, + documentType: historyView.documentType, + historyId: historyView.id, + }, + ], + conflicts: [], + }, + }; + } + + if (!analysisResult) { + return NextResponse.json({ error: "No se pudo recuperar resultado de analisis IA." }, { status: 400 }); + } + + const requirementSeeds = buildRequirementSeeds(analysisResult); + const technicalSectionSeeds = buildTechnicalSectionSeeds(analysisResult); + const aiMilestoneSeeds = buildMilestoneSeedsFromAnalysis(analysisResult); + const sourceMilestoneSeeds = linkedSource + ? toMilestoneSeeds({ + openingDate: linkedSource.openingDate, + closingDate: linkedSource.closingDate, + eventDates: linkedSource.eventDates, + }) + : []; + const milestoneSeeds = [...sourceMilestoneSeeds, ...aiMilestoneSeeds]; + const existingWorkflowParsed = ProposalWorkflowStateSchema.safeParse(proposal.workflowDraft); + const defaultDraft = buildDefaultWorkflowDraft({ + proposal: { + title: proposal.title, + issuingEntity: proposal.issuingEntity, + summary: proposal.summary, + status: proposal.status, + }, + linkedSource: linkedSource + ? { + title: linkedSource.title, + description: linkedSource.description, + issuingEntity: linkedSource.supplierAwarded, + procedureType: linkedSource.procedureType, + stateName: linkedSource.municipality.stateName, + municipalityName: linkedSource.municipality.municipalityName, + sourceUrl: linkedSource.rawSourceUrl, + milestones: milestoneSeeds, + } + : null, + requirementSeeds, + technicalSectionSeeds, + }); + + const baseWorkflow = existingWorkflowParsed.success ? existingWorkflowParsed.data : defaultDraft; + const mergedWorkflow = applyAiSeedsToWorkflow(baseWorkflow, { + requirementSeeds, + technicalSectionSeeds, + milestoneSeeds, + }); + const extractedDocumentType = analysisMeta.bundle?.sources[0]?.documentType ?? documentType; + const signaturePolicy = evaluateSignaturePolicy({ + stateName: linkedSource?.municipality.stateName ?? null, + municipalityName: linkedSource?.municipality.municipalityName ?? null, + documentType: extractedDocumentType, + }); + const workflow = { + ...mergedWorkflow, + signatureCompliance: { + ...mergedWorkflow.signatureCompliance, + policyStatus: signaturePolicy.policyStatus, + policyName: signaturePolicy.policyName, + jurisdictionLabel: signaturePolicy.jurisdictionLabel, + sourceUrl: signaturePolicy.sourceUrl ?? "", + minimumEvidence: signaturePolicy.evidenceRequired, + notes: signaturePolicy.notes, + }, + step: Math.max(2, mergedWorkflow.step), + }; + + const completionPercent = computeWorkflowCompletionPercent(workflow); + const gate = computeWorkflowGate(workflow); + + const nextStatus = + proposal.status === "DRAFT" && completionPercent > 0 && canTransitionProposalStatus("DRAFT", "IN_PROGRESS") ? "IN_PROGRESS" : proposal.status; + + const updated = await prisma.proposal.update({ + where: { id: proposal.id }, + data: { + workflowDraft: workflow as Prisma.InputJsonValue, + currentStep: workflow.step, + completionPercent, + ...(nextStatus !== proposal.status ? { status: nextStatus } : {}), + ...(gate.allReady ? {} : { readyForSubmissionAt: null }), + }, + select: { + id: true, + status: true, + currentStep: true, + completionPercent: true, + readyForSubmissionAt: true, + }, + }); + + logContinuityHandoff("m4_to_m5_extract_completed", { + userId: user.id, + proposalId: proposal.id, + sourceLicitationId: proposal.sourceLicitationId, + analysisSource: analysisMeta.source, + historyId: analysisMeta.historyId, + bundleSources: analysisMeta.bundle?.sources.length ?? 0, + bundleConflicts: analysisMeta.bundle?.conflicts.length ?? 0, + completionPercent, + currentStep: updated.currentStep, + }); + + return NextResponse.json({ + ok: true, + proposal: { + id: updated.id, + status: updated.status, + currentStep: updated.currentStep, + completionPercent: updated.completionPercent, + readyForSubmissionAt: updated.readyForSubmissionAt ? updated.readyForSubmissionAt.toISOString() : null, + }, + workflow, + gate, + analysis: analysisMeta, + }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible extraer requisitos con IA." }, { status: 400 }); + } +} diff --git a/src/app/api/proposals/[id]/workflow/mark-ready/route.ts b/src/app/api/proposals/[id]/workflow/mark-ready/route.ts new file mode 100644 index 0000000..b4a1583 --- /dev/null +++ b/src/app/api/proposals/[id]/workflow/mark-ready/route.ts @@ -0,0 +1,131 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { canTransitionProposalStatus } from "@/lib/proposals/status"; +import { ProposalWorkflowStateSchema, computeWorkflowCompletionPercent, computeWorkflowGate } from "@/lib/proposals/workflow-state"; + +function isSchemaNotReadyError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022"); +} + +export async function POST(_: Request, context: { params: Promise<{ id: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id } = await context.params; + + const proposal = await prisma.proposal.findFirst({ + where: { + id, + userId: user.id, + }, + select: { + id: true, + status: true, + workflowDraft: true, + }, + }); + + if (!proposal) { + return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 }); + } + + if (proposal.status === "ARCHIVED") { + return NextResponse.json({ error: "No puedes marcar una propuesta archivada." }, { status: 400 }); + } + + const parsedWorkflow = ProposalWorkflowStateSchema.safeParse(proposal.workflowDraft); + if (!parsedWorkflow.success) { + return NextResponse.json({ error: "No hay flujo persistido valido para validar la propuesta." }, { status: 400 }); + } + + const workflow = parsedWorkflow.data; + const gate = computeWorkflowGate(workflow); + if (!gate.allReady) { + return NextResponse.json( + { + error: "No se cumplen todos los requisitos para marcar como Lista para Envio.", + gate, + }, + { status: 400 }, + ); + } + + const completionPercent = computeWorkflowCompletionPercent(workflow); + const persistedWorkflow = { + ...workflow, + readyMarked: true, + step: 6, + }; + + const updated = await prisma.$transaction(async (tx) => { + if (proposal.status === "DRAFT") { + if (!canTransitionProposalStatus("DRAFT", "IN_PROGRESS")) { + throw new Error("Transicion invalida de estatus."); + } + + await tx.proposal.update({ + where: { id: proposal.id }, + data: { status: "IN_PROGRESS" }, + }); + } + + const current = await tx.proposal.findUnique({ + where: { id: proposal.id }, + select: { status: true }, + }); + + if (!current) { + throw new Error("Propuesta no encontrada."); + } + + if (current.status !== "SUBMITTED" && !canTransitionProposalStatus(current.status, "SUBMITTED")) { + throw new Error("Transicion invalida de estatus."); + } + + return tx.proposal.update({ + where: { id: proposal.id }, + data: { + status: "SUBMITTED", + workflowDraft: persistedWorkflow as Prisma.InputJsonValue, + completionPercent, + currentStep: 6, + readyForSubmissionAt: new Date(), + }, + select: { + id: true, + status: true, + currentStep: true, + completionPercent: true, + readyForSubmissionAt: true, + }, + }); + }); + + return NextResponse.json({ + ok: true, + proposal: { + id: updated.id, + status: updated.status, + currentStep: updated.currentStep, + completionPercent: updated.completionPercent, + readyForSubmissionAt: updated.readyForSubmissionAt ? updated.readyForSubmissionAt.toISOString() : null, + }, + gate, + }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible marcar la propuesta como lista para envio." }, { status: 400 }); + } +} diff --git a/src/app/api/proposals/[id]/workflow/route.ts b/src/app/api/proposals/[id]/workflow/route.ts new file mode 100644 index 0000000..9f43141 --- /dev/null +++ b/src/app/api/proposals/[id]/workflow/route.ts @@ -0,0 +1,163 @@ +import { Prisma, type ProposalStatus } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { canTransitionProposalStatus } from "@/lib/proposals/status"; +import { ProposalWorkflowStateSchema, clampWorkflowStep, computeWorkflowCompletionPercent, computeWorkflowGate, type ProposalWorkflowState } from "@/lib/proposals/workflow-state"; + +function isSchemaNotReadyError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022"); +} + +function parseWorkflowState(value: unknown): ProposalWorkflowState | null { + const parsed = ProposalWorkflowStateSchema.safeParse(value); + if (!parsed.success) { + return null; + } + + return { + ...parsed.data, + step: clampWorkflowStep(parsed.data.step), + }; +} + +function nextStatusFromDraft(progressStatus: ProposalStatus, completionPercent: number): ProposalStatus { + if (completionPercent <= 0 || progressStatus !== "DRAFT") { + return progressStatus; + } + + return canTransitionProposalStatus(progressStatus, "IN_PROGRESS") ? "IN_PROGRESS" : progressStatus; +} + +export async function GET(_: Request, context: { params: Promise<{ id: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id } = await context.params; + + const proposal = await prisma.proposal.findFirst({ + where: { + id, + userId: user.id, + }, + select: { + id: true, + status: true, + currentStep: true, + completionPercent: true, + readyForSubmissionAt: true, + workflowDraft: true, + }, + }); + + if (!proposal) { + return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 }); + } + + const workflow = parseWorkflowState(proposal.workflowDraft); + const gate = workflow ? computeWorkflowGate(workflow) : null; + + return NextResponse.json({ + ok: true, + proposal: { + id: proposal.id, + status: proposal.status, + currentStep: proposal.currentStep, + completionPercent: proposal.completionPercent, + readyForSubmissionAt: proposal.readyForSubmissionAt ? proposal.readyForSubmissionAt.toISOString() : null, + }, + workflow, + gate, + }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible cargar el flujo de trabajo." }, { status: 400 }); + } +} + +export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { id } = await context.params; + const body = (await request.json()) as Record; + const workflow = parseWorkflowState(body.workflow); + + if (!workflow) { + return NextResponse.json({ error: "Workflow invalido." }, { status: 400 }); + } + + const proposal = await prisma.proposal.findFirst({ + where: { + id, + userId: user.id, + }, + select: { + id: true, + status: true, + }, + }); + + if (!proposal) { + return NextResponse.json({ error: "Propuesta no encontrada." }, { status: 404 }); + } + + const gate = computeWorkflowGate(workflow); + const completionPercent = computeWorkflowCompletionPercent(workflow); + const nextStatus = nextStatusFromDraft(proposal.status, completionPercent); + + const updated = await prisma.proposal.update({ + where: { id: proposal.id }, + data: { + workflowDraft: workflow as Prisma.InputJsonValue, + currentStep: workflow.step, + completionPercent, + ...(proposal.status !== nextStatus ? { status: nextStatus } : {}), + ...(gate.allReady ? {} : { readyForSubmissionAt: null }), + }, + select: { + id: true, + status: true, + currentStep: true, + completionPercent: true, + readyForSubmissionAt: true, + }, + }); + + return NextResponse.json({ + ok: true, + proposal: { + id: updated.id, + status: updated.status, + currentStep: updated.currentStep, + completionPercent: updated.completionPercent, + readyForSubmissionAt: updated.readyForSubmissionAt ? updated.readyForSubmissionAt.toISOString() : null, + }, + gate, + }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene los campos de persistencia de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible guardar el flujo de trabajo." }, { status: 400 }); + } +} + diff --git a/src/app/api/proposals/route.ts b/src/app/api/proposals/route.ts new file mode 100644 index 0000000..92567f1 --- /dev/null +++ b/src/app/api/proposals/route.ts @@ -0,0 +1,151 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { prisma } from "@/lib/prisma"; +import { listUserProposals } from "@/lib/proposals/server"; + +function isSchemaNotReadyError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022"); +} + +function parseString(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function toProposalPayload( + proposal: { + id: string; + title: string; + issuingEntity: string; + summary: string; + status: string; + sourceLicitationId: string | null; + createdAt: Date; + updatedAt: Date; + documents: Array<{ + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; + createdAt: Date; + }>; + } & { + currentStep?: number; + completionPercent?: number; + readyForSubmissionAt?: Date | null; + }, +) { + return { + id: proposal.id, + title: proposal.title, + issuingEntity: proposal.issuingEntity, + summary: proposal.summary, + currentStep: proposal.currentStep ?? 1, + completionPercent: proposal.completionPercent ?? 0, + readyForSubmissionAt: proposal.readyForSubmissionAt ? proposal.readyForSubmissionAt.toISOString() : null, + status: proposal.status, + sourceLicitationId: proposal.sourceLicitationId, + createdAt: proposal.createdAt.toISOString(), + updatedAt: proposal.updatedAt.toISOString(), + documents: proposal.documents.map((document) => ({ + id: document.id, + fileName: document.fileName, + mimeType: document.mimeType, + sizeBytes: document.sizeBytes, + createdAt: document.createdAt.toISOString(), + })), + }; +} + +export async function GET(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const url = new URL(request.url); + const q = url.searchParams.get("q") ?? ""; + const proposals = await listUserProposals(user.id, q); + return NextResponse.json({ ok: true, proposals }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible obtener propuestas." }, { status: 400 }); + } +} + +export async function POST(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = (await request.json()) as Record; + const title = parseString(body.title); + const issuingEntity = parseString(body.issuingEntity); + const summary = parseString(body.summary); + const sourceLicitationIdRaw = parseString(body.sourceLicitationId); + const sourceLicitationId = sourceLicitationIdRaw || null; + + if (!title) { + return NextResponse.json({ error: "El titulo es requerido." }, { status: 400 }); + } + + if (!issuingEntity) { + return NextResponse.json({ error: "La entidad emisora es requerida." }, { status: 400 }); + } + + const proposal = await prisma.proposal.create({ + data: { + userId: user.id, + title, + issuingEntity, + summary, + sourceLicitationId, + }, + select: { + id: true, + title: true, + issuingEntity: true, + summary: true, + status: true, + sourceLicitationId: true, + createdAt: true, + updatedAt: true, + documents: { + orderBy: [{ createdAt: "desc" }], + select: { + id: true, + fileName: true, + mimeType: true, + sizeBytes: true, + createdAt: true, + }, + }, + }, + }); + + return NextResponse.json({ + ok: true, + proposal: toProposalPayload(proposal), + }); + } catch (error) { + if (isSchemaNotReadyError(error)) { + return NextResponse.json( + { error: "La base de datos aun no tiene las tablas de Gestion de Licitaciones. Ejecuta prisma migrate para continuar." }, + { status: 503 }, + ); + } + + return NextResponse.json({ error: "No fue posible crear la propuesta." }, { status: 400 }); + } +} diff --git a/src/app/api/regulations/official/route.ts b/src/app/api/regulations/official/route.ts new file mode 100644 index 0000000..2a4e6c6 --- /dev/null +++ b/src/app/api/regulations/official/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { + listOfficialNormativeSources, + listOfficialNormativeSuggestions, + suggestOfficialNormativeSource, +} from "@/lib/compliance/regulations"; +import type { OfficialNormativeSourceView, OfficialNormativeSuggestionInput } from "@/lib/compliance/types"; + +function isValidSourceType(value: unknown): value is OfficialNormativeSourceView["sourceType"] { + return value === "ley" || value === "reglamento" || value === "lineamiento" || value === "portal"; +} + +function parseSuggestion(body: Record): OfficialNormativeSuggestionInput | null { + const stateCode = typeof body.stateCode === "string" ? body.stateCode.trim().toUpperCase() : ""; + const municipalityCode = typeof body.municipalityCode === "string" ? body.municipalityCode.trim().toUpperCase() : null; + const authorityName = typeof body.authorityName === "string" ? body.authorityName.trim() : ""; + const title = typeof body.title === "string" ? body.title.trim() : ""; + const officialUrl = typeof body.officialUrl === "string" ? body.officialUrl.trim() : ""; + const sourceType = body.sourceType; + const notes = typeof body.notes === "string" ? body.notes.trim() : undefined; + + if (!stateCode || !authorityName || !title || !officialUrl || !isValidSourceType(sourceType)) { + return null; + } + + return { + stateCode, + municipalityCode, + authorityName, + title, + officialUrl, + sourceType, + notes, + }; +} + +export async function GET(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(request.url); + const includeSuggestions = url.searchParams.get("includeSuggestions") === "1"; + const stateCode = url.searchParams.get("stateCode")?.trim() || null; + const municipalityCode = url.searchParams.get("municipalityCode")?.trim() || null; + const pilotOnly = url.searchParams.get("pilotOnly") !== "0"; + + const sources = await listOfficialNormativeSources({ + stateCode, + municipalityCode, + pilotOnly, + }); + const suggestions = includeSuggestions ? await listOfficialNormativeSuggestions() : undefined; + + return NextResponse.json({ + ok: true, + sources, + suggestions, + }); +} + +export async function POST(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json().catch(() => ({}))) as Record; + const suggestion = parseSuggestion(body); + + if (!suggestion) { + return NextResponse.json({ error: "Datos invalidos para sugerencia normativa." }, { status: 400 }); + } + + const created = await suggestOfficialNormativeSource(suggestion); + return NextResponse.json({ ok: true, suggestion: created }, { status: 201 }); +} diff --git a/src/app/api/regulations/official/verify/route.ts b/src/app/api/regulations/official/verify/route.ts new file mode 100644 index 0000000..9f1559e --- /dev/null +++ b/src/app/api/regulations/official/verify/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { verifyOfficialNormativeSources } from "@/lib/compliance/regulations"; + +export async function POST(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json().catch(() => ({}))) as { sourceIds?: unknown }; + const sourceIds = Array.isArray(body.sourceIds) + ? body.sourceIds.filter((item): item is string => typeof item === "string" && item.trim().length > 0).map((item) => item.trim()) + : undefined; + + const results = await verifyOfficialNormativeSources(sourceIds); + return NextResponse.json({ ok: true, results }); +} diff --git a/src/app/api/strategic-diagnostic/ai/insights/route.ts b/src/app/api/strategic-diagnostic/ai/insights/route.ts new file mode 100644 index 0000000..eff8efb --- /dev/null +++ b/src/app/api/strategic-diagnostic/ai/insights/route.ts @@ -0,0 +1,279 @@ +import { z } from "zod"; +import { NextResponse } from "next/server"; +import { requireAdminApiUser } from "@/lib/auth/admin"; +import { callOpenAiJsonSchema } from "@/lib/ai/openai"; +import { storeAiSuggestionFromEnvelope } from "@/lib/ai/suggestions"; +import { getStrategicDiagnosticSnapshot } from "@/lib/strategic-diagnostic/server"; +import { STRATEGIC_SECTION_KEYS, type StrategicSectionKey } from "@/lib/strategic-diagnostic/types"; + +const M2_PROMPT_VERSION = "m2_insights_v1"; + +const M2SectionKeySchema = z.enum(STRATEGIC_SECTION_KEYS); + +const M2InsightResponseSchema = z.object({ + sectionGaps: z + .array( + z.object({ + sectionKey: M2SectionKeySchema, + gap: z.string().min(8).max(240), + impact: z.string().min(8).max(600), + urgency: z.enum(["alta", "media", "baja"]), + }), + ) + .max(12), + priorityActions: z + .array( + z.object({ + title: z.string().min(8).max(220), + description: z.string().min(8).max(900), + priority: z.enum(["alta", "media", "baja"]), + ownerSuggestion: z.string().min(3).max(120), + targetDateSuggestion: z.string().min(4).max(40), + }), + ) + .max(12), + suggestedEvidence: z + .array( + z.object({ + sectionKey: M2SectionKeySchema, + category: z.string().min(3).max(120), + reason: z.string().min(8).max(600), + }), + ) + .max(20), + suggestedFieldValues: z + .array( + z.object({ + sectionKey: M2SectionKeySchema, + fieldPath: z.string().min(4).max(120), + suggestedValue: z.string().min(1).max(500), + rationale: z.string().min(8).max(700), + }), + ) + .max(20), + confidence: z.enum(["low", "medium", "high"]), +}); + +const M2InsightJsonSchema = { + type: "object", + additionalProperties: false, + required: ["sectionGaps", "priorityActions", "suggestedEvidence", "suggestedFieldValues", "confidence"], + properties: { + sectionGaps: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["sectionKey", "gap", "impact", "urgency"], + properties: { + sectionKey: { type: "string", enum: STRATEGIC_SECTION_KEYS }, + gap: { type: "string" }, + impact: { type: "string" }, + urgency: { type: "string", enum: ["alta", "media", "baja"] }, + }, + }, + }, + priorityActions: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["title", "description", "priority", "ownerSuggestion", "targetDateSuggestion"], + properties: { + title: { type: "string" }, + description: { type: "string" }, + priority: { type: "string", enum: ["alta", "media", "baja"] }, + ownerSuggestion: { type: "string" }, + targetDateSuggestion: { type: "string" }, + }, + }, + }, + suggestedEvidence: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["sectionKey", "category", "reason"], + properties: { + sectionKey: { type: "string", enum: STRATEGIC_SECTION_KEYS }, + category: { type: "string" }, + reason: { type: "string" }, + }, + }, + }, + suggestedFieldValues: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["sectionKey", "fieldPath", "suggestedValue", "rationale"], + properties: { + sectionKey: { type: "string", enum: STRATEGIC_SECTION_KEYS }, + fieldPath: { type: "string" }, + suggestedValue: { type: "string" }, + rationale: { type: "string" }, + }, + }, + }, + confidence: { type: "string", enum: ["low", "medium", "high"] }, + }, +} as const; + +function isSectionKey(value: string): value is StrategicSectionKey { + return (STRATEGIC_SECTION_KEYS as readonly string[]).includes(value); +} + +function normalizeEvidenceMetadata(value: unknown, fallback: Record) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return fallback; + } + + const incoming = value as Record; + const normalized = { ...fallback }; + + for (const key of STRATEGIC_SECTION_KEYS) { + const sectionPayload = incoming[key]; + if (!sectionPayload || typeof sectionPayload !== "object" || Array.isArray(sectionPayload)) { + continue; + } + + const sectionRecord = sectionPayload as Record; + const countCandidate = Number(sectionRecord.count); + const categories = Array.isArray(sectionRecord.categories) + ? sectionRecord.categories + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 20) + : []; + + normalized[key] = { + count: Number.isFinite(countCandidate) && countCandidate >= 0 ? Math.floor(countCandidate) : normalized[key].count, + categories: categories.length ? categories : normalized[key].categories, + }; + } + + return normalized; +} + +export async function POST(request: Request) { + const user = await requireAdminApiUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const snapshot = await getStrategicDiagnosticSnapshot(user.id); + + if (!snapshot) { + return NextResponse.json({ error: "No existe un perfil organizacional para este usuario." }, { status: 400 }); + } + + const body = (await request.json().catch(() => ({}))) as Record; + const data = (body.data as unknown) ?? snapshot.data; + + const fallbackEvidenceMetadata = STRATEGIC_SECTION_KEYS.reduce( + (accumulator, key) => { + accumulator[key] = { + count: snapshot.evidenceBySection[key].length, + categories: Array.from(new Set(snapshot.evidenceBySection[key].map((item) => item.category))).slice(0, 20), + }; + return accumulator; + }, + {} as Record, + ); + + const evidenceMetadata = normalizeEvidenceMetadata(body.evidenceMetadata, fallbackEvidenceMetadata); + + const systemPrompt = [ + "Eres consultor senior de estrategia comercial para licitaciones publicas en Mexico.", + "Analiza el diagnostico estrategico y prioriza acciones de alto impacto.", + "No alteres puntajes deterministas: solo entrega recomendaciones asistidas.", + "Responde exclusivamente JSON valido en espanol.", + ].join(" "); + + const userPrompt = [ + "Diagnostico estrategico actual:", + JSON.stringify({ + data, + scores: snapshot.scores, + evidenceMetadata, + }), + "", + "Genera:", + "- sectionGaps: brechas por seccion con impacto y urgencia.", + "- priorityActions: acciones priorizadas con responsable sugerido y fecha objetivo.", + "- suggestedEvidence: evidencia recomendada por seccion.", + "- suggestedFieldValues: valores sugeridos para campos del formulario.", + "", + "Reglas para suggestedFieldValues:", + "- sectionKey debe ser una seccion valida.", + "- fieldPath debe usar notacion con punto (ej. technical.responseTimes).", + "- suggestedValue debe ser texto aplicable directamente por el usuario.", + ].join("\n"); + + const envelope = await callOpenAiJsonSchema({ + promptVersion: M2_PROMPT_VERSION, + systemPrompt, + userPrompt, + outputSchema: M2InsightResponseSchema, + schemaName: "m2_strategic_insights", + jsonSchema: M2InsightJsonSchema as unknown as Record, + model: process.env.OPENAI_M2_MODEL?.trim() || undefined, + }); + + const safeData = + envelope.data ?? + ({ + sectionGaps: [], + priorityActions: [], + suggestedEvidence: [], + suggestedFieldValues: [], + confidence: envelope.confidence ?? "low", + } satisfies z.infer); + + const filteredFieldValues = safeData.suggestedFieldValues.filter((item) => { + if (!isSectionKey(item.sectionKey)) { + return false; + } + + return item.fieldPath.startsWith(`${item.sectionKey}.`); + }); + + const payload = { + sectionGaps: safeData.sectionGaps, + priorityActions: safeData.priorityActions, + suggestedEvidence: safeData.suggestedEvidence, + suggestedFieldValues: filteredFieldValues, + confidence: safeData.confidence, + }; + + const persisted = await storeAiSuggestionFromEnvelope({ + userId: user.id, + moduleKey: "M2", + featureKey: "strategic_insights", + subjectType: "organization", + subjectId: snapshot.organizationId, + inputForHash: { + organizationId: snapshot.organizationId, + data, + evidenceMetadata, + scores: snapshot.scores, + }, + envelope, + responsePayload: payload, + }); + + return NextResponse.json({ + ok: true, + ...payload, + suggestionId: persisted.suggestionId, + meta: { + engine: envelope.engine, + model: envelope.model, + usage: envelope.usage, + warnings: envelope.warnings, + confidence: envelope.confidence, + }, + }); +} diff --git a/src/app/candados/page.tsx b/src/app/candados/page.tsx new file mode 100644 index 0000000..206b400 --- /dev/null +++ b/src/app/candados/page.tsx @@ -0,0 +1,118 @@ +import Link from "next/link"; +import { hasModuleAccess } from "@/lib/auth/module-access"; +import { requireOnboardedUser } from "@/lib/auth/user"; +import { prisma } from "@/lib/prisma"; +import { NormativeAnalysisView } from "@/components/app/normative-analysis-view"; +import { PageShell } from "@/components/app/page-shell"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +type CandadosPageProps = { + searchParams: Promise>; +}; + +function getParam(params: Record, key: string) { + const value = params[key]; + return Array.isArray(value) ? value[0] ?? "" : (value ?? ""); +} + +export default async function CandadosPage({ searchParams }: CandadosPageProps) { + const user = await requireOnboardedUser(); + const hasPaidModulesAccess = await hasModuleAccess(user, 6); + + if (!hasPaidModulesAccess) { + return ( + + + +

Acceso restringido

+

Necesitas un plan con acceso premium para ingresar al Modulo 6.

+ + + +
+
+
+ ); + } + + const resolvedSearchParams = await searchParams; + const proposalId = getParam(resolvedSearchParams, "proposalId").trim(); + const sourceIdFromQuery = getParam(resolvedSearchParams, "sourceId").trim(); + + const linkedProposal = proposalId + ? await prisma.proposal.findFirst({ + where: { + id: proposalId, + userId: user.id, + }, + select: { + id: true, + title: true, + issuingEntity: true, + sourceLicitationId: true, + }, + }) + : null; + + const sourceId = (linkedProposal?.sourceLicitationId ?? sourceIdFromQuery) || null; + const linkedSource = sourceId + ? await prisma.licitation.findUnique({ + where: { id: sourceId }, + select: { + id: true, + title: true, + supplierAwarded: true, + }, + }) + : null; + + const backHref = linkedProposal ? `/gestion-licitaciones/${encodeURIComponent(linkedProposal.id)}` : "/gestion-licitaciones"; + + return ( + +
+ +
+

Detector de Candados y Riesgos

+

Modulo 6: detecta direccionamiento, favoritismo y candados en bases de licitacion

+
+
+ + +
+ ); +} diff --git a/src/app/compliance-alerts/page.tsx b/src/app/compliance-alerts/page.tsx new file mode 100644 index 0000000..8630a76 --- /dev/null +++ b/src/app/compliance-alerts/page.tsx @@ -0,0 +1,66 @@ +import Link from "next/link"; +import { hasModuleAccess } from "@/lib/auth/module-access"; +import { requireOnboardedUser } from "@/lib/auth/user"; +import { getM7DatasetForUser } from "@/lib/compliance/server"; +import { ComplianceAlertsView } from "@/components/app/compliance-alerts-view"; +import { PageShell } from "@/components/app/page-shell"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +export default async function ComplianceAlertsPage() { + const user = await requireOnboardedUser(); + const hasPaidModulesAccess = await hasModuleAccess(user, 7); + + if (!hasPaidModulesAccess) { + return ( + Bloqueado} + > + + +

Acceso restringido

+

Modulo 7 forma parte de la ruta premium de modulos pagados.

+
+ + + + + + +
+
+
+
+ ); + } + + const dataset = await getM7DatasetForUser(user.id); + + return ( + +
+ +
+

Modulo 7: Alertas de Cumplimiento

+

Monitorea plazos, riesgos, checklist y vigencia normativa desde una sola vista

+
+
+ + +
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index f2849cc..36e2372 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,10 +1,14 @@ import Link from "next/link"; +import type { ModulePlanKey } from "@prisma/client"; import { isAdminIdentity } from "@/lib/auth/admin"; +import { getActivePlanKeysForUser } from "@/lib/auth/module-access"; import { PageShell } from "@/components/app/page-shell"; import { DashboardMaturitySection } from "@/components/app/dashboard-maturity-section"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { requireOnboardedUser } from "@/lib/auth/user"; +import { hasMercadoPagoConfig } from "@/lib/payments/mercadopago"; +import { getModulePlanByUiKey } from "@/lib/payments/plans"; import { cn } from "@/lib/utils"; import { recomputeAssessmentResults } from "@/lib/scoring"; import { getTalleresSnapshot } from "@/lib/talleres/server"; @@ -57,6 +61,7 @@ const planGroups: PlanGroup[] = [ id: "M04", title: "Analisis Normativo", description: "Analiza las bases de licitacion que te interesen con IA.", + href: "/normative-analysis", }, ], }, @@ -75,16 +80,19 @@ const planGroups: PlanGroup[] = [ id: "M05", title: "Gestion Integral de Licitaciones", description: "Herramienta completa para preparar, revisar y enviar propuestas con checklist.", + href: "/gestion-licitaciones", }, { id: "M06", title: "Detector de Candados y Riesgos", description: "Analisis automatico de bases para identificar requisitos excluyentes o riesgos.", + href: "/candados", }, { id: "M07", title: "Alertas de Cumplimiento", description: "Notificaciones proactivas sobre plazos, documentos vencidos y cambios normativos.", + href: "/compliance-alerts", }, ], }, @@ -103,16 +111,19 @@ const planGroups: PlanGroup[] = [ id: "M08", title: "Gestion Estrategica de Contratos", description: "Dashboard para administrar contratos activos, entregables y pagos.", + href: "/gestion-contratos", }, { id: "M09", title: "Proteccion Legal", description: "Guia legal y plantillas ante incumplimientos, retenciones o sanciones.", + href: "/proteccion-legal", }, { id: "M10", title: "Simulador de Auditorias", description: "Autoevaluacion que prepara a tu empresa para auditorias gubernamentales.", + href: "/expediente-preventivo", }, ], }, @@ -165,11 +176,28 @@ function LockIcon({ className }: { className?: string }) { ); } -export default async function DashboardPage() { +type DashboardPageProps = { + searchParams: Promise>; +}; + +function getParam(params: Record, key: string) { + const value = params[key]; + return Array.isArray(value) ? value[0] ?? "" : (value ?? ""); +} + +export default async function DashboardPage({ searchParams }: DashboardPageProps) { const user = await requireOnboardedUser(); const snapshot = await recomputeAssessmentResults(user.id); const talleresSnapshot = await getTalleresSnapshot(user.id, { assessmentSnapshot: snapshot }); - const hasPaidModulesAccess = isAdminIdentity(user.email, user.role); + const isAdminUser = isAdminIdentity(user.email, user.role); + const activePlanKeys = isAdminUser + ? new Set(["PLAN_2_4", "PLAN_5_7", "PLAN_8_10"]) + : await getActivePlanKeysForUser(user.id); + const hasPaidModulesAccess = activePlanKeys.size > 0; + const paymentConfigured = hasMercadoPagoConfig(); + const resolvedSearchParams = await searchParams; + const checkoutResult = getParam(resolvedSearchParams, "mp_result"); + const checkoutError = getParam(resolvedSearchParams, "mp_error"); const roundedOverallScore = Math.round(snapshot.overallScore); const readinessLabel = getReadinessLabel(roundedOverallScore); const strongest = snapshot.strongestModule; @@ -212,11 +240,11 @@ export default async function DashboardPage() {
- Nivel {readinessLabel} -

+ Nivel {readinessLabel} +

Puntaje Global: {roundedOverallScore}%

-

+

{getReadinessDescription(roundedOverallScore)}

@@ -251,13 +279,29 @@ export default async function DashboardPage() {

Modulos y planes

-

Ruta de crecimiento: Modulo 1 (gratis) + Modulos 2-20 por suscripcion.

+

Ruta de crecimiento: Modulo 1 (gratis) + Modulos 2-10 por suscripcion.

{hasPaidModulesAccess ? "Plan con acceso activo" : "Cuenta en plan gratuito"}
+ {checkoutResult ? ( +
+ {checkoutResult === "success" + ? "Mercado Pago reporto checkout exitoso. Estamos validando tu pago y activando el plan." + : checkoutResult === "failure" + ? "El pago no se completo. Puedes intentarlo nuevamente." + : "Tu pago esta pendiente de confirmacion. Te avisaremos cuando se active el plan."} +
+ ) : null} + + {checkoutError ? ( +
+ No fue posible iniciar el checkout ({checkoutError}). Verifica la configuracion de Mercado Pago. +
+ ) : null} +
@@ -275,6 +319,11 @@ export default async function DashboardPage() { {planGroups.map((group) => (
+ {(() => { + const plan = getModulePlanByUiKey(group.key); + const planActive = plan ? activePlanKeys.has(plan.key) : false; + return ( + <>
@@ -288,13 +337,13 @@ export default async function DashboardPage() { - {hasPaidModulesAccess ? "Disponible" : "Bloqueado"} + {planActive ? "Disponible" : "Bloqueado"}
{group.modules.map((moduleItem) => { - const isLocked = !hasPaidModulesAccess || !moduleItem.href; + const isLocked = !planActive || !moduleItem.href; return (
+ {!planActive ? ( +
+

+ Desbloquea Modulos {plan?.moduleStart}-{plan?.moduleEnd} con {group.name}. +

+ {paymentConfigured ? ( + + + + ) : ( + + Configura `MP_ACCESS_TOKEN` + + )} +
+ ) : null} + + ); + })()}
))}

Ruta de expansion

-

Modulos 11-20 se habilitaran en siguientes etapas de la plataforma.

+

Actualmente la plataforma contempla 10 modulos.

+ + + + +
+ + + + ); + } + + const resolvedSearchParams = await searchParams; + const strategy = (getParam(resolvedSearchParams, "strategy") === "snapshot" ? "snapshot" : "live") satisfies InstitutionalDossierLoadStrategy; + + const [simulations, kpis, dossierState] = await Promise.all([ + listAuditSimulationsForUser(user.id), + getAuditKpisForUser(user.id), + getInstitutionalDossierForUser(user.id, strategy), + ]); + + return ( + +
+ +
+

Expediente Preventivo

+

Simula auditorias y consolida evidencia institucional desde M5, M8, M9 y M7

+
+
+ + +
+ ); +} diff --git a/src/app/gestion-contratos/page.tsx b/src/app/gestion-contratos/page.tsx new file mode 100644 index 0000000..5a01b12 --- /dev/null +++ b/src/app/gestion-contratos/page.tsx @@ -0,0 +1,237 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { hasModuleAccess } from "@/lib/auth/module-access"; +import { requireOnboardedUser } from "@/lib/auth/user"; +import { prisma } from "@/lib/prisma"; +import { getContractsKpisForUser, listContractsForUser } from "@/lib/contracts/server"; +import { + ensureProposalPdfLinkedToContract, + getLatestProposalPdfSourceForUser, + isPdfProposalDocument, +} from "@/lib/contracts/proposal-continuity"; +import { ContractsManagementView } from "@/components/app/contracts-management-view"; +import { PageShell } from "@/components/app/page-shell"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +type PageProps = { + searchParams: Promise>; +}; + +function getParam(params: Record, key: string) { + const value = params[key]; + return Array.isArray(value) ? value[0] ?? "" : (value ?? ""); +} + +function logContinuityHandoff(event: string, data: Record) { + console.info( + "[continuity_handoff]", + JSON.stringify({ + event, + at: new Date().toISOString(), + ...data, + }), + ); +} + +export default async function GestionContratosPage({ searchParams }: PageProps) { + const user = await requireOnboardedUser(); + const hasPaidModulesAccess = await hasModuleAccess(user, 8); + + if (!hasPaidModulesAccess) { + return ( + Bloqueado} + > + + +

Acceso restringido

+

Modulo 8 forma parte de la ruta premium de modulos pagados.

+
+ + + + + + +
+
+
+
+ ); + } + + const resolvedSearchParams = await searchParams; + const proposalId = getParam(resolvedSearchParams, "proposalId") || null; + const shouldAutoCreate = getParam(resolvedSearchParams, "autocreate") === "1"; + + if (proposalId || shouldAutoCreate) { + logContinuityHandoff("m5_to_m8_autocreate_requested", { + userId: user.id, + proposalId, + shouldAutoCreate, + }); + } + + if (proposalId && shouldAutoCreate) { + const ownedProposal = await prisma.proposal.findFirst({ + where: { + id: proposalId, + userId: user.id, + }, + select: { + id: true, + title: true, + issuingEntity: true, + summary: true, + }, + }); + + if (ownedProposal) { + logContinuityHandoff("m5_to_m8_autocreate_proposal_resolved", { + userId: user.id, + proposalId: ownedProposal.id, + }); + + const existing = await prisma.contractRecord.findFirst({ + where: { + userId: user.id, + sourceProposalId: ownedProposal.id, + }, + select: { + id: true, + }, + }); + + let targetContractId = existing?.id ?? null; + if (!targetContractId) { + const created = await prisma.contractRecord.create({ + data: { + userId: user.id, + sourceProposalId: ownedProposal.id, + title: ownedProposal.title, + counterpartyEntity: ownedProposal.issuingEntity, + contractType: "Desde propuesta adjudicada", + description: ownedProposal.summary, + status: "ACTIVE", + currency: "MXN", + }, + select: { + id: true, + }, + }); + targetContractId = created.id; + } + logContinuityHandoff("m5_to_m8_autocreate_contract_resolved", { + userId: user.id, + proposalId: ownedProposal.id, + contractId: targetContractId, + mode: existing ? "reuse" : "create", + }); + + if (targetContractId) { + try { + const proposalPdfLookup = await getLatestProposalPdfSourceForUser(user.id, ownedProposal.id); + logContinuityHandoff("m5_to_m8_autocreate_proposal_pdf_lookup", { + userId: user.id, + proposalId: ownedProposal.id, + contractId: targetContractId, + lookupStatus: proposalPdfLookup.status, + }); + + if (proposalPdfLookup.status === "ok") { + const linked = await ensureProposalPdfLinkedToContract({ + userId: user.id, + contractId: targetContractId, + source: proposalPdfLookup.source, + }); + logContinuityHandoff("m5_to_m8_autocreate_pdf_linked", { + userId: user.id, + proposalId: ownedProposal.id, + contractId: targetContractId, + created: linked.created, + documentId: linked.documentId, + }); + } + } catch { + logContinuityHandoff("m5_to_m8_autocreate_pdf_link_failed", { + userId: user.id, + proposalId: ownedProposal.id, + contractId: targetContractId, + }); + // Preserve autocreate flow even if continuity copy fails unexpectedly. + } + } + + redirect("/gestion-contratos"); + } + } + + const [contracts, kpis, proposals] = await Promise.all([ + listContractsForUser(user.id), + getContractsKpisForUser(user.id), + prisma.proposal.findMany({ + where: { + userId: user.id, + }, + orderBy: [{ updatedAt: "desc" }], + select: { + id: true, + title: true, + documents: { + orderBy: [{ createdAt: "desc" }], + select: { + id: true, + fileName: true, + mimeType: true, + createdAt: true, + }, + }, + }, + take: 200, + }), + ]); + const proposalPdfOptions = proposals + .map((proposal) => { + const latestPdf = proposal.documents.find((document) => isPdfProposalDocument(document)) ?? null; + if (!latestPdf) { + return null; + } + + return { + proposalId: proposal.id, + proposalTitle: proposal.title, + documentId: latestPdf.id, + fileName: latestPdf.fileName, + createdAt: latestPdf.createdAt.toISOString(), + }; + }) + .filter((item): item is { proposalId: string; proposalTitle: string; documentId: string; fileName: string; createdAt: string } => Boolean(item)); + + return ( + +
+ +
+

Gestion Estrategica de Contratos

+

Registra contratos, entregables, pagos y evidencia documental con trazabilidad completa

+
+
+ + +
+ ); +} diff --git a/src/app/gestion-licitaciones/[id]/page.tsx b/src/app/gestion-licitaciones/[id]/page.tsx new file mode 100644 index 0000000..7598d7b --- /dev/null +++ b/src/app/gestion-licitaciones/[id]/page.tsx @@ -0,0 +1,305 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Prisma } from "@prisma/client"; +import { hasModuleAccess } from "@/lib/auth/module-access"; +import { requireOnboardedUser } from "@/lib/auth/user"; +import { toNormativeHistoryView } from "@/lib/normative-analysis/history"; +import { prisma } from "@/lib/prisma"; +import { buildRequirementSeeds, buildTechnicalSectionSeeds, type WorkflowMilestoneSeed } from "@/lib/proposals/workflow"; +import { PageShell } from "@/components/app/page-shell"; +import { ProposalWorkflowView } from "@/components/app/proposal-workflow-view"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +type PageProps = { + params: Promise<{ id: string }>; + searchParams: Promise>; +}; + +type SourceDocumentLink = { + name: string; + url: string; + type: string | null; +}; + +function toSourceDocumentLinks(value: unknown): SourceDocumentLink[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => { + if (!item || typeof item !== "object") { + return null; + } + + const record = item as Record; + const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : "Documento"; + const url = typeof record.url === "string" && record.url.trim() ? record.url.trim() : ""; + const type = typeof record.type === "string" && record.type.trim() ? record.type.trim() : null; + + if (!url) { + return null; + } + + return { name, url, type }; + }) + .filter((item): item is SourceDocumentLink => Boolean(item)); +} + +function parseDateCandidate(value: unknown) { + if (typeof value !== "string") { + return null; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + + return parsed; +} + +function toMilestoneSeeds(args: { + openingDate: Date | null; + closingDate: Date | null; + eventDates: Prisma.JsonValue | null; +}): WorkflowMilestoneSeed[] { + const milestones: WorkflowMilestoneSeed[] = []; + + if (args.openingDate) { + milestones.push({ + id: "opening-date", + title: "Apertura del proceso", + dateIso: args.openingDate.toISOString(), + location: "", + note: "", + source: "licitation", + }); + } + + if (args.closingDate) { + milestones.push({ + id: "closing-date", + title: "Presentacion de propuestas", + dateIso: args.closingDate.toISOString(), + location: "", + note: "", + source: "licitation", + }); + } + + if (args.eventDates && typeof args.eventDates === "object" && !Array.isArray(args.eventDates)) { + const entries = Object.entries(args.eventDates as Record); + for (let index = 0; index < entries.length; index += 1) { + const [key, value] = entries[index]; + const parsed = parseDateCandidate(value); + milestones.push({ + id: `event-date-${index + 1}`, + title: key || `Evento ${index + 1}`, + dateIso: parsed ? parsed.toISOString() : null, + location: "", + note: "", + source: "licitation", + }); + } + } + + const dedupe = new Set(); + return milestones.filter((item) => { + const signature = `${item.title.toLowerCase()}|${item.dateIso ?? ""}`; + if (dedupe.has(signature)) { + return false; + } + dedupe.add(signature); + return true; + }); +} + +function getParam(params: Record, key: string) { + const value = params[key]; + return Array.isArray(value) ? value[0] ?? "" : (value ?? ""); +} + +export default async function GestionLicitacionDetailPage({ params, searchParams }: PageProps) { + const user = await requireOnboardedUser(); + const hasPaidModulesAccess = await hasModuleAccess(user, 5); + + if (!hasPaidModulesAccess) { + return ( + Bloqueado} + > + + +

Acceso restringido

+

Modulo 5 forma parte de la ruta premium de modulos pagados.

+
+ + + + + + +
+
+
+
+ ); + } + + const { id } = await params; + const resolvedSearchParams = await searchParams; + const autoExtractOnLoad = getParam(resolvedSearchParams, "autofill") === "1"; + + const proposal = await prisma.proposal.findFirst({ + where: { + id, + userId: user.id, + }, + select: { + id: true, + title: true, + issuingEntity: true, + summary: true, + status: true, + sourceLicitationId: true, + updatedAt: true, + documents: { + orderBy: [{ createdAt: "desc" }], + select: { + id: true, + fileName: true, + mimeType: true, + sizeBytes: true, + createdAt: true, + }, + }, + }, + }); + + if (!proposal) { + notFound(); + } + + const linkedSource = proposal.sourceLicitationId + ? await prisma.licitation.findUnique({ + where: { id: proposal.sourceLicitationId }, + include: { + municipality: { + select: { + stateName: true, + municipalityName: true, + }, + }, + }, + }) + : null; + + const historyEntry = proposal.sourceLicitationId + ? await prisma.normativeAnalysisHistory.findFirst({ + where: { + userId: user.id, + deletedAt: null, + sourceLicitationId: proposal.sourceLicitationId, + }, + orderBy: [{ analyzedAt: "desc" }], + 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, + }, + }) + : null; + + const historyView = historyEntry ? toNormativeHistoryView(historyEntry) : null; + const requirementSeeds = historyView ? buildRequirementSeeds(historyView.result) : []; + const technicalSeeds = historyView ? buildTechnicalSectionSeeds(historyView.result) : []; + const milestoneSeeds = linkedSource + ? toMilestoneSeeds({ + openingDate: linkedSource.openingDate, + closingDate: linkedSource.closingDate, + eventDates: linkedSource.eventDates, + }) + : []; + const sourceDocumentLinks = linkedSource ? toSourceDocumentLinks(linkedSource.documents) : []; + const candadosHref = + `/candados?proposalId=${encodeURIComponent(proposal.id)}` + + (proposal.sourceLicitationId ? `&sourceId=${encodeURIComponent(proposal.sourceLicitationId)}` : ""); + + return ( + + ({ + id: document.id, + fileName: document.fileName, + mimeType: document.mimeType, + sizeBytes: document.sizeBytes, + createdAt: document.createdAt.toISOString(), + })), + }} + linkedSource={ + linkedSource + ? { + id: linkedSource.id, + title: linkedSource.title, + description: linkedSource.description ?? "", + issuingEntity: linkedSource.supplierAwarded ?? null, + procedureType: linkedSource.procedureType, + stateName: linkedSource.municipality.stateName, + municipalityName: linkedSource.municipality.municipalityName, + sourceUrl: linkedSource.rawSourceUrl ?? null, + documentLinks: sourceDocumentLinks, + milestones: milestoneSeeds, + } + : null + } + normativeContext={ + historyView + ? { + id: historyView.id, + analyzedAt: historyView.analyzedAt, + executiveSummary: historyView.result.executiveSummary, + confidence: historyView.confidence, + requirementSeeds, + technicalSeeds, + } + : null + } + autoExtractOnLoad={autoExtractOnLoad} + /> + + ); +} diff --git a/src/app/gestion-licitaciones/page.tsx b/src/app/gestion-licitaciones/page.tsx new file mode 100644 index 0000000..82fa8af --- /dev/null +++ b/src/app/gestion-licitaciones/page.tsx @@ -0,0 +1,133 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { hasModuleAccess } from "@/lib/auth/module-access"; +import { requireOnboardedUser } from "@/lib/auth/user"; +import { prisma } from "@/lib/prisma"; +import { listUserProposals } from "@/lib/proposals/server"; +import { PageShell } from "@/components/app/page-shell"; +import { ProposalsManagementView } from "@/components/app/proposals-management-view"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +type PageProps = { + searchParams: Promise>; +}; + +function getParam(params: Record, key: string) { + const value = params[key]; + return Array.isArray(value) ? value[0] ?? "" : (value ?? ""); +} + +export default async function GestionLicitacionesPage({ searchParams }: PageProps) { + const user = await requireOnboardedUser(); + const hasPaidModulesAccess = await hasModuleAccess(user, 5); + + if (!hasPaidModulesAccess) { + return ( + Bloqueado} + > + + +

Acceso restringido

+

Modulo 5 forma parte de la ruta premium de modulos pagados.

+
+ + + + + + +
+
+
+
+ ); + } + + const resolvedSearchParams = await searchParams; + const sourceLicitationId = getParam(resolvedSearchParams, "sourceId") || null; + const shouldAutoCreate = getParam(resolvedSearchParams, "autocreate") === "1"; + const shouldAutoFill = getParam(resolvedSearchParams, "autofill") === "1"; + + if (sourceLicitationId && shouldAutoCreate) { + const existing = await prisma.proposal.findFirst({ + where: { + userId: user.id, + sourceLicitationId, + }, + orderBy: [{ updatedAt: "desc" }], + select: { id: true }, + }); + + let targetId = existing?.id ?? null; + + if (!targetId) { + const source = await prisma.licitation.findUnique({ + where: { id: sourceLicitationId }, + select: { + id: true, + title: true, + description: true, + supplierAwarded: true, + }, + }); + + if (source) { + const created = await prisma.proposal.create({ + data: { + userId: user.id, + sourceLicitationId: source.id, + title: source.title, + issuingEntity: source.supplierAwarded?.trim() || "Entidad no especificada", + summary: source.description ?? "", + }, + select: { id: true }, + }); + + targetId = created.id; + } + } + + if (targetId) { + redirect(`/gestion-licitaciones/${encodeURIComponent(targetId)}${shouldAutoFill ? "?autofill=1" : ""}`); + } + } + + const proposals = await listUserProposals(user.id, ""); + const latestProposal = proposals[0] ?? null; + const candadosHref = + latestProposal + ? `/candados?proposalId=${encodeURIComponent(latestProposal.id)}${ + latestProposal.sourceLicitationId ? `&sourceId=${encodeURIComponent(latestProposal.sourceLicitationId)}` : "" + }` + : sourceLicitationId + ? `/candados?sourceId=${encodeURIComponent(sourceLicitationId)}` + : "/candados"; + + return ( + +
+
+

Gestion de Licitaciones

+

Prepara tus propuestas de licitacion paso a paso

+
+
+ + +
+ ); +} diff --git a/src/app/licitations/[id]/page.tsx b/src/app/licitations/[id]/page.tsx index e0c43ea..c20a6b8 100644 --- a/src/app/licitations/[id]/page.tsx +++ b/src/app/licitations/[id]/page.tsx @@ -1,9 +1,12 @@ import Link from "next/link"; import { notFound } from "next/navigation"; -import { isAdminIdentity } from "@/lib/auth/admin"; +import { hasModuleAccess } from "@/lib/auth/module-access"; import { requireOnboardedUser } from "@/lib/auth/user"; +import { isLikelyPdfUrl, parseLicitationDocumentLinks, pickPrimaryLicitationPdfDocument } from "@/lib/licitations/documents"; import { getCategoryLabel, getProcedureTypeLabel, getSourceLabel } from "@/lib/licitations/labels"; +import { type LicitationReviewStatusView } from "@/lib/licitations/preferences"; import { prisma } from "@/lib/prisma"; +import { LicitationCardActions } from "@/components/app/licitation-card-actions"; import { PageShell } from "@/components/app/page-shell"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; @@ -25,22 +28,10 @@ function formatDate(value: Date | null) { } function toDocumentArray(value: unknown) { - if (!Array.isArray(value)) { - return [] as Array<{ name: string; url: string; type?: string }>; - } - - return value - .filter((item) => item && typeof item === "object") - .map((item) => { - const entry = item as Record; - - return { - name: typeof entry.name === "string" ? entry.name : "Documento", - url: typeof entry.url === "string" ? entry.url : "", - type: typeof entry.type === "string" ? entry.type : undefined, - }; - }) - .filter((item) => item.url.length > 0); + return parseLicitationDocumentLinks(value).map((item) => ({ + ...item, + type: item.type ?? undefined, + })); } function toEventDates(value: unknown) { @@ -60,7 +51,7 @@ function toEventDates(value: unknown) { export default async function LicitationDetailPage({ params }: LicitationDetailPageProps) { const user = await requireOnboardedUser(); - const hasPaidModulesAccess = isAdminIdentity(user.email, user.role); + const hasPaidModulesAccess = await hasModuleAccess(user, 3); if (!hasPaidModulesAccess) { return ( @@ -99,16 +90,56 @@ export default async function LicitationDetailPage({ params }: LicitationDetailP } const documents = toDocumentArray(licitation.documents); + const primaryPdfDocument = + pickPrimaryLicitationPdfDocument(parseLicitationDocumentLinks(licitation.documents)) ?? + (isLikelyPdfUrl(licitation.rawSourceUrl) + ? { + name: "Documento principal", + url: licitation.rawSourceUrl as string, + type: "PDF", + } + : null); const timeline = toEventDates(licitation.eventDates); + const preference = await prisma.licitationUserPreference.findUnique({ + where: { + userId_licitationId: { + userId: user.id, + licitationId: licitation.id, + }, + }, + select: { + status: true, + }, + }); + const reviewStatus = (preference?.status as LicitationReviewStatusView | undefined) ?? "NEW"; return ( - - +
+ + + + + + + {primaryPdfDocument ? ( + + + + ) : null} + + + +
} > @@ -129,6 +160,12 @@ export default async function LicitationDetailPage({ params }: LicitationDetailP Cierre: {formatDate(licitation.closingDate)}
+ +

Monto

@@ -170,6 +207,16 @@ export default async function LicitationDetailPage({ params }: LicitationDetailP

Documentos

+ {primaryPdfDocument ? ( + + PDF principal: {primaryPdfDocument.name} + + ) : null} {documents.length === 0 ? (

No hay documentos asociados.

) : ( diff --git a/src/app/licitations/page.tsx b/src/app/licitations/page.tsx index 829a748..35ac8c7 100644 --- a/src/app/licitations/page.tsx +++ b/src/app/licitations/page.tsx @@ -1,22 +1,64 @@ import Link from "next/link"; -import { LicitationProcedureType } from "@prisma/client"; -import { isAdminIdentity } from "@/lib/auth/admin"; +import type { LicitationSource } from "@prisma/client"; +import { hasModuleAccess } from "@/lib/auth/module-access"; import { requireOnboardedUser } from "@/lib/auth/user"; -import { getCategoryLabel, getProcedureTypeLabel, getSourceLabel } from "@/lib/licitations/labels"; +import { isLikelyPdfUrl, parseLicitationDocumentLinks, pickPrimaryLicitationPdfDocument } from "@/lib/licitations/documents"; +import { getCategoryLabel, getProcedureTypeLabel } from "@/lib/licitations/labels"; +import { getLicitationReviewStatusLabel, type LicitationReviewStatusView } from "@/lib/licitations/preferences"; import { listMunicipalities, searchLicitations } from "@/lib/licitations/query"; -import { getLicitationRecommendationsForUser } from "@/lib/licitations/recommendations"; +import { getAiEnhancedLicitationRecommendationsForUser } from "@/lib/licitations/ai-recommendations"; +import { computeModule3Summary, getViabilityLevel } from "@/lib/licitations/module3"; +import { prisma } from "@/lib/prisma"; +import { LicitationCardActions } from "@/components/app/licitation-card-actions"; import { LicitationsSyncButton } from "@/components/app/licitations-sync-button"; import { PageShell } from "@/components/app/page-shell"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; type LicitationsPageProps = { searchParams: Promise>; }; +type ViabilityFilter = "all" | "alta" | "media" | "baja"; +type SortFilter = "compat_desc" | "compat_asc" | "closing_soon" | "recent"; +type StatusFilter = "all" | "open" | "closed"; +type ReviewFilter = "all" | "consulted" | "interested" | "active"; + function getParam(params: Record, key: string) { const value = params[key]; - return Array.isArray(value) ? value[0] : value; + return Array.isArray(value) ? value[0] ?? "" : (value ?? ""); +} + +function parseViability(value: string): ViabilityFilter { + if (value === "alta" || value === "media" || value === "baja") { + return value; + } + + return "all"; +} + +function parseSort(value: string): SortFilter { + if (value === "compat_asc" || value === "closing_soon" || value === "recent") { + return value; + } + + return "compat_desc"; +} + +function parseStatus(value: string): StatusFilter { + if (value === "open" || value === "closed") { + return value; + } + + return "all"; +} + +function parseReview(value: string): ReviewFilter { + if (value === "consulted" || value === "interested" || value === "active") { + return value; + } + + return "all"; } function formatDate(value: Date | null) { @@ -26,11 +68,18 @@ function formatDate(value: Date | null) { return value.toLocaleDateString("es-MX", { year: "numeric", - month: "short", + month: "long", day: "2-digit", }); } +function formatDateShort(value: Date) { + return value.toLocaleDateString("es-MX", { + day: "2-digit", + month: "long", + }); +} + function formatAmount(amount: unknown, currency: string | null) { if (amount == null) { return "Monto no disponible"; @@ -42,12 +91,67 @@ function formatAmount(amount: unknown, currency: string | null) { return "Monto no disponible"; } - return `${currency ?? "MXN"} ${numeric.toLocaleString("es-MX", { maximumFractionDigits: 2 })}`; + return `${currency ?? "MXN"} ${numeric.toLocaleString("es-MX", { maximumFractionDigits: 0 })}`; +} + +function getSourceShortLabel(source: LicitationSource) { + if (source === "MUNICIPAL_OPEN_PORTAL") { + return "Portal municipal"; + } + + if (source === "MUNICIPAL_BACKUP") { + return "Portal respaldo"; + } + + if (source === "LICITAYA") { + return "LicitaYa API"; + } + + return "PNT"; +} + +function getDaysUntilDate(value: Date | null) { + if (!value) { + return "Sin cierre"; + } + + const now = new Date(); + const difference = value.getTime() - now.getTime(); + const dayCount = Math.max(0, Math.ceil(difference / (1000 * 60 * 60 * 24))); + return `${dayCount} dias`; +} + +function ViabilityBadge({ score }: { score: number }) { + const level = getViabilityLevel(score); + + const label = level === "alta" ? "Alta Viabilidad" : level === "media" ? "Media Viabilidad" : "Baja Viabilidad"; + const className = + level === "alta" + ? "border-[#b8e3d0] bg-[#ecf9f2] text-[#1f8b63]" + : level === "media" + ? "border-[#f0deb0] bg-[#fff8e9] text-[#9d6d08]" + : "border-[#f2cccc] bg-[#fff1f1] text-[#af3a3a]"; + + return {label}; +} + +function ReviewStatusBadge({ status }: { status: LicitationReviewStatusView }) { + const label = getLicitationReviewStatusLabel(status); + const className = + status === "REVIEWED" + ? "border-[#d4dbe8] bg-[#f2f5fb] text-[#42577c]" + : status === "INTERESTED" + ? "border-[#b9e6cd] bg-[#eaf9f1] text-[#1f8b63]" + : status === "DISCARDED" + ? "border-[#f0ccd2] bg-[#fff1f4] text-[#a74150]" + : "border-[#d4e2ff] bg-[#e8f0ff] text-[#2f58c1]"; + + return {label}; } export default async function LicitationsPage({ searchParams }: LicitationsPageProps) { const user = await requireOnboardedUser(); - const hasPaidModulesAccess = isAdminIdentity(user.email, user.role); + const hasPaidModulesAccess = await hasModuleAccess(user, 3); if (!hasPaidModulesAccess) { return ( @@ -57,10 +161,8 @@ export default async function LicitationsPage({ searchParams }: LicitationsPageP action={Bloqueado} > - -

Acceso restringido

-
- + +

Acceso restringido

El Modulo 3 forma parte de la ruta premium. Tu cuenta actual puede completar el Diagnostico (Modulo 1) y ver los planes en la seccion Modulos.

@@ -74,14 +176,18 @@ export default async function LicitationsPage({ searchParams }: LicitationsPageP } const params = await searchParams; - const q = getParam(params, "q") ?? ""; - const state = getParam(params, "state") ?? ""; - const municipality = getParam(params, "municipality") ?? ""; - const procedureType = getParam(params, "procedure_type") ?? ""; - const minAmount = getParam(params, "min_amount") ?? ""; - const maxAmount = getParam(params, "max_amount") ?? ""; - const dateFrom = getParam(params, "date_from") ?? ""; - const dateTo = getParam(params, "date_to") ?? ""; + const q = getParam(params, "q"); + const state = getParam(params, "state"); + const municipality = getParam(params, "municipality"); + const procedureType = getParam(params, "procedure_type"); + const minAmount = getParam(params, "min_amount"); + const maxAmount = getParam(params, "max_amount"); + const dateFrom = getParam(params, "date_from"); + const dateTo = getParam(params, "date_to"); + const viabilityFilter = parseViability(getParam(params, "viability")); + const statusFilter = parseStatus(getParam(params, "status")); + const reviewFilter = parseReview(getParam(params, "review")); + const sortFilter = parseSort(getParam(params, "sort")); const [municipalities, records, recommendations] = await Promise.all([ listMunicipalities(), @@ -96,166 +202,522 @@ export default async function LicitationsPage({ searchParams }: LicitationsPageP dateTo, take: 100, }), - getLicitationRecommendationsForUser(user.id), + getAiEnhancedLicitationRecommendationsForUser(user.id), ]); + const preferenceRecords = records.records.length + ? await prisma.licitationUserPreference.findMany({ + where: { + userId: user.id, + licitationId: { + in: records.records.map((record) => record.id), + }, + }, + select: { + licitationId: true, + status: true, + }, + }) + : []; + + const recommendationsById = new Map(recommendations.results.map((item) => [item.id, item])); + const preferencesById = new Map(preferenceRecords.map((item) => [item.licitationId, item.status as LicitationReviewStatusView])); + const activeProposalRecords = records.records.length + ? await prisma.proposal.findMany({ + where: { + userId: user.id, + sourceLicitationId: { + in: records.records.map((record) => record.id), + }, + status: { + not: "ARCHIVED", + }, + }, + select: { + sourceLicitationId: true, + status: true, + updatedAt: true, + }, + orderBy: [{ updatedAt: "desc" }], + }) + : []; + const activeLicitationIds = new Set(activeProposalRecords.map((item) => item.sourceLicitationId).filter((value): value is string => Boolean(value))); + + const baseRows = records.records + .map((record) => { + const recommendation = recommendationsById.get(record.id); + const deterministicScore = recommendation?.deterministicScore ?? recommendation?.score ?? 0; + const aiFitScore = recommendation?.aiScore ?? deterministicScore; + const blendedScore = recommendation?.blendedScore ?? deterministicScore; + + return { + id: record.id, + title: record.title, + description: record.description, + entity: record.supplierAwarded ?? "Entidad no especificada", + municipalityName: record.municipality.municipalityName, + stateName: record.municipality.stateName, + amountLabel: formatAmount(record.amount, record.currency), + daysToClose: getDaysUntilDate(record.closingDate), + procedureLabel: getProcedureTypeLabel(record.procedureType), + categoryLabel: getCategoryLabel(record.category), + publishDate: record.publishDate, + closingDate: record.closingDate, + sourceLabel: getSourceShortLabel(record.source), + deterministicScore, + aiFitScore, + compatibilityScore: blendedScore, + blendedScore, + reasons: recommendation?.reasons ?? [], + aiReasons: recommendation?.aiReasons ?? [], + aiRisks: recommendation?.aiRisks ?? [], + nextStep: recommendation?.nextStep ?? null, + suggestionId: recommendation?.suggestionId ?? null, + isOpen: record.isOpen, + reviewStatus: preferencesById.get(record.id) ?? "NEW", + isActive: activeLicitationIds.has(record.id), + primaryPdfUrl: + pickPrimaryLicitationPdfDocument(parseLicitationDocumentLinks(record.documents))?.url ?? + (isLikelyPdfUrl(record.rawSourceUrl) ? record.rawSourceUrl : null), + }; + }) + .filter((row) => { + if (statusFilter === "open") { + return row.isOpen; + } + + if (statusFilter === "closed") { + return !row.isOpen; + } + + return true; + }) + .filter((row) => { + if (viabilityFilter === "all") { + return true; + } + + return getViabilityLevel(row.compatibilityScore) === viabilityFilter; + }); + + const rows = baseRows.filter((row) => { + if (reviewFilter === "consulted") { + return row.reviewStatus === "REVIEWED"; + } + + if (reviewFilter === "interested") { + return row.reviewStatus === "INTERESTED"; + } + + if (reviewFilter === "active") { + return row.isActive; + } + + return true; + }); + + const sortedRows = [...rows].sort((left, right) => { + if (sortFilter === "compat_asc") { + return left.blendedScore - right.blendedScore; + } + + if (sortFilter === "closing_soon") { + const leftTime = left.closingDate?.getTime() ?? Number.MAX_SAFE_INTEGER; + const rightTime = right.closingDate?.getTime() ?? Number.MAX_SAFE_INTEGER; + return leftTime - rightTime; + } + + if (sortFilter === "recent") { + const leftTime = left.publishDate?.getTime() ?? 0; + const rightTime = right.publishDate?.getTime() ?? 0; + return rightTime - leftTime; + } + + return right.blendedScore - left.blendedScore; + }); + const uniqueStates = Array.from(new Map(municipalities.map((item) => [item.stateCode, item.stateName])).entries()).map(([code, name]) => ({ code, name, })); + const filteredMunicipalities = state ? municipalities.filter((item) => item.stateCode === state) : municipalities; + const now = new Date(); + const summary = computeModule3Summary( + sortedRows.length, + sortedRows.map((row) => ({ + id: row.id, + score: row.compatibilityScore, + publishDate: row.publishDate?.toISOString() ?? null, + })), + now, + ); + const newToReviewCount = sortedRows.filter((row) => row.reviewStatus === "NEW").length; + const consultedCount = baseRows.filter((row) => row.reviewStatus === "REVIEWED").length; + const interestedCount = baseRows.filter((row) => row.reviewStatus === "INTERESTED").length; + const activeCount = baseRows.filter((row) => row.isActive).length; return ( } + headerMode="module" + headerBackHref="/dashboard" + headerBackLabel="Volver al Dashboard" + headerShowManual + headerPlanBadgeLabel="Plan Premium" + showPageHeading={false} + contentWidth="wide" className="space-y-6" > +
+
+ +
+

Modulo 3: Deteccion de Oportunidades

+

Encuentra licitaciones compatibles con tu perfil empresarial

+
+
+ +
+ + + +
+

Como usar este modulo?

+

Guia rapida para encontrar oportunidades

+
+ + ˅ + +
+
+ +
+ + +

{summary.opportunities}

+

Oportunidades

+
+
+ + +

{summary.highViability}

+

Alta viabilidad

+
+
+ + +

{summary.mediumViability}

+

Media viabilidad

+
+
+ + +

{summary.averageCompatibility}%

+

Compatibilidad promedio

+
+
+
+ +
+ + + +

{consultedCount}

+

Consultadas

+
+
+ + + + +

{interestedCount}

+

Me interesa

+
+
+ + + + +

{activeCount}

+

Activas

+
+
+ +
+ +

+ Ultimo analisis: {formatDateShort(now)}, {now.toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit" })} · + {newToReviewCount} nuevas por revisar +

+ -
- + +
+ - + - + - + - + +
- +
+ - + - + -
- - - - + + + +
- - -

Recomendaciones para tu empresa

-
- - {recommendations.results.slice(0, 5).length ? ( - recommendations.results.slice(0, 5).map((item) => ( -
-
-

{item.title}

- Score {item.score} -
-

{item.municipalityName}, {item.stateName}

-

{item.reasons.join(" ")}

- - Ver detalle - -
- )) - ) : ( -

Aun no hay recomendaciones por perfil. Completa tu perfil y ejecuta sincronizacion.

- )} -
-
- - - -

Resultados ({records.total})

-

Mostrando oportunidades abiertas por defecto.

-
- - {records.records.length === 0 ? ( -

No se encontraron oportunidades con los filtros seleccionados.

- ) : ( - records.records.map((record) => ( -
-
-
-

{record.municipality.stateName} / {record.municipality.municipalityName}

-

{record.title}

-

{record.description ?? "Sin descripcion"}

+
+ {sortedRows.length === 0 ? ( + + +

No hay oportunidades con estos filtros

+

Ajusta los filtros o ejecuta una nueva sincronizacion.

+
+
+ ) : ( + sortedRows.map((row) => ( +
+
+
+ + + {row.isActive ? ( + Activa en M5 + ) : null}
- {getProcedureTypeLabel(record.procedureType)} +
+

{row.blendedScore}%

+

score combinado

+
-
- - {record.isOpen ? "Abierta" : "Cerrada"} +

{row.title}

+ +
+
+
+ +
+

+ Determinista: {row.deterministicScore}% +

+

+ AI fit: {row.aiFitScore}% +

+

+ Combinado: {row.blendedScore}% +

+
+ +
+

{row.entity}

+

{row.municipalityName}

+

{row.amountLabel}

+

{row.daysToClose}

+
+ +
+

Por que es compatible:

+ {row.aiReasons.length ? ( + row.aiReasons.slice(0, 2).map((reason) => ( + + {reason} + + )) + ) : row.reasons.length ? ( + row.reasons.slice(0, 2).map((reason) => ( + + {reason} + + )) + ) : ( + + Completa mas datos de perfil para mejorar recomendaciones. - {getCategoryLabel(record.category)} - {formatAmount(record.amount, record.currency)} - Publicacion: {formatDate(record.publishDate)} - Cierre: {formatDate(record.closingDate)} - Fuente: {getSourceLabel(record.source)} -
+ )} +
- - Ver detalle - -
- )) - )} - - + {row.aiRisks.length ? ( +
+

Riesgos detectados por IA:

+
+ {row.aiRisks.slice(0, 2).map((risk) => ( + + {risk} + + ))} +
+
+ ) : null} + + {row.nextStep ?

Siguiente paso sugerido: {row.nextStep}

: null} + +
+ Ver resumen rapido +
+

Procedimiento: {row.procedureLabel}

+

Categoria: {row.categoryLabel}

+

Fuente: {row.sourceLabel}

+

Publicacion: {formatDate(row.publishDate)}

+

Cierre: {formatDate(row.closingDate)}

+
+
+ +
+
+ + + + + + + {row.primaryPdfUrl ? ( + + + + ) : null} + + + +
+
+
+ )) + )} + + +
+
+
+

Te interesa una oportunidad?

+

Descarga las bases de licitacion y continua en Analisis Normativo (M4).

+
+ + + +
+
); } diff --git a/src/app/normative-analysis/page.tsx b/src/app/normative-analysis/page.tsx new file mode 100644 index 0000000..c4afb13 --- /dev/null +++ b/src/app/normative-analysis/page.tsx @@ -0,0 +1,113 @@ +import Link from "next/link"; +import { hasModuleAccess } from "@/lib/auth/module-access"; +import { requireOnboardedUser } from "@/lib/auth/user"; +import { prisma } from "@/lib/prisma"; +import { NormativeAnalysisView } from "@/components/app/normative-analysis-view"; +import { PageShell } from "@/components/app/page-shell"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +type NormativeAnalysisPageProps = { + searchParams: Promise>; +}; + +function getParam(params: Record, key: string) { + const value = params[key]; + return Array.isArray(value) ? value[0] ?? "" : (value ?? ""); +} + +export default async function NormativeAnalysisPage({ searchParams }: NormativeAnalysisPageProps) { + const user = await requireOnboardedUser(); + const hasPaidModulesAccess = await hasModuleAccess(user, 4); + + if (!hasPaidModulesAccess) { + return ( + Bloqueado} + > + + +

Acceso restringido

+

+ El Modulo 4 forma parte de la ruta premium. Tu cuenta actual puede completar el Diagnostico (Modulo 1) y revisar los planes en la seccion Modulos. +

+ + + +
+
+
+ ); + } + + const params = await searchParams; + const sourceId = getParam(params, "sourceId").trim(); + const linkedSource = sourceId + ? await prisma.licitation.findUnique({ + where: { id: sourceId }, + select: { + id: true, + title: true, + supplierAwarded: true, + }, + }) + : null; + + return ( + +
+ +
+

Modulo 4: Analisis Normativo

+

Analiza bases de licitacion, reglamentos y leyes con IA

+
+
+ + + +
+
+
+

Navegacion

+

Regresa al Modulo 3 para ver mas oportunidades o vuelve al dashboard.

+
+
+ + + + + + +
+
+
+
+ ); +} diff --git a/src/app/proteccion-legal/page.tsx b/src/app/proteccion-legal/page.tsx new file mode 100644 index 0000000..b4b9635 --- /dev/null +++ b/src/app/proteccion-legal/page.tsx @@ -0,0 +1,103 @@ +import Link from "next/link"; +import { hasModuleAccess } from "@/lib/auth/module-access"; +import { requireOnboardedUser } from "@/lib/auth/user"; +import { listLegalTemplates } from "@/lib/legal/ai-documents"; +import { getLegalKpisForUser, listLegalCasesForUser, listLegalDirectory } from "@/lib/legal/server"; +import { prisma } from "@/lib/prisma"; +import { LegalProtectionView } from "@/components/app/legal-protection-view"; +import { PageShell } from "@/components/app/page-shell"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +type PageProps = { + searchParams: Promise>; +}; + +function getParam(params: Record, key: string) { + const value = params[key]; + return Array.isArray(value) ? value[0] ?? "" : (value ?? ""); +} + +export default async function ProteccionLegalPage({ searchParams }: PageProps) { + const user = await requireOnboardedUser(); + const hasPaidModulesAccess = await hasModuleAccess(user, 9); + + if (!hasPaidModulesAccess) { + return ( + Bloqueado} + > + + +

Acceso restringido

+

Modulo 9 forma parte de la ruta premium de modulos pagados.

+
+ + + + + + +
+
+
+
+ ); + } + + const resolvedSearchParams = await searchParams; + const contractId = getParam(resolvedSearchParams, "contractId") || null; + + const [cases, kpis, directory, contracts] = await Promise.all([ + listLegalCasesForUser(user.id), + getLegalKpisForUser(user.id), + listLegalDirectory({}), + prisma.contractRecord.findMany({ + where: { userId: user.id }, + orderBy: [{ updatedAt: "desc" }], + select: { + id: true, + title: true, + contractNumber: true, + counterpartyEntity: true, + status: true, + }, + take: 200, + }), + ]); + const templates = listLegalTemplates(); + + return ( + +
+ +
+

Proteccion Legal

+

Diagnostico guiado, gestion de casos, escalada y generacion de escritos con trazabilidad

+
+
+ + +
+ ); +} diff --git a/src/app/strategic-diagnostic/page.tsx b/src/app/strategic-diagnostic/page.tsx index a793f1c..02e46b2 100644 --- a/src/app/strategic-diagnostic/page.tsx +++ b/src/app/strategic-diagnostic/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { isAdminIdentity } from "@/lib/auth/admin"; +import { hasModuleAccess } from "@/lib/auth/module-access"; import { StrategicDiagnosticWizard } from "@/components/app/strategic-diagnostic-wizard"; import { PageShell } from "@/components/app/page-shell"; import { Button } from "@/components/ui/button"; @@ -9,7 +9,7 @@ import { getStrategicDiagnosticSnapshot } from "@/lib/strategic-diagnostic/serve export default async function StrategicDiagnosticPage() { const user = await requireOnboardedUser(); - const hasPaidModulesAccess = isAdminIdentity(user.email, user.role); + const hasPaidModulesAccess = await hasModuleAccess(user, 2); if (!hasPaidModulesAccess) { return ( diff --git a/src/app/talleres-desarrollo/page.tsx b/src/app/talleres-desarrollo/page.tsx index d65d6b8..224fe0f 100644 --- a/src/app/talleres-desarrollo/page.tsx +++ b/src/app/talleres-desarrollo/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { isAdminIdentity } from "@/lib/auth/admin"; +import { hasAnyPaidModuleAccess } from "@/lib/auth/module-access"; import { requireOnboardedUser } from "@/lib/auth/user"; import { PageShell } from "@/components/app/page-shell"; import { Button } from "@/components/ui/button"; @@ -27,7 +27,7 @@ function parseDimension(searchParams: Record): M7Dataset { + return { + generatedAt: "2026-04-02T10:00:00.000Z", + kpis: { + activeLicitations: 2, + completionRate: 64, + criticalPending: 1, + upcoming7Days: 3, + }, + m3States: { + consulted: 1, + interested: 2, + active: 1, + new: 5, + }, + tabs: { + plazos: [ + { + id: "plazo-1", + proposalId: "p-1", + licitationId: "l-1", + title: "Cierre de convocatoria", + description: "Fecha limite", + dueAt: "2026-04-03T12:00:00.000Z", + source: "milestone", + status: "upcoming", + }, + ], + alertas: [ + { + id: "alerta-1", + proposalId: "p-1", + licitationId: "l-1", + title: "Vence pronto", + description: "Requisito critico", + severity: "high", + kind: "deadline_soon", + dueAt: "2026-04-03T12:00:00.000Z", + createdAt: "2026-04-02T10:00:00.000Z", + }, + ], + checklist: [ + { + proposalId: "p-1", + proposalTitle: "Propuesta 1", + mandatoryResolved: 4, + mandatoryTotal: 6, + completionRate: 67, + signaturePolicyStatus: "condicionado", + }, + ], + panelKpi: [ + { + id: "panel-1", + label: "Licitaciones activas", + value: "2", + tone: "success", + }, + ], + }, + ...(overrides ?? {}), + }; +} + +describe("ComplianceAlertsView UI contract", () => { + it("renders the four KPI cards and tab labels", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("Licitaciones activas"); + expect(html).toContain("Tasa de completitud"); + expect(html).toContain("Pendientes criticos"); + expect(html).toContain("Proximos 7 dias"); + expect(html).toContain("Plazos"); + expect(html).toContain("Alertas"); + expect(html).toContain("Checklist"); + expect(html).toContain("Panel KPI"); + }); + + it("renders empty state when there is no active compliance data", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Sin alertas de cumplimiento"); + }); + + it("keeps responsive layout classes for desktop and mobile grids", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("md:grid-cols-2"); + expect(html).toContain("xl:grid-cols-4"); + expect(html).toContain("md:grid-cols-4"); + }); +}); diff --git a/src/components/app/compliance-alerts-view.tsx b/src/components/app/compliance-alerts-view.tsx new file mode 100644 index 0000000..aaf3e9e --- /dev/null +++ b/src/components/app/compliance-alerts-view.tsx @@ -0,0 +1,388 @@ +"use client"; + +import { useState } from "react"; +import { Tabs } from "@/components/ui/tabs"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import type { M7Dataset } from "@/lib/compliance/types"; + +type ComplianceAlertsViewProps = { + dataset: M7Dataset; +}; + +type M7AiPlaybook = { + predictedIncidents: { + title: string; + likelihood: "alta" | "media" | "baja"; + impact: "alto" | "medio" | "bajo"; + timeHorizon: string; + }[]; + priorityOrder: string[]; + preventiveActions: { + action: string; + ownerSuggestion: string; + targetDate: string; + }[]; + escalationAdvice: string[]; + confidence: "low" | "medium" | "high"; +}; + +function toneClass(tone: "neutral" | "success" | "warning" | "danger") { + if (tone === "success") { + return "border-[#b9e6cd] bg-[#eaf9f1] text-[#1f8b63]"; + } + + if (tone === "warning") { + return "border-[#f0deb0] bg-[#fff8e9] text-[#8d6308]"; + } + + if (tone === "danger") { + return "border-[#f1c7ce] bg-[#fff1f4] text-[#b03f4f]"; + } + + return "border-[#d8e1ef] bg-[#f4f7fb] text-[#51688f]"; +} + +function severityClass(severity: "high" | "medium" | "low") { + if (severity === "high") { + return "border-[#f1c7ce] bg-[#fff1f4] text-[#b03f4f]"; + } + + if (severity === "medium") { + return "border-[#f0deb0] bg-[#fff8e9] text-[#8d6308]"; + } + + return "border-[#b9e6cd] bg-[#eaf9f1] text-[#1f8b63]"; +} + +function formatDateTime(value: string) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return "Sin fecha"; + } + + return parsed.toLocaleString("es-MX", { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function ComplianceAlertsView({ dataset }: ComplianceAlertsViewProps) { + const [isLoadingAiPlaybook, setIsLoadingAiPlaybook] = useState(false); + const [aiPlaybook, setAiPlaybook] = useState(null); + const [aiSuggestionId, setAiSuggestionId] = useState(null); + const [aiFeedback, setAiFeedback] = useState(null); + const [aiError, setAiError] = useState(null); + + async function setSuggestionDecision(decision: "accept" | "dismiss") { + if (!aiSuggestionId) { + return; + } + + await fetch(`/api/ai/suggestions/${encodeURIComponent(aiSuggestionId)}/decision`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ decision }), + }); + } + + async function generateAiPlaybook() { + setIsLoadingAiPlaybook(true); + setAiError(null); + setAiFeedback(null); + + try { + const response = await fetch("/api/compliance/m7/ai/playbook", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ dataset }), + }); + + const payload = (await response.json().catch(() => ({}))) as { + ok?: boolean; + error?: string; + predictedIncidents?: M7AiPlaybook["predictedIncidents"]; + priorityOrder?: M7AiPlaybook["priorityOrder"]; + preventiveActions?: M7AiPlaybook["preventiveActions"]; + escalationAdvice?: M7AiPlaybook["escalationAdvice"]; + confidence?: M7AiPlaybook["confidence"]; + suggestionId?: string; + }; + + if (!response.ok || !payload.ok) { + setAiError(payload.error ?? "No fue posible generar plan IA."); + return; + } + + setAiPlaybook({ + predictedIncidents: payload.predictedIncidents ?? [], + priorityOrder: payload.priorityOrder ?? [], + preventiveActions: payload.preventiveActions ?? [], + escalationAdvice: payload.escalationAdvice ?? [], + confidence: payload.confidence ?? "low", + }); + setAiSuggestionId(payload.suggestionId ?? null); + setAiFeedback("Plan IA generado."); + } catch { + setAiError("No fue posible generar plan IA."); + } finally { + setIsLoadingAiPlaybook(false); + } + } + + const isEmpty = + dataset.kpis.activeLicitations === 0 && + dataset.tabs.plazos.length === 0 && + dataset.tabs.alertas.length === 0 && + dataset.tabs.checklist.length === 0; + + return ( +
+
+ + +

Licitaciones activas

+

{dataset.kpis.activeLicitations}

+
+
+ + + +

Tasa de completitud

+

{dataset.kpis.completionRate}%

+
+
+ + + +

Pendientes criticos

+

{dataset.kpis.criticalPending}

+
+
+ + + +

Proximos 7 dias

+

{dataset.kpis.upcoming7Days}

+
+
+
+ +
+ + +

Consultadas

+

{dataset.m3States.consulted}

+
+
+ + +

Me interesa

+

{dataset.m3States.interested}

+
+
+ + +

Activas M5

+

{dataset.m3States.active}

+
+
+ + +

Nuevas M3

+

{dataset.m3States.new}

+
+
+
+ + {isEmpty ? ( + + +

Sin alertas de cumplimiento

+

Cuando tengas procesos activos en M5 y fechas detectadas, este tablero mostrara riesgos y vencimientos.

+
+
+ ) : null} + + {aiError ?

{aiError}

: null} + {aiFeedback ?

{aiFeedback}

: null} + + + {dataset.tabs.plazos.length === 0 ?

Sin plazos detectables para los proximos dias.

: null} + {dataset.tabs.plazos.map((item) => ( +
+
+

{item.title}

+ + {item.status === "overdue" ? "Vencido" : "Programado"} + +
+

{item.description}

+

{formatDateTime(item.dueAt)}

+
+ ))} +
+ ), + }, + { + id: "alertas", + label: "Alertas", + content: ( +
+ {dataset.tabs.alertas.length === 0 ?

Sin alertas activas.

: null} + {dataset.tabs.alertas.map((item) => ( +
+
+

{item.title}

+ {item.severity} +
+

{item.description}

+ {item.dueAt ?

{formatDateTime(item.dueAt)}

: null} +
+ ))} +
+ ), + }, + { + id: "checklist", + label: "Checklist", + content: ( +
+ {dataset.tabs.checklist.length === 0 ?

No hay procesos activos para checklist.

: null} + {dataset.tabs.checklist.map((item) => ( +
+

{item.proposalTitle}

+

+ Obligatorios resueltos: {item.mandatoryResolved}/{item.mandatoryTotal} ({item.completionRate}%) +

+

Firma: {item.signaturePolicyStatus.replaceAll("_", " ")}

+
+ ))} +
+ ), + }, + { + id: "panel-kpi", + label: "Panel KPI", + content: ( +
+ {dataset.tabs.panelKpi.map((item) => ( +
+

{item.label}

+

{item.value}

+
+ ))} +
+ ), + }, + { + id: "plan-ia", + label: "Plan IA", + content: ( +
+
+ + {aiPlaybook ? ( + + ) : null} +
+ + {!aiPlaybook ?

Sin plan IA generado todavia.

: null} + + {aiPlaybook ? ( + <> +
+

Incidentes predichos

+
    + {aiPlaybook.predictedIncidents.map((incident) => ( +
  • +

    {incident.title}

    +

    + Probabilidad {incident.likelihood} · Impacto {incident.impact} · Horizonte {incident.timeHorizon} +

    +
  • + ))} +
+
+ +
+

Orden de prioridad

+
    + {aiPlaybook.priorityOrder.map((item, index) => ( +
  1. + {index + 1}. {item} +
  2. + ))} +
+
+ +
+

Acciones preventivas

+
    + {aiPlaybook.preventiveActions.map((action) => ( +
  • +

    {action.action}

    +

    + Responsable sugerido: {action.ownerSuggestion} · Fecha objetivo: {action.targetDate} +

    +
  • + ))} +
+
+ +
+

Recomendaciones de escalamiento

+
    + {aiPlaybook.escalationAdvice.map((item) => ( +
  • - {item}
  • + ))} +
+
+ +
+
+ + ) : null} +
+ ), + }, + ]} + /> +
+ ); +} diff --git a/src/components/app/contracts-management-view.tsx b/src/components/app/contracts-management-view.tsx new file mode 100644 index 0000000..6f71395 --- /dev/null +++ b/src/components/app/contracts-management-view.tsx @@ -0,0 +1,1046 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; +import type { + ContractCreateInput, + ContractDeliverableView, + ContractKpiSnapshot, + ContractPaymentView, + ContractRecordView, +} from "@/lib/contracts/types"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Dialog } from "@/components/ui/dialog"; +import { Tabs } from "@/components/ui/tabs"; + +type ContractsManagementViewProps = { + initialContracts: ContractRecordView[]; + initialKpis: ContractKpiSnapshot; + proposalPdfOptions: Array<{ + proposalId: string; + proposalTitle: string; + documentId: string; + fileName: string; + createdAt: string; + }>; +}; + +type ApiPayload = { + ok?: boolean; + error?: string; + contracts?: ContractRecordView[]; + contract?: ContractRecordView | null; + document?: { + id: string; + contractId: string; + fileName: string; + filePath: string; + mimeType: string; + sizeBytes: number; + checksumSha256: string | null; + kind: ContractRecordView["documents"][number]["kind"]; + createdAt: string; + }; + kpis?: ContractKpiSnapshot; + extraction?: { + warnings?: Array<{ code: string; message: string }>; + fields?: { + title?: string | null; + counterpartyEntity?: string | null; + contractNumber?: string | null; + contractType?: string | null; + totalAmount?: number | null; + }; + }; +}; + +function formatDate(value: string | null) { + if (!value) { + return "Sin fecha"; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return "Sin fecha"; + } + + return parsed.toLocaleDateString("es-MX", { + year: "numeric", + month: "short", + day: "2-digit", + }); +} + +function formatCurrency(amount: number, currency = "MXN") { + return new Intl.NumberFormat("es-MX", { + style: "currency", + currency, + maximumFractionDigits: 2, + }).format(amount); +} + +function formatDateTime(value: string) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return "Sin fecha"; + } + + return parsed.toLocaleString("es-MX", { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatFileSize(bytes: number) { + if (!Number.isFinite(bytes) || bytes <= 0) { + return "N/D"; + } + + if (bytes < 1024) { + return `${bytes} B`; + } + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function contractDocumentKindLabel(kind: ContractRecordView["documents"][number]["kind"]) { + if (kind === "SIGNED_CONTRACT") { + return "Contrato firmado"; + } + + if (kind === "ADDENDUM") { + return "Convenio / adenda"; + } + + if (kind === "DELIVERABLE_EVIDENCE") { + return "Evidencia de entregable"; + } + + if (kind === "PAYMENT_EVIDENCE") { + return "Evidencia de pago"; + } + + return "Otro"; +} + +function statusTone(status: ContractRecordView["status"]) { + if (status === "ACTIVE") { + return "border-[#bce5d1] bg-[#ebf9f1] text-[#1e8b63]"; + } + + if (status === "COMPLETED") { + return "border-[#c7d8f5] bg-[#ecf3ff] text-[#2c59a8]"; + } + + if (status === "PAUSED") { + return "border-[#f0dfb8] bg-[#fff9ea] text-[#946807]"; + } + + return "border-[#e6c9cf] bg-[#fff2f4] text-[#b04a58]"; +} + +export function ContractsManagementView({ initialContracts, initialKpis, proposalPdfOptions }: ContractsManagementViewProps) { + const [contracts, setContracts] = useState(initialContracts); + const [kpis, setKpis] = useState(initialKpis); + const [isBusy, setIsBusy] = useState(false); + const [message, setMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [extractionSummary, setExtractionSummary] = useState(null); + + const [uploadContractId, setUploadContractId] = useState(""); + const [uploadFile, setUploadFile] = useState(null); + const [proposalSourceId, setProposalSourceId] = useState(proposalPdfOptions[0]?.proposalId ?? ""); + const [evidenceContractId, setEvidenceContractId] = useState(""); + const [evidenceKind, setEvidenceKind] = useState("SIGNED_CONTRACT"); + const [evidenceFile, setEvidenceFile] = useState(null); + + const [newContract, setNewContract] = useState({ + sourceProposalId: null, + title: "", + counterpartyEntity: "", + contractNumber: null, + contractType: "General", + startDate: null, + endDate: null, + totalAmount: null, + currency: "MXN", + status: "ACTIVE", + description: "", + }); + + const [newDeliverable, setNewDeliverable] = useState({ + contractId: "", + title: "", + dueDate: "", + amountLinked: "", + notes: "", + }); + + const [newPayment, setNewPayment] = useState({ + contractId: "", + amount: "", + paymentDate: "", + invoiceNumber: "", + concept: "", + status: "REGISTERED", + }); + + const deliverables = useMemo(() => { + const rows: Array = []; + + for (const contract of contracts) { + for (const item of contract.deliverables) { + rows.push({ + ...item, + contractTitle: contract.title, + contractId: contract.id, + }); + } + } + + return rows.sort((a, b) => { + const left = a.dueDate ? new Date(a.dueDate).getTime() : Number.POSITIVE_INFINITY; + const right = b.dueDate ? new Date(b.dueDate).getTime() : Number.POSITIVE_INFINITY; + return left - right; + }); + }, [contracts]); + + const payments = useMemo(() => { + const rows: Array = []; + + for (const contract of contracts) { + for (const item of contract.payments) { + rows.push({ + ...item, + contractTitle: contract.title, + currency: contract.currency, + }); + } + } + + return rows.sort((a, b) => new Date(b.paymentDate).getTime() - new Date(a.paymentDate).getTime()); + }, [contracts]); + + const selectedProposalPdfOption = useMemo( + () => proposalPdfOptions.find((item) => item.proposalId === proposalSourceId) ?? null, + [proposalPdfOptions, proposalSourceId], + ); + + const hasContractsWithoutProposalTrace = useMemo( + () => contracts.some((item) => !item.sourceProposalId), + [contracts], + ); + + async function reloadData() { + const [contractsResponse, kpisResponse] = await Promise.all([fetch("/api/contracts"), fetch("/api/contracts/kpis")]); + + const contractsPayload = (await contractsResponse.json().catch(() => ({}))) as ApiPayload; + const kpisPayload = (await kpisResponse.json().catch(() => ({}))) as ApiPayload; + + if (contractsResponse.ok && contractsPayload.ok && contractsPayload.contracts) { + setContracts(contractsPayload.contracts); + } + + if (kpisResponse.ok && kpisPayload.ok && kpisPayload.kpis) { + setKpis(kpisPayload.kpis); + } + } + + async function createContract() { + setErrorMessage(null); + setMessage(null); + setIsBusy(true); + + try { + const response = await fetch("/api/contracts", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...newContract, + totalAmount: newContract.totalAmount === null ? null : Number(newContract.totalAmount), + }), + }); + + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + + if (!response.ok || !payload.ok) { + setErrorMessage(payload.error ?? "No fue posible crear el contrato."); + return; + } + + setMessage("Contrato creado correctamente."); + setNewContract({ + sourceProposalId: null, + title: "", + counterpartyEntity: "", + contractNumber: null, + contractType: "General", + startDate: null, + endDate: null, + totalAmount: null, + currency: "MXN", + status: "ACTIVE", + description: "", + }); + await reloadData(); + } catch { + setErrorMessage("No fue posible crear el contrato."); + } finally { + setIsBusy(false); + } + } + + async function updateContractStatus(contractId: string, status: ContractRecordView["status"]) { + setErrorMessage(null); + setMessage(null); + + const response = await fetch(`/api/contracts/${encodeURIComponent(contractId)}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ status }), + }); + + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + + if (!response.ok || !payload.ok) { + setErrorMessage(payload.error ?? "No fue posible actualizar el estado."); + return; + } + + setMessage("Estado de contrato actualizado."); + await reloadData(); + } + + async function performExtraction(formData: FormData, successMessage: string) { + setErrorMessage(null); + setMessage(null); + setExtractionSummary(null); + setIsBusy(true); + + try { + const response = await fetch("/api/contracts/extract", { + method: "POST", + body: formData, + }); + + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + + if (!response.ok || !payload.ok) { + setErrorMessage(payload.error ?? "No fue posible analizar el contrato."); + return false; + } + + const extraction = payload.extraction; + const fields = extraction?.fields; + const warningText = extraction?.warnings?.map((item) => item.message).join(" | ") ?? ""; + setExtractionSummary( + [ + fields?.title ? `Titulo: ${fields.title}` : "", + fields?.counterpartyEntity ? `Entidad: ${fields.counterpartyEntity}` : "", + fields?.contractNumber ? `Numero: ${fields.contractNumber}` : "", + fields?.contractType ? `Tipo: ${fields.contractType}` : "", + fields?.totalAmount ? `Monto: ${fields.totalAmount}` : "", + warningText ? `Notas: ${warningText}` : "", + ] + .filter(Boolean) + .join(" • "), + ); + + setMessage(successMessage); + await reloadData(); + return true; + } catch { + setErrorMessage("No fue posible analizar el contrato."); + return false; + } finally { + setIsBusy(false); + } + } + + async function submitExtraction() { + const currentFile = uploadFile; + if (!currentFile) { + setErrorMessage("Selecciona un PDF para extraer."); + return; + } + + const formData = new FormData(); + formData.append("file", currentFile); + + if (uploadContractId) { + formData.append("contractId", uploadContractId); + } + + const ok = await performExtraction(formData, "Contrato analizado y guardado."); + if (ok) { + setUploadFile(null); + setUploadContractId(""); + } + } + + async function submitExtractionFromProposal() { + if (!proposalSourceId) { + setErrorMessage("Selecciona una propuesta con PDF para continuar."); + return; + } + + const formData = new FormData(); + formData.append("sourceProposalId", proposalSourceId); + + if (uploadContractId) { + formData.append("contractId", uploadContractId); + } + + const ok = await performExtraction(formData, "Contrato analizado usando PDF de propuesta (M5)."); + if (ok) { + setUploadFile(null); + } + } + + async function uploadContractEvidence() { + if (!evidenceContractId) { + setErrorMessage("Selecciona un contrato para adjuntar evidencia."); + return; + } + + if (!evidenceFile) { + setErrorMessage("Selecciona un archivo PDF para adjuntar evidencia."); + return; + } + + setIsBusy(true); + setErrorMessage(null); + setMessage(null); + + try { + const formData = new FormData(); + formData.append("contractId", evidenceContractId); + formData.append("kind", evidenceKind); + formData.append("file", evidenceFile); + + const response = await fetch("/api/contracts/upload", { + method: "POST", + body: formData, + }); + + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + if (!response.ok || !payload.ok || !payload.document) { + setErrorMessage(payload.error ?? "No fue posible adjuntar el documento al contrato."); + return; + } + + setMessage("Documento adjuntado al contrato."); + setEvidenceFile(null); + await reloadData(); + } catch { + setErrorMessage("No fue posible adjuntar el documento al contrato."); + } finally { + setIsBusy(false); + } + } + + async function deleteContractDocument(contractId: string, documentId: string) { + if (!window.confirm("Se eliminara este documento del contrato. Deseas continuar?")) { + return; + } + + setErrorMessage(null); + setMessage(null); + setIsBusy(true); + + try { + const response = await fetch(`/api/contracts/${encodeURIComponent(contractId)}/documents/${encodeURIComponent(documentId)}`, { + method: "DELETE", + }); + + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + if (!response.ok || !payload.ok) { + setErrorMessage(payload.error ?? "No fue posible eliminar el documento."); + return; + } + + setMessage("Documento eliminado."); + await reloadData(); + } catch { + setErrorMessage("No fue posible eliminar el documento."); + } finally { + setIsBusy(false); + } + } + + async function createDeliverable() { + if (!newDeliverable.contractId) { + setErrorMessage("Selecciona un contrato para el entregable."); + return; + } + + setErrorMessage(null); + setMessage(null); + setIsBusy(true); + + try { + const response = await fetch(`/api/contracts/${encodeURIComponent(newDeliverable.contractId)}/deliverables`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title: newDeliverable.title, + dueDate: newDeliverable.dueDate ? new Date(newDeliverable.dueDate).toISOString() : null, + amountLinked: newDeliverable.amountLinked ? Number(newDeliverable.amountLinked) : null, + notes: newDeliverable.notes, + }), + }); + + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + + if (!response.ok || !payload.ok) { + setErrorMessage(payload.error ?? "No fue posible crear el entregable."); + return; + } + + setMessage("Entregable registrado."); + setNewDeliverable({ + contractId: "", + title: "", + dueDate: "", + amountLinked: "", + notes: "", + }); + await reloadData(); + } catch { + setErrorMessage("No fue posible crear el entregable."); + } finally { + setIsBusy(false); + } + } + + async function updateDeliverableStatus(deliverableId: string, status: ContractDeliverableView["status"]) { + setErrorMessage(null); + + const response = await fetch(`/api/deliverables/${encodeURIComponent(deliverableId)}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ status }), + }); + + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + + if (!response.ok || !payload.ok) { + setErrorMessage(payload.error ?? "No fue posible actualizar el entregable."); + return; + } + + setMessage("Entregable actualizado."); + await reloadData(); + } + + async function createPayment() { + if (!newPayment.contractId) { + setErrorMessage("Selecciona un contrato para el pago."); + return; + } + + setErrorMessage(null); + setMessage(null); + setIsBusy(true); + + try { + const response = await fetch(`/api/contracts/${encodeURIComponent(newPayment.contractId)}/payments`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + amount: Number(newPayment.amount), + paymentDate: new Date(newPayment.paymentDate).toISOString(), + invoiceNumber: newPayment.invoiceNumber || null, + concept: newPayment.concept, + status: newPayment.status, + }), + }); + + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + + if (!response.ok || !payload.ok) { + setErrorMessage(payload.error ?? "No fue posible registrar el pago."); + return; + } + + setMessage("Pago registrado."); + setNewPayment({ + contractId: "", + amount: "", + paymentDate: "", + invoiceNumber: "", + concept: "", + status: "REGISTERED", + }); + await reloadData(); + } catch { + setErrorMessage("No fue posible registrar el pago."); + } finally { + setIsBusy(false); + } + } + + return ( +
+
+ + +

Contratos activos

+

{kpis.activeContracts}

+
+
+ + + +

Total cobrado

+

{formatCurrency(kpis.totalCollected)}

+
+
+ + + +

Entregables pendientes

+

{kpis.pendingDeliverables}

+
+
+ + + +

Entregables vencidos

+

{kpis.overdueDeliverables}

+
+
+
+ + {errorMessage ?

{errorMessage}

: null} + {message ?

{message}

: null} + {extractionSummary ?

{extractionSummary}

: null} + {proposalPdfOptions.length === 0 ? ( +

+ Advertencia de continuidad: no hay propuestas M5 con PDF para reutilizar en M8. +

+ ) : null} + {hasContractsWithoutProposalTrace ? ( +

+ Advertencia de continuidad: hay contratos sin `sourceProposalId`, la trazabilidad M5->M8 puede estar incompleta. +

+ ) : null} + + + +

Sube contrato PDF para extraer campos, entregables, pagos y clausulas de riesgo.

+ + + + + + + +
+ +
+

Continuidad M5 a M8 (sin reupload)

+

Usa el PDF mas reciente de una propuesta de Modulo 5 como fuente de extraccion.

+ + + + + {selectedProposalPdfOption ? ( +

+ Por que este archivo: se usa el PDF mas reciente de la propuesta seleccionada ({selectedProposalPdfOption.fileName}). +

+ ) : null} +
+ +
+ +
+

Adjuntar documento al contrato (sin IA)

+

Usa este flujo para subir evidencia contractual directamente usando `/api/contracts/upload`.

+ + + + + + + + +
+ + + ), + }, + { + id: "contracts", + label: "Contratos", + content: ( +
+
+ +
+ setNewContract((prev) => ({ ...prev, title: event.target.value }))} + placeholder="Titulo" + className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" + /> + setNewContract((prev) => ({ ...prev, counterpartyEntity: event.target.value }))} + placeholder="Entidad contratante" + className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" + /> + setNewContract((prev) => ({ ...prev, contractNumber: event.target.value || null }))} + placeholder="Numero de contrato" + className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" + /> + setNewContract((prev) => ({ ...prev, contractType: event.target.value }))} + placeholder="Tipo" + className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" + /> + setNewContract((prev) => ({ ...prev, totalAmount: event.target.value ? Number(event.target.value) : null }))} + placeholder="Monto total" + type="number" + className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" + /> + +
+
+
+ + {contracts.length === 0 ?

Aun no hay contratos registrados.

: null} + + {contracts.map((contract) => ( +
+
+
+

{contract.title}

+

{contract.counterpartyEntity}

+

{contract.contractType}

+
+ {contract.status} +
+ +
+

Inicio: {formatDate(contract.startDate)}

+

Fin: {formatDate(contract.endDate)}

+

Monto: {contract.totalAmount ? formatCurrency(contract.totalAmount, contract.currency) : "Sin monto"}

+

+ Progreso: {contract.progress.completed}/{contract.progress.total} ({contract.progress.percent}%) +

+
+ +
+

Documentos del contrato

+ {contract.documents.length === 0 ?

Sin documentos cargados.

: null} + + {contract.documents.length > 0 ? ( +
    + {contract.documents.map((document) => ( +
  • +
    +
    +

    {document.fileName}

    +

    + {contractDocumentKindLabel(document.kind)} | {formatFileSize(document.sizeBytes)} | {formatDateTime(document.createdAt)} +

    + {contract.sourceProposalId && document.kind === "SIGNED_CONTRACT" ? ( +

    + Por que este archivo: continuidad M5->M8 desde propuesta {contract.sourceProposalId}. +

    + ) : null} +
    +
    + + Descargar + + +
    +
    +
  • + ))} +
+ ) : null} +
+ +
+ + + + + +
+
+ ))} +
+ ), + }, + { + id: "deliverables", + label: "Entregables", + content: ( +
+ +
+ + setNewDeliverable((prev) => ({ ...prev, title: event.target.value }))} + placeholder="Titulo del entregable" + className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" + /> + setNewDeliverable((prev) => ({ ...prev, dueDate: event.target.value }))} + className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" + /> + setNewDeliverable((prev) => ({ ...prev, amountLinked: event.target.value }))} + placeholder="Monto vinculado" + className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" + /> + +
+
+ + {deliverables.length === 0 ?

Sin entregables registrados.

: null} + + {deliverables.map((item) => ( +
+
+
+

{item.title}

+

{item.contractTitle}

+
+ +
+

Vence: {formatDate(item.dueDate)} {item.isOverdue ? "(vencido)" : ""}

+
+ ))} +
+ ), + }, + { + id: "payments", + label: "Pagos", + content: ( +
+ +
+ + setNewPayment((prev) => ({ ...prev, amount: event.target.value }))} + placeholder="Monto" + className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" + /> + setNewPayment((prev) => ({ ...prev, paymentDate: event.target.value }))} + className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" + /> + setNewPayment((prev) => ({ ...prev, concept: event.target.value }))} + placeholder="Concepto" + className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3" + /> + +
+
+ + {payments.length === 0 ?

Sin pagos registrados.

: null} + + {payments.map((item) => ( +
+
+

{item.contractTitle}

+ {formatCurrency(item.amount, item.currency)} +
+

{item.concept}

+

+ {formatDate(item.paymentDate)} - {item.status} +

+
+ ))} +
+ ), + }, + ]} + /> +
+ ); +} diff --git a/src/components/app/dashboard-maturity-section.tsx b/src/components/app/dashboard-maturity-section.tsx index ac75d76..2522b07 100644 --- a/src/components/app/dashboard-maturity-section.tsx +++ b/src/components/app/dashboard-maturity-section.tsx @@ -43,13 +43,13 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
-

Tu Indice de Madurez

+

Tu Indice de Madurez

Visualiza tu progreso hacia el siguiente nivel de preparacion.

-

{Math.round(snapshot.overallMaturity)}%

-

Puntaje Global

+

{Math.round(snapshot.overallMaturity)}%

+

Puntaje Global

Nivel {snapshot.maturityLevel.label} @@ -69,7 +69,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
-

+

Proximo nivel: {snapshot.nextLevel?.label ?? "Avanzado"}

{snapshot.nextLevel ? ( @@ -101,7 +101,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
-

Tu RADAR Empresarial

+

Tu RADAR Empresarial

Visualiza tus fortalezas y areas de mejora en 5 dimensiones.

@@ -116,7 +116,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps -

Puntaje por Dimension

+

Puntaje por Dimension

Cada respuesta afirmativa suma 20 puntos. Maximo 100 por dimension.

@@ -125,7 +125,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
{statusIcon(dimension.gapLevel)} -

{dimension.moduleName}

+

{dimension.moduleName}

{dimension.gapLabel} - {Math.round(dimension.displayScore)}% + {Math.round(dimension.displayScore)}%
@@ -152,7 +152,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps -

Analisis de Brechas por Dimension

+

Analisis de Brechas por Dimension

Identificacion de riesgos asociados a cada area de tu empresa.

@@ -193,13 +193,13 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps -

Recomendaciones Estrategicas Generales

+

Recomendaciones Estrategicas Generales

3 acciones prioritarias para mejorar tu posicion competitiva.

{snapshot.recommendations.map((recommendation, index) => (
-

+

{index + 1}. {recommendation.title}

{recommendation.description}

@@ -211,19 +211,19 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
-

Que es RADAR?

+

Que es RADAR?

El diagnostico RADAR evalua 5 dimensiones clave para identificar tu nivel de preparacion.

-

Que es CRECE?

+

Que es CRECE?

Es tu ruta de fortalecimiento empresarial con capacitacion, rediseno y preparacion.

-

Indice de Madurez

+

Indice de Madurez

Mide tu evolucion de Inicial a En Desarrollo, Preparado y Avanzado.

diff --git a/src/components/app/legal-protection-view.tsx b/src/components/app/legal-protection-view.tsx new file mode 100644 index 0000000..ab9fd32 --- /dev/null +++ b/src/components/app/legal-protection-view.tsx @@ -0,0 +1,891 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import type { + DiagnosisRecommendation, + LegalCaseView, + LegalContractOptionView, + LegalDirectoryEntityView, + LegalKpiSnapshot, + LegalTemplateView, +} from "@/lib/legal/types"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Dialog } from "@/components/ui/dialog"; +import { Tabs } from "@/components/ui/tabs"; + +type LegalProtectionViewProps = { + initialCases: LegalCaseView[]; + initialKpis: LegalKpiSnapshot; + initialDirectory: LegalDirectoryEntityView[]; + initialTemplates: LegalTemplateView[]; + availableContracts: LegalContractOptionView[]; + prefilledContractId?: string | null; +}; + +type ApiPayload = { + ok?: boolean; + error?: string; + kpis?: LegalKpiSnapshot; + cases?: LegalCaseView[]; + case?: LegalCaseView | null; + diagnosis?: { + id: string; + legalCaseId: string | null; + recommendation: DiagnosisRecommendation; + }; + escalation?: { + caseId: string; + steps: Array<{ + key: string; + title: string; + estimatedWindow: string; + checklist: string[]; + requiresLawyer: boolean; + dependsOn: string | null; + completedAt: string | null; + notes: string; + isBlocked: boolean; + }>; + }; + templates?: LegalTemplateView[]; + entities?: LegalDirectoryEntityView[]; + document?: { + id: string; + title: string; + content: string; + createdAt: string; + }; + generation?: { + warnings?: string[]; + }; +}; + +function formatDate(value: string | null) { + if (!value) { + return "Sin fecha"; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return "Sin fecha"; + } + + return parsed.toLocaleString("es-MX", { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function severityTone(severity: LegalCaseView["severity"]) { + if (severity === "HIGH") { + return "border-[#f1c7ce] bg-[#fff1f4] text-[#b03f4f]"; + } + + if (severity === "MEDIUM") { + return "border-[#f0deb0] bg-[#fff8e9] text-[#8d6308]"; + } + + return "border-[#b9e6cd] bg-[#eaf9f1] text-[#1f8b63]"; +} + +function formatContractOptionLabel(contract: LegalContractOptionView) { + const numberLabel = contract.contractNumber ? `#${contract.contractNumber}` : "Sin numero"; + return `${contract.title} - ${contract.counterpartyEntity} (${numberLabel})`; +} + +export function LegalProtectionView({ + initialCases, + initialKpis, + initialDirectory, + initialTemplates, + availableContracts, + prefilledContractId, +}: LegalProtectionViewProps) { + const [cases, setCases] = useState(initialCases); + const [kpis, setKpis] = useState(initialKpis); + const [directory, setDirectory] = useState(initialDirectory); + const [templates, setTemplates] = useState(initialTemplates); + + const [isBusy, setIsBusy] = useState(false); + const [message, setMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + const [diagnosisStep, setDiagnosisStep] = useState(1); + const [diagnosisId, setDiagnosisId] = useState(null); + const [diagnosisRecommendation, setDiagnosisRecommendation] = useState(null); + const [diagnosisAnswers, setDiagnosisAnswers] = useState({ + breachType: "contract_breach", + processStage: "none", + evidenceLevel: "basic", + urgency: "medium", + objective: "payment", + amountAtRisk: "", + counterparty: "", + description: "", + }); + const [diagnosisContractId, setDiagnosisContractId] = useState(prefilledContractId ?? ""); + + const [newCase, setNewCase] = useState({ + contractId: prefilledContractId ?? "", + caseType: "CONTRACT_BREACH", + severity: "MEDIUM", + counterparty: "", + description: "", + amountAtRisk: "", + status: "OPEN", + }); + + const [selectedEscalationCaseId, setSelectedEscalationCaseId] = useState(""); + const [escalation, setEscalation] = useState(null); + + const [documentDraft, setDocumentDraft] = useState({ + legalCaseId: "", + caseType: "CONTRACT_BREACH", + severity: "MEDIUM", + templateKey: "", + counterparty: "", + companyName: "", + amountAtRisk: "", + description: "", + objective: "", + title: "", + }); + const [generatedDocuments, setGeneratedDocuments] = useState>([]); + + const [directoryFilters, setDirectoryFilters] = useState({ + jurisdictionLevel: "", + q: "", + }); + + const currentEscalationCase = useMemo( + () => cases.find((item) => item.id === selectedEscalationCaseId) ?? null, + [cases, selectedEscalationCaseId], + ); + const contractOptions = useMemo(() => { + const options = availableContracts.map((item) => ({ + id: item.id, + label: formatContractOptionLabel(item), + isPrefilled: item.id === prefilledContractId, + })); + + if (prefilledContractId && !options.some((item) => item.id === prefilledContractId)) { + options.unshift({ + id: prefilledContractId, + label: `${prefilledContractId} (prefill)`, + isPrefilled: true, + }); + } + + return options; + }, [availableContracts, prefilledContractId]); + + async function reloadDataset() { + const [kpisResponse, casesResponse, templatesResponse] = await Promise.all([ + fetch("/api/legal/kpis"), + fetch("/api/legal/cases"), + fetch("/api/legal/templates"), + ]); + + const kpisPayload = (await kpisResponse.json().catch(() => ({}))) as ApiPayload; + const casesPayload = (await casesResponse.json().catch(() => ({}))) as ApiPayload; + const templatesPayload = (await templatesResponse.json().catch(() => ({}))) as ApiPayload; + + if (kpisResponse.ok && kpisPayload.ok && kpisPayload.kpis) { + setKpis(kpisPayload.kpis); + } + + if (casesResponse.ok && casesPayload.ok && casesPayload.cases) { + setCases(casesPayload.cases); + } + + if (templatesResponse.ok && templatesPayload.ok && templatesPayload.templates) { + setTemplates(templatesPayload.templates); + } + } + + async function startDiagnosis() { + setIsBusy(true); + setErrorMessage(null); + + try { + const response = await fetch("/api/legal/diagnosis/start", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + contractId: diagnosisContractId || null, + }), + }); + + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + + if (!response.ok || !payload.ok || !payload.diagnosis) { + setErrorMessage(payload.error ?? "No fue posible iniciar diagnostico."); + return; + } + + setDiagnosisId(payload.diagnosis.id); + setDiagnosisStep(1); + setDiagnosisRecommendation(payload.diagnosis.recommendation); + setMessage("Diagnostico iniciado."); + } catch { + setErrorMessage("No fue posible iniciar diagnostico."); + } finally { + setIsBusy(false); + } + } + + async function submitDiagnosisStep(finalize = false) { + if (!diagnosisId) { + await startDiagnosis(); + return; + } + + setIsBusy(true); + setErrorMessage(null); + + try { + const response = await fetch("/api/legal/diagnosis/answer", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + diagnosisId, + stepIndex: diagnosisStep, + finalize, + answers: { + breachType: diagnosisAnswers.breachType, + processStage: diagnosisAnswers.processStage, + evidenceLevel: diagnosisAnswers.evidenceLevel, + urgency: diagnosisAnswers.urgency, + objective: diagnosisAnswers.objective, + amountAtRisk: diagnosisAnswers.amountAtRisk ? Number(diagnosisAnswers.amountAtRisk) : null, + counterparty: diagnosisAnswers.counterparty, + description: diagnosisAnswers.description, + }, + }), + }); + + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + + if (!response.ok || !payload.ok || !payload.diagnosis) { + setErrorMessage(payload.error ?? "No fue posible registrar respuesta del diagnostico."); + return; + } + + setDiagnosisRecommendation(payload.diagnosis.recommendation); + if (finalize) { + setMessage("Diagnostico finalizado y caso legal generado."); + setDiagnosisStep(4); + await reloadDataset(); + } else { + setDiagnosisStep((prev) => Math.min(4, prev + 1)); + } + } catch { + setErrorMessage("No fue posible registrar respuesta del diagnostico."); + } finally { + setIsBusy(false); + } + } + + async function createCase() { + setIsBusy(true); + setErrorMessage(null); + + try { + const response = await fetch("/api/legal/cases", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + contractId: newCase.contractId || null, + caseType: newCase.caseType, + severity: newCase.severity, + counterparty: newCase.counterparty, + description: newCase.description, + amountAtRisk: newCase.amountAtRisk ? Number(newCase.amountAtRisk) : null, + status: newCase.status, + }), + }); + + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + + if (!response.ok || !payload.ok) { + setErrorMessage(payload.error ?? "No fue posible crear el caso legal."); + return; + } + + setMessage("Caso legal creado."); + setNewCase({ + contractId: prefilledContractId ?? "", + caseType: "CONTRACT_BREACH", + severity: "MEDIUM", + counterparty: "", + description: "", + amountAtRisk: "", + status: "OPEN", + }); + await reloadDataset(); + } catch { + setErrorMessage("No fue posible crear el caso legal."); + } finally { + setIsBusy(false); + } + } + + async function updateCaseStatus(caseId: string, status: LegalCaseView["status"]) { + setErrorMessage(null); + + const resolvedAt = status === "RESOLVED" || status === "CLOSED" ? new Date().toISOString() : null; + const response = await fetch(`/api/legal/cases/${encodeURIComponent(caseId)}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ status, resolvedAt }), + }); + + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + + if (!response.ok || !payload.ok) { + setErrorMessage(payload.error ?? "No fue posible actualizar el caso."); + return; + } + + setMessage("Caso actualizado."); + await reloadDataset(); + } + + async function loadEscalation(caseId: string) { + if (!caseId) { + setEscalation(null); + return; + } + + const response = await fetch(`/api/legal/escalation/${encodeURIComponent(caseId)}`); + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + + if (!response.ok || !payload.ok || !payload.escalation) { + setErrorMessage(payload.error ?? "No fue posible cargar la ruta de escalada."); + return; + } + + setEscalation(payload.escalation); + } + + async function toggleEscalationStep(caseId: string, stepKey: string, completed: boolean, notes: string) { + const response = await fetch(`/api/legal/escalation/${encodeURIComponent(caseId)}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + routeStepKey: stepKey, + completed, + notes, + }), + }); + + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + + if (!response.ok || !payload.ok || !payload.escalation) { + setErrorMessage(payload.error ?? "No fue posible actualizar la escalada."); + return; + } + + setEscalation(payload.escalation); + setMessage("Escalada actualizada."); + } + + async function generateDocument() { + setIsBusy(true); + setErrorMessage(null); + + try { + const response = await fetch("/api/legal/documents/generate", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + legalCaseId: documentDraft.legalCaseId || null, + caseType: documentDraft.caseType, + severity: documentDraft.severity, + templateKey: documentDraft.templateKey || null, + counterparty: documentDraft.counterparty, + companyName: documentDraft.companyName, + amountAtRisk: documentDraft.amountAtRisk ? Number(documentDraft.amountAtRisk) : null, + description: documentDraft.description, + objective: documentDraft.objective, + title: documentDraft.title || null, + }), + }); + + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + + if (!response.ok || !payload.ok || !payload.document) { + setErrorMessage(payload.error ?? "No fue posible generar el escrito."); + return; + } + + setGeneratedDocuments((prev) => [payload.document as { id: string; title: string; content: string; createdAt: string }, ...prev]); + const warningText = payload.generation?.warnings?.join(" | "); + setMessage(warningText ? `Escrito generado. ${warningText}` : "Escrito generado."); + } catch { + setErrorMessage("No fue posible generar el escrito."); + } finally { + setIsBusy(false); + } + } + + async function reloadDirectory() { + const params = new URLSearchParams(); + if (directoryFilters.jurisdictionLevel) { + params.set("jurisdictionLevel", directoryFilters.jurisdictionLevel); + } + if (directoryFilters.q.trim()) { + params.set("q", directoryFilters.q.trim()); + } + + const response = await fetch(`/api/legal/directory${params.toString() ? `?${params.toString()}` : ""}`); + const payload = (await response.json().catch(() => ({}))) as ApiPayload; + + if (!response.ok || !payload.ok || !payload.entities) { + setErrorMessage(payload.error ?? "No fue posible cargar el directorio."); + return; + } + + setDirectory(payload.entities); + } + + useEffect(() => { + if (selectedEscalationCaseId) { + void loadEscalation(selectedEscalationCaseId); + } + }, [selectedEscalationCaseId]); + + return ( +
+
+ + +

Casos abiertos

+

{kpis.openCases}

+
+
+ + + +

Alta severidad

+

{kpis.highSeverityCases}

+
+
+ + + +

Monto en riesgo

+

{kpis.amountAtRisk.toLocaleString("es-MX")}

+
+
+ + + +

Casos resueltos

+

{kpis.resolvedCases}

+
+
+
+ + {errorMessage ?

{errorMessage}

: null} + {message ?

{message}

: null} + + + +

Wizard de 4 pasos para sugerir severidad, tipo de caso y ruta de escalada.

+

Paso {diagnosisStep} de 4

+ + +
+ + + + + + + + + + + +
+ + setDiagnosisAnswers((prev) => ({ ...prev, counterparty: event.target.value }))} + placeholder="Contraparte" + className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm" + /> + +