This commit is contained in:
Marcelo Dares
2026-04-29 01:15:50 +02:00
parent 65aaf9275e
commit ea23136288
172 changed files with 30358 additions and 353 deletions

View File

@@ -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(
{

View File

@@ -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<string, unknown>;
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 });
}

View File

@@ -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<string, unknown>;
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<string, unknown>,
model: process.env.OPENAI_M10_MODEL?.trim() || undefined,
});
const payload =
envelope.data ??
({
auditorLikelyFindings: [],
missingEvidence: [],
topRisks: [],
remediationPlan: [],
confidence: envelope.confidence ?? "low",
} satisfies z.infer<typeof M10FindingsSchema>);
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,
},
});
}

View File

@@ -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,
});
}

View File

@@ -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,
});
}

View File

@@ -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 });
}

View File

@@ -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<string, unknown>) : {};
}
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 });
}

View File

@@ -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<string, unknown>) : {};
}
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 });
}

View File

@@ -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<string, unknown>;
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<string, unknown>,
model: process.env.OPENAI_M7_MODEL?.trim() || undefined,
});
const payload =
envelope.data ??
({
predictedIncidents: [],
priorityOrder: [],
preventiveActions: [],
escalationAdvice: [],
confidence: envelope.confidence ?? "low",
} satisfies z.infer<typeof M7PlaybookSchema>);
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,
},
});
}

View File

@@ -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 });
}

View File

@@ -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<string, unknown>) : {};
}
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 });
}
}

View File

@@ -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<typeof import("@/lib/contracts/storage")>("@/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");
});
});

View File

@@ -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 });
}
}

View File

@@ -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<string, unknown>) : {};
}
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 });
}
}

View File

@@ -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<string, unknown>) : {};
}
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 });
}
}

View File

@@ -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<string, unknown>) {
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<ReturnType<typeof getLatestProposalPdfSourceForUser>> | 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,
});
}

View File

@@ -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 });
}

View File

@@ -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<string, unknown>) : {};
}
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 });
}
}

View File

@@ -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(),
},
});
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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<string, unknown>) : {};
}
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 });
}
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<typeof item> => 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<string, unknown>,
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<typeof M1AiSuggestionItemSchema> => 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,
},
});
}

View File

@@ -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<string, unknown>) : {};
}
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 });
}
}

View File

@@ -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<string, unknown>) : {};
}
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 });
}
}

View File

@@ -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",
}),
}),
);
});
});

View File

@@ -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<string, unknown>) : {};
}
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<string, unknown>) {
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<string, unknown>)
: {};
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(),
},
});
}

View File

@@ -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<string, unknown>) : {};
}
function logContinuityHandoff(event: string, data: Record<string, unknown>) {
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<string, unknown> = {
...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,
},
});
}

View File

@@ -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 });
}

View File

@@ -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<string, unknown>) : {};
}
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 });
}
}

View File

@@ -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<string, unknown>) : {};
}
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 });
}

View File

@@ -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 });
}

View File

@@ -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() });
}

View File

@@ -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,
});
}

View File

@@ -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<string, unknown>;
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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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<string>();
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<LicitationSourcePdf> {
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 });
}
}

View File

@@ -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,
}),
);
}

View File

@@ -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<string, string>) {
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" });
}
}

View File

@@ -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<string, unknown>) : {};
}
function parseWebhookPaymentId(url: URL, body: Record<string, unknown>) {
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<string, unknown>) : null;
return typeof data?.id === "string" || typeof data?.id === "number" ? String(data.id) : "";
}
async function processWebhook(request: Request, body: Record<string, unknown>) {
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, {});
}

View File

@@ -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<typeof import("@/lib/proposals/storage")>("@/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");
});
});

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 },
);
}
}

View File

@@ -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<string, unknown>;
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 });
}
}

View File

@@ -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();
});
});

View File

@@ -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<string, unknown>) {
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<string, unknown>);
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<typeof toNormativeHistoryView>["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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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<string, unknown>;
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 });
}
}

View File

@@ -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<string, unknown>;
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 });
}
}

View File

@@ -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<string, unknown>): 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<string, unknown>;
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 });
}

View File

@@ -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 });
}

View File

@@ -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<StrategicSectionKey, { count: number; categories: string[] }>) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return fallback;
}
const incoming = value as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<StrategicSectionKey, { count: number; categories: string[] }>,
);
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<string, unknown>,
model: process.env.OPENAI_M2_MODEL?.trim() || undefined,
});
const safeData =
envelope.data ??
({
sectionGaps: [],
priorityActions: [],
suggestedEvidence: [],
suggestedFieldValues: [],
confidence: envelope.confidence ?? "low",
} satisfies z.infer<typeof M2InsightResponseSchema>);
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,
},
});
}

118
src/app/candados/page.tsx Normal file
View File

@@ -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<Record<string, string | string[] | undefined>>;
};
function getParam(params: Record<string, string | string[] | undefined>, 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 (
<PageShell title="Modulo 6: Candados" description="Acceso restringido al modulo premium.">
<Card>
<CardContent className="space-y-3 py-6">
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
<p className="text-sm text-[#64718a]">Necesitas un plan con acceso premium para ingresar al Modulo 6.</p>
<Link href="/dashboard#modulos">
<Button>Ver modulos y planes</Button>
</Link>
</CardContent>
</Card>
</PageShell>
);
}
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 (
<PageShell
title="Modulo 6: Candados"
headerMode="module"
headerBackHref={backHref}
headerBackLabel="Volver a M5"
headerNextHref="/compliance-alerts"
headerNextLabel="M7: Cumplimiento"
headerShowManual
headerPlanBadgeLabel="Plan Premium"
showPageHeading={false}
contentWidth="wide"
className="space-y-6"
>
<section className="flex items-start gap-4">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]"></span>
<div>
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Detector de Candados y Riesgos</h1>
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Modulo 6: detecta direccionamiento, favoritismo y candados en bases de licitacion</p>
</div>
</section>
<NormativeAnalysisView
linkedProposal={
linkedProposal
? {
id: linkedProposal.id,
title: linkedProposal.title,
issuingEntity: linkedProposal.issuingEntity,
}
: null
}
linkedSource={
linkedSource
? {
id: linkedSource.id,
title: linkedSource.title,
issuingEntity: linkedSource.supplierAwarded,
}
: null
}
autoAnalyzeOnLoad={Boolean(linkedProposal || linkedSource)}
moduleContext="candados"
/>
</PageShell>
);
}

View File

@@ -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 (
<PageShell
title="Modulo 7: Alertas de Cumplimiento"
description="Este modulo esta protegido por suscripcion de pago."
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
>
<Card>
<CardContent className="space-y-3 py-6">
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
<p className="text-sm text-[#64718a]">Modulo 7 forma parte de la ruta premium de modulos pagados.</p>
<div className="flex flex-wrap gap-2">
<Link href="/dashboard#modulos">
<Button>Ver modulos y planes</Button>
</Link>
<Link href="/gestion-licitaciones">
<Button variant="secondary">Ir a Modulo 5</Button>
</Link>
</div>
</CardContent>
</Card>
</PageShell>
);
}
const dataset = await getM7DatasetForUser(user.id);
return (
<PageShell
title="Modulo 7: Alertas de Cumplimiento"
headerMode="module"
headerBackHref="/dashboard"
headerBackLabel="Volver al Dashboard"
headerNextHref="/gestion-contratos"
headerNextLabel="M8: Contratos"
headerShowManual
headerPlanBadgeLabel="Plan Premium"
showPageHeading={false}
contentWidth="wide"
className="space-y-6"
>
<section className="flex items-start gap-4">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]"></span>
<div>
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Modulo 7: Alertas de Cumplimiento</h1>
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Monitorea plazos, riesgos, checklist y vigencia normativa desde una sola vista</p>
</div>
</section>
<ComplianceAlertsView dataset={dataset} />
</PageShell>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
};
function getParam(params: Record<string, string | string[] | undefined>, 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<ModulePlanKey>(["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() {
<Card className="border-[#a8dbc8]">
<CardContent className="py-8">
<div className="mx-auto max-w-4xl text-center">
<span className="inline-flex rounded-full bg-[#24aa77] px-8 py-2 text-2xl font-semibold text-white">Nivel {readinessLabel}</span>
<h2 className="mt-6 text-4xl font-semibold text-[#0f2142] [font-family:var(--font-display)] md:text-5xl">
<span className="inline-flex rounded-full bg-[#24aa77] px-7 py-2 text-xl font-semibold text-white">Nivel {readinessLabel}</span>
<h2 className="mt-6 text-3xl font-semibold text-[#0f2142] [font-family:var(--font-display)] md:text-4xl">
Puntaje Global: {roundedOverallScore}%
</h2>
<p className="mx-auto mt-4 max-w-3xl text-2xl leading-relaxed text-[#4d6285] md:text-[35px] md:leading-tight">
<p className="mx-auto mt-4 max-w-3xl text-xl leading-relaxed text-[#4d6285] md:text-2xl">
{getReadinessDescription(roundedOverallScore)}
</p>
@@ -251,13 +279,29 @@ export default async function DashboardPage() {
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-2xl font-semibold text-[#1a2b48] [font-family:var(--font-display)] md:text-3xl">Modulos y planes</h2>
<p className="mt-1 text-sm text-[#5f7293]">Ruta de crecimiento: Modulo 1 (gratis) + Modulos 2-20 por suscripcion.</p>
<p className="mt-1 text-sm text-[#5f7293]">Ruta de crecimiento: Modulo 1 (gratis) + Modulos 2-10 por suscripcion.</p>
</div>
<span className={cn("inline-flex items-center gap-2 rounded-full border px-4 py-1.5 text-sm font-semibold", hasPaidModulesAccess ? "border-[#a9d8c8] bg-[#e8f6ef] text-[#1e8f67]" : "border-[#d7dceb] bg-[#eff3fb] text-[#5a6b88]")}>
{hasPaidModulesAccess ? "Plan con acceso activo" : "Cuenta en plan gratuito"}
</span>
</div>
{checkoutResult ? (
<div className={cn("rounded-xl border px-4 py-3 text-sm", checkoutResult === "success" ? "border-[#a9d8c8] bg-[#ecf9f2] text-[#1f8b63]" : "border-[#e7d7ac] bg-[#fff8ea] text-[#8b6b1f]")}>
{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."}
</div>
) : null}
{checkoutError ? (
<div className="rounded-xl border border-[#f1c7cc] bg-[#fff1f3] px-4 py-3 text-sm text-[#a03d4b]">
No fue posible iniciar el checkout ({checkoutError}). Verifica la configuracion de Mercado Pago.
</div>
) : null}
<Card className="border-[#a9d9c7] bg-[#f3fbf7]">
<CardContent className="flex flex-wrap items-center justify-between gap-3 py-4">
<div>
@@ -275,6 +319,11 @@ export default async function DashboardPage() {
{planGroups.map((group) => (
<article key={group.key} className={cn("rounded-2xl border p-3 md:p-4", group.frameClassName)}>
{(() => {
const plan = getModulePlanByUiKey(group.key);
const planActive = plan ? activePlanKeys.has(plan.key) : false;
return (
<>
<header className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/60 bg-white/50 px-3 py-2">
<div className="flex items-start gap-3">
<span className={cn("mt-1.5 h-3 w-3 rounded-full", group.dotClassName)} />
@@ -288,13 +337,13 @@ export default async function DashboardPage() {
<span className={cn("inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm font-semibold", group.badgeClassName)}>
<LockIcon className="h-4 w-4" />
{hasPaidModulesAccess ? "Disponible" : "Bloqueado"}
{planActive ? "Disponible" : "Bloqueado"}
</span>
</header>
<div className="mt-3 grid gap-3 md:grid-cols-3">
{group.modules.map((moduleItem) => {
const isLocked = !hasPaidModulesAccess || !moduleItem.href;
const isLocked = !planActive || !moduleItem.href;
return (
<article
@@ -335,12 +384,33 @@ export default async function DashboardPage() {
);
})}
</div>
{!planActive ? (
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/70 bg-white/55 px-3 py-3">
<p className="text-sm text-[#5a6f90]">
Desbloquea Modulos {plan?.moduleStart}-{plan?.moduleEnd} con {group.name}.
</p>
{paymentConfigured ? (
<Link href={`/api/payments/checkout?plan=${encodeURIComponent(group.key)}`}>
<Button size="sm" className="rounded-xl">
Comprar {group.name}
</Button>
</Link>
) : (
<span className="rounded-full border border-[#d8dfea] bg-[#eef2f8] px-3 py-1 text-xs font-semibold text-[#64738c]">
Configura `MP_ACCESS_TOKEN`
</span>
)}
</div>
) : null}
</>
);
})()}
</article>
))}
<div className="rounded-2xl border border-dashed border-[#d4dceb] bg-[#f8fbff] px-5 py-5 text-center">
<p className="text-sm font-semibold uppercase tracking-[0.08em] text-[#607290]">Ruta de expansion</p>
<p className="mt-2 text-base text-[#536a8e]">Modulos 11-20 se habilitaran en siguientes etapas de la plataforma.</p>
<p className="mt-2 text-base text-[#536a8e]">Actualmente la plataforma contempla 10 modulos.</p>
<Link href="/#planes" className="mt-4 inline-flex">
<Button size="sm" className="rounded-xl bg-[#e1af1c] text-[#243351] hover:bg-[#cf9f16]">
Ver todos los planes

View File

@@ -0,0 +1,89 @@
import Link from "next/link";
import { hasModuleAccess } from "@/lib/auth/module-access";
import { requireOnboardedUser } from "@/lib/auth/user";
import {
getInstitutionalDossierForUser,
getAuditKpisForUser,
listAuditSimulationsForUser,
} from "@/lib/audits/server";
import { PreventiveDossierView } from "@/components/app/preventive-dossier-view";
import { PageShell } from "@/components/app/page-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import type { InstitutionalDossierLoadStrategy } from "@/lib/audits/types";
type PageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
const value = params[key];
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
}
export default async function ExpedientePreventivoPage({ searchParams }: PageProps) {
const user = await requireOnboardedUser();
const hasPaidModulesAccess = await hasModuleAccess(user, 10);
if (!hasPaidModulesAccess) {
return (
<PageShell
title="Modulo 10: Expediente Preventivo"
description="Este modulo esta protegido por suscripcion de pago."
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
>
<Card>
<CardContent className="space-y-3 py-6">
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
<p className="text-sm text-[#64718a]">Modulo 10 forma parte de la ruta premium de modulos pagados.</p>
<div className="flex flex-wrap gap-2">
<Link href="/dashboard#modulos">
<Button>Ver modulos y planes</Button>
</Link>
<Link href="/proteccion-legal">
<Button variant="secondary">Ir a Modulo 9</Button>
</Link>
</div>
</CardContent>
</Card>
</PageShell>
);
}
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 (
<PageShell
title="Modulo 10: Expediente Preventivo"
headerMode="module"
headerBackHref="/proteccion-legal"
headerBackLabel="M9: Legal"
headerPlanBadgeLabel="Plan Premium"
showPageHeading={false}
contentWidth="wide"
className="space-y-6"
>
<section className="flex items-start gap-4">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]"></span>
<div>
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Expediente Preventivo</h1>
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Simula auditorias y consolida evidencia institucional desde M5, M8, M9 y M7</p>
</div>
</section>
<PreventiveDossierView
initialSimulations={simulations}
initialKpis={kpis}
initialDossier={dossierState.dossier}
initialFreshness={dossierState.freshness}
/>
</PageShell>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
};
function getParam(params: Record<string, string | string[] | undefined>, key: string) {
const value = params[key];
return Array.isArray(value) ? value[0] ?? "" : (value ?? "");
}
function logContinuityHandoff(event: string, data: Record<string, unknown>) {
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 (
<PageShell
title="Modulo 8: Gestion Estrategica de Contratos"
description="Este modulo esta protegido por suscripcion de pago."
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
>
<Card>
<CardContent className="space-y-3 py-6">
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
<p className="text-sm text-[#64718a]">Modulo 8 forma parte de la ruta premium de modulos pagados.</p>
<div className="flex flex-wrap gap-2">
<Link href="/dashboard#modulos">
<Button>Ver modulos y planes</Button>
</Link>
<Link href="/compliance-alerts">
<Button variant="secondary">Ir a Modulo 7</Button>
</Link>
</div>
</CardContent>
</Card>
</PageShell>
);
}
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 (
<PageShell
title="Modulo 8: Gestion Estrategica de Contratos"
headerMode="module"
headerBackHref="/compliance-alerts"
headerBackLabel="M7: Alertas"
headerNextHref="/proteccion-legal"
headerNextLabel="M9: Legal"
headerPlanBadgeLabel="Plan Premium"
showPageHeading={false}
contentWidth="wide"
className="space-y-6"
>
<section className="flex items-start gap-4">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]"></span>
<div>
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Gestion Estrategica de Contratos</h1>
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Registra contratos, entregables, pagos y evidencia documental con trazabilidad completa</p>
</div>
</section>
<ContractsManagementView initialContracts={contracts} initialKpis={kpis} proposalPdfOptions={proposalPdfOptions} />
</PageShell>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
};
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<string, unknown>;
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<string, unknown>);
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<string>();
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<string, string | string[] | undefined>, 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 (
<PageShell
title="Modulo 5: Gestion de Licitaciones"
description="Este modulo esta protegido por suscripcion de pago."
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
>
<Card>
<CardContent className="space-y-3 py-6">
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
<p className="text-sm text-[#64718a]">Modulo 5 forma parte de la ruta premium de modulos pagados.</p>
<div className="flex flex-wrap gap-2">
<Link href="/dashboard#modulos">
<Button>Ver modulos y planes</Button>
</Link>
<Link href="/normative-analysis">
<Button variant="secondary">Ir a Modulo 4</Button>
</Link>
</div>
</CardContent>
</Card>
</PageShell>
);
}
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 (
<PageShell
title="Modulo 5: Gestion de Licitaciones"
headerMode="module"
headerBackHref="/gestion-licitaciones"
headerBackLabel="M5: Lista"
headerNextHref={candadosHref}
headerNextLabel="M6: Candados"
headerPlanBadgeLabel="Plan Premium"
showPageHeading={false}
contentWidth="wide"
className="space-y-6"
>
<ProposalWorkflowView
proposal={{
id: proposal.id,
title: proposal.title,
issuingEntity: proposal.issuingEntity,
summary: proposal.summary,
status: proposal.status,
sourceLicitationId: proposal.sourceLicitationId,
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(),
})),
}}
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}
/>
</PageShell>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
};
function getParam(params: Record<string, string | string[] | undefined>, 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 (
<PageShell
title="Modulo 5: Gestion de Licitaciones"
description="Este modulo esta protegido por suscripcion de pago."
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
>
<Card>
<CardContent className="space-y-3 py-6">
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
<p className="text-sm text-[#64718a]">Modulo 5 forma parte de la ruta premium de modulos pagados.</p>
<div className="flex flex-wrap gap-2">
<Link href="/dashboard#modulos">
<Button>Ver modulos y planes</Button>
</Link>
<Link href="/normative-analysis">
<Button variant="secondary">Ir a Modulo 4</Button>
</Link>
</div>
</CardContent>
</Card>
</PageShell>
);
}
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 (
<PageShell
title="Modulo 5: Gestion de Licitaciones"
headerMode="module"
headerBackHref="/normative-analysis"
headerBackLabel="M4: Analisis"
headerNextHref={candadosHref}
headerNextLabel="M6: Candados"
headerPlanBadgeLabel="Plan Premium"
showPageHeading={false}
contentWidth="wide"
className="space-y-6"
>
<section className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Gestion de Licitaciones</h1>
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Prepara tus propuestas de licitacion paso a paso</p>
</div>
</section>
<ProposalsManagementView initialProposals={proposals} initialSourceLicitationId={sourceLicitationId} />
</PageShell>
);
}

View File

@@ -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<string, unknown>;
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 (
<PageShell
title="Detalle de licitacion"
description={`${licitation.municipality.municipalityName}, ${licitation.municipality.stateName}`}
headerMode="module"
headerBackHref="/licitations"
headerBackLabel="Volver a Oportunidades"
headerShowManual
headerPlanBadgeLabel="Plan Premium"
contentWidth="wide"
action={
<Link href="/licitations">
<Button variant="secondary">Volver a resultados</Button>
</Link>
<div className="flex gap-2">
<Link href={`/normative-analysis?sourceId=${encodeURIComponent(licitation.id)}`}>
<Button>Analizar en Modulo 4</Button>
</Link>
<Link href={`/gestion-licitaciones?sourceId=${encodeURIComponent(licitation.id)}&autocreate=1&autofill=1`}>
<Button variant="secondary">Crear propuesta (M5)</Button>
</Link>
{primaryPdfDocument ? (
<a href={primaryPdfDocument.url} target="_blank" rel="noreferrer">
<Button variant="secondary">Ver PDF principal</Button>
</a>
) : null}
<Link href="/licitations">
<Button variant="secondary">Volver a resultados</Button>
</Link>
</div>
}
>
<Card>
@@ -129,6 +160,12 @@ export default async function LicitationDetailPage({ params }: LicitationDetailP
<span className="rounded-full border border-[#d8e1ef] px-2 py-1">Cierre: {formatDate(licitation.closingDate)}</span>
</div>
<LicitationCardActions
licitationId={licitation.id}
initialStatus={reviewStatus}
normativeAnalysisHref={`/normative-analysis?sourceId=${encodeURIComponent(licitation.id)}`}
/>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-xl border border-[#d8e1ef] p-3">
<p className="text-xs font-semibold uppercase text-[#60718f]">Monto</p>
@@ -170,6 +207,16 @@ export default async function LicitationDetailPage({ params }: LicitationDetailP
<h3 className="text-lg font-semibold text-[#1f2a40]">Documentos</h3>
</CardHeader>
<CardContent className="space-y-2">
{primaryPdfDocument ? (
<a
href={primaryPdfDocument.url}
target="_blank"
rel="noreferrer"
className="block rounded-xl border border-[#c6d8f4] bg-[#ebf3ff] p-3 text-sm font-semibold text-[#1f3f84]"
>
PDF principal: {primaryPdfDocument.name}
</a>
) : null}
{documents.length === 0 ? (
<p className="text-sm text-[#64718a]">No hay documentos asociados.</p>
) : (

View File

@@ -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<Record<string, string | string[] | undefined>>;
};
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<string, string | string[] | undefined>, 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 <span className={`inline-flex rounded-full border px-3 py-1 text-sm font-semibold ${className}`}>{label}</span>;
}
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 <span className={`inline-flex rounded-full border px-3 py-1 text-sm font-semibold ${className}`}>{label}</span>;
}
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={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Acceso restringido</h2>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-3 py-6">
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
<p className="text-sm text-[#64718a]">
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.
</p>
@@ -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 (
<PageShell
title="Modulo 3: Deteccion de Oportunidades"
description="Encuentra licitaciones compatibles con tu perfil empresarial"
action={<LicitationsSyncButton />}
headerMode="module"
headerBackHref="/dashboard"
headerBackLabel="Volver al Dashboard"
headerShowManual
headerPlanBadgeLabel="Plan Premium"
showPageHeading={false}
contentWidth="wide"
className="space-y-6"
>
<section className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-start gap-4">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dee5f1] text-3xl text-[#264574]"></span>
<div>
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Modulo 3: Deteccion de Oportunidades</h1>
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Encuentra licitaciones compatibles con tu perfil empresarial</p>
</div>
</div>
<LicitationsSyncButton />
</section>
<Card className="border-[#d2dbe9] bg-[#f7f9fd]">
<CardContent className="flex items-center justify-between py-5">
<div>
<p className="text-2xl font-semibold text-[#1b3158] [font-family:var(--font-display)] md:text-3xl">Como usar este modulo?</p>
<p className="text-base text-[#566d92] md:text-lg">Guia rapida para encontrar oportunidades</p>
</div>
<span className="text-2xl text-[#273f67]" aria-hidden>
˅
</span>
</CardContent>
</Card>
<section className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="py-4">
<p className="text-4xl font-semibold text-[#12274d] md:text-5xl">{summary.opportunities}</p>
<p className="text-lg text-[#60718f] md:text-xl">Oportunidades</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-4xl font-semibold text-[#12274d] md:text-5xl">{summary.highViability}</p>
<p className="text-lg text-[#60718f] md:text-xl">Alta viabilidad</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-4xl font-semibold text-[#12274d] md:text-5xl">{summary.mediumViability}</p>
<p className="text-lg text-[#60718f] md:text-xl">Media viabilidad</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-4xl font-semibold text-[#12274d] md:text-5xl">{summary.averageCompatibility}%</p>
<p className="text-lg text-[#60718f] md:text-xl">Compatibilidad promedio</p>
</CardContent>
</Card>
</section>
<section className="grid gap-3 md:grid-cols-3">
<Link href="/licitations?review=consulted" className="block">
<Card className={reviewFilter === "consulted" ? "border-[#b8c9eb] bg-[#edf3ff]" : undefined}>
<CardContent className="py-4">
<p className="text-3xl font-semibold text-[#15305d]">{consultedCount}</p>
<p className="text-sm font-semibold text-[#5f7090]">Consultadas</p>
</CardContent>
</Card>
</Link>
<Link href="/licitations?review=interested" className="block">
<Card className={reviewFilter === "interested" ? "border-[#b9e6cd] bg-[#edf9f2]" : undefined}>
<CardContent className="py-4">
<p className="text-3xl font-semibold text-[#155540]">{interestedCount}</p>
<p className="text-sm font-semibold text-[#5f7090]">Me interesa</p>
</CardContent>
</Card>
</Link>
<Link href="/licitations?review=active" className="block">
<Card className={reviewFilter === "active" ? "border-[#f0deb0] bg-[#fff9ea]" : undefined}>
<CardContent className="py-4">
<p className="text-3xl font-semibold text-[#704f05]">{activeCount}</p>
<p className="text-sm font-semibold text-[#5f7090]">Activas</p>
</CardContent>
</Card>
</Link>
</section>
<p className="text-base text-[#4d6488] md:text-lg">
Ultimo analisis: {formatDateShort(now)}, {now.toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit" })} ·
<span className="ml-1 font-semibold text-[#1c3768]">{newToReviewCount} nuevas por revisar</span>
</p>
<Card>
<CardContent className="py-4">
<form method="get" className="grid gap-3 md:grid-cols-3">
<label className="space-y-1 text-sm font-semibold text-[#33415c] md:col-span-2">
Buscar oportunidades
<input
type="text"
name="q"
defaultValue={q}
placeholder="Palabra clave, proveedor, concepto..."
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
/>
</label>
<form method="get" className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px_220px_220px_220px]">
<label className="space-y-1 text-sm font-semibold text-[#33415c] lg:col-span-1">
Buscar oportunidades
<input
type="text"
name="q"
defaultValue={q}
placeholder="Buscar oportunidades..."
className="h-12 w-full rounded-xl border border-[#cfd8e6] px-4 text-base"
/>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Tipo de procedimiento
<select name="procedure_type" defaultValue={procedureType} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm">
<option value="">Todos</option>
<option value={LicitationProcedureType.LICITACION_PUBLICA}>Licitacion publica</option>
<option value={LicitationProcedureType.INVITACION_RESTRINGIDA}>Invitacion restringida</option>
<option value={LicitationProcedureType.ADJUDICACION_DIRECTA}>Adjudicacion directa</option>
</select>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Viabilidad
<select name="viability" defaultValue={viabilityFilter} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
<option value="all">Todas</option>
<option value="alta">Alta</option>
<option value="media">Media</option>
<option value="baja">Baja</option>
</select>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Estado
<select name="state" defaultValue={state} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm">
<option value="">Todos los estados</option>
{uniqueStates.map((stateOption) => (
<option key={stateOption.code} value={stateOption.code}>
{stateOption.name}
</option>
))}
</select>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Estatus
<select name="status" defaultValue={statusFilter} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
<option value="all">Todos</option>
<option value="open">Abiertas</option>
<option value="closed">Cerradas</option>
</select>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Municipio
<select name="municipality" defaultValue={municipality} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm">
<option value="">Todos</option>
{filteredMunicipalities.map((municipalityOption) => (
<option key={municipalityOption.id} value={municipalityOption.municipalityCode}>
{municipalityOption.municipalityName}
</option>
))}
</select>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Seguimiento M3
<select name="review" defaultValue={reviewFilter} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
<option value="all">Todos</option>
<option value="consulted">Consultadas</option>
<option value="interested">Me interesa</option>
<option value="active">Activas</option>
</select>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Monto minimo
<input type="number" step="0.01" name="min_amount" defaultValue={minAmount} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" />
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Ordenar por
<select name="sort" defaultValue={sortFilter} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
<option value="compat_desc">Mayor compatibilidad</option>
<option value="compat_asc">Menor compatibilidad</option>
<option value="closing_soon">Cierre proximo</option>
<option value="recent">Mas recientes</option>
</select>
</label>
</div>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Monto maximo
<input type="number" step="0.01" name="max_amount" defaultValue={maxAmount} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" />
</label>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-6">
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Procedimiento
<select name="procedure_type" defaultValue={procedureType} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
<option value="">Todos</option>
<option value="LICITACION_PUBLICA">Licitacion publica</option>
<option value="INVITACION_RESTRINGIDA">Invitacion restringida</option>
<option value="ADJUDICACION_DIRECTA">Adjudicacion directa</option>
</select>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Fecha desde
<input type="date" name="date_from" defaultValue={dateFrom} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" />
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c] lg:col-span-2">
Estado
<select name="state" defaultValue={state} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
<option value="">Todos los estados</option>
{uniqueStates.map((stateOption) => (
<option key={stateOption.code} value={stateOption.code}>
{stateOption.name}
</option>
))}
</select>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Fecha hasta
<input type="date" name="date_to" defaultValue={dateTo} className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm" />
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c] lg:col-span-2">
Municipio
<select name="municipality" defaultValue={municipality} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base">
<option value="">Todos</option>
{filteredMunicipalities.map((municipalityOption) => (
<option key={municipalityOption.id} value={municipalityOption.municipalityCode}>
{municipalityOption.municipalityName}
</option>
))}
</select>
</label>
<div className="md:col-span-3 flex gap-2">
<Button type="submit">Aplicar filtros</Button>
<Link href="/licitations">
<Button type="button" variant="secondary">
Limpiar
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Monto minimo
<input type="number" step="0.01" name="min_amount" defaultValue={minAmount} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base" />
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Monto maximo
<input type="number" step="0.01" name="max_amount" defaultValue={maxAmount} className="h-12 w-full rounded-xl border border-[#cfd8e6] px-3 text-base" />
</label>
<input type="hidden" name="date_from" value={dateFrom} />
<input type="hidden" name="date_to" value={dateTo} />
<div className="flex items-end gap-2 lg:col-span-2">
<Button type="submit" className="h-12 rounded-xl px-5">
Aplicar
</Button>
</Link>
<Link href="/licitations" className="inline-flex">
<Button type="button" variant="secondary" className="h-12 rounded-xl px-5">
Limpiar
</Button>
</Link>
</div>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Recomendaciones para tu empresa</h2>
</CardHeader>
<CardContent className="space-y-3">
{recommendations.results.slice(0, 5).length ? (
recommendations.results.slice(0, 5).map((item) => (
<div key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-[#1f2f4f]">{item.title}</p>
<span className="rounded-full bg-[#e8eefc] px-2 py-1 text-xs font-semibold text-[#1a3f8d]">Score {item.score}</span>
</div>
<p className="mt-1 text-sm text-[#5c6f8e]">{item.municipalityName}, {item.stateName}</p>
<p className="mt-1 text-xs text-[#5c6f8e]">{item.reasons.join(" ")}</p>
<Link href={`/licitations/${item.id}`} className="mt-2 inline-flex text-sm font-semibold text-[#1f3f84]">
Ver detalle
</Link>
</div>
))
) : (
<p className="text-sm text-[#64718a]">Aun no hay recomendaciones por perfil. Completa tu perfil y ejecuta sincronizacion.</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-[#1f2a40]">Resultados ({records.total})</h2>
<p className="text-sm text-[#60718f]">Mostrando oportunidades abiertas por defecto.</p>
</CardHeader>
<CardContent className="space-y-3">
{records.records.length === 0 ? (
<p className="text-sm text-[#64718a]">No se encontraron oportunidades con los filtros seleccionados.</p>
) : (
records.records.map((record) => (
<article key={record.id} className="rounded-xl border border-[#d8e1ef] bg-white p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase text-[#60718f]">{record.municipality.stateName} / {record.municipality.municipalityName}</p>
<h3 className="text-lg font-semibold text-[#1f2a40]">{record.title}</h3>
<p className="text-sm text-[#60718f]">{record.description ?? "Sin descripcion"}</p>
<section className="grid gap-4 xl:grid-cols-2">
{sortedRows.length === 0 ? (
<Card className="xl:col-span-2">
<CardContent className="py-14 text-center">
<p className="text-2xl font-semibold text-[#1b3158] [font-family:var(--font-display)] md:text-3xl">No hay oportunidades con estos filtros</p>
<p className="mt-2 text-base text-[#5f7090] md:text-lg">Ajusta los filtros o ejecuta una nueva sincronizacion.</p>
</CardContent>
</Card>
) : (
sortedRows.map((row) => (
<article
key={row.id}
className={`rounded-2xl border border-[#d7dfea] p-6 shadow-[0_1px_2px_rgba(16,24,40,0.06)] ${row.reviewStatus === "DISCARDED" ? "bg-[#fafbfe] opacity-85" : "bg-white"}`}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<ReviewStatusBadge status={row.reviewStatus} />
<ViabilityBadge score={row.blendedScore} />
{row.isActive ? (
<span className="inline-flex rounded-full border border-[#f0deb0] bg-[#fff8e9] px-3 py-1 text-sm font-semibold text-[#8d6308]">Activa en M5</span>
) : null}
</div>
<span className="rounded-full bg-[#eef3ff] px-2 py-1 text-xs font-semibold text-[#1f3f84]">{getProcedureTypeLabel(record.procedureType)}</span>
<div className="text-right">
<p className="text-3xl font-semibold text-[#12306a] md:text-4xl">{row.blendedScore}%</p>
<p className="text-base text-[#5f7290] md:text-lg">score combinado</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-[#5e7190]">
<span className={`rounded-full border px-2 py-1 ${record.isOpen ? "border-[#acd8c7] bg-[#e8f7ef] text-[#1c8f67]" : "border-[#d4dced]"}`}>
{record.isOpen ? "Abierta" : "Cerrada"}
<h3 className="mt-4 text-2xl font-semibold leading-tight text-[#162748] [font-family:var(--font-display)] md:text-3xl">{row.title}</h3>
<div className="mt-4 h-4 w-full overflow-hidden rounded-full bg-[#d7e6ef]">
<div
className="h-full rounded-full bg-gradient-to-r from-[#1f3f84] via-[#2576a8] to-[#2bb67f]"
style={{ width: `${Math.max(8, Math.min(100, row.blendedScore))}%` }}
/>
</div>
<div className="mt-3 grid gap-2 text-sm text-[#5d7090] md:grid-cols-3">
<p>
Determinista: <span className="font-semibold text-[#17315a]">{row.deterministicScore}%</span>
</p>
<p>
AI fit: <span className="font-semibold text-[#17315a]">{row.aiFitScore}%</span>
</p>
<p>
Combinado: <span className="font-semibold text-[#17315a]">{row.blendedScore}%</span>
</p>
</div>
<div className="mt-4 grid gap-2 text-base text-[#596f93] md:grid-cols-2 md:text-lg">
<p>{row.entity}</p>
<p>{row.municipalityName}</p>
<p>{row.amountLabel}</p>
<p>{row.daysToClose}</p>
</div>
<div className="mt-4 space-y-2 text-sm">
<p className="font-semibold text-[#0f8a56]">Por que es compatible:</p>
{row.aiReasons.length ? (
row.aiReasons.slice(0, 2).map((reason) => (
<span key={reason} className="mr-2 inline-flex rounded-full border border-[#b9e6cd] bg-[#eaf9f1] px-3 py-1 font-semibold text-[#1c865e]">
{reason}
</span>
))
) : row.reasons.length ? (
row.reasons.slice(0, 2).map((reason) => (
<span key={reason} className="mr-2 inline-flex rounded-full border border-[#dbe4ef] bg-[#f4f7fb] px-3 py-1 font-semibold text-[#576b8f]">
{reason}
</span>
))
) : (
<span className="inline-flex rounded-full border border-[#dbe4ef] bg-[#f4f7fb] px-3 py-1 font-semibold text-[#576b8f]">
Completa mas datos de perfil para mejorar recomendaciones.
</span>
<span className="rounded-full border border-[#d4dced] px-2 py-1">{getCategoryLabel(record.category)}</span>
<span className="rounded-full border border-[#d4dced] px-2 py-1">{formatAmount(record.amount, record.currency)}</span>
<span className="rounded-full border border-[#d4dced] px-2 py-1">Publicacion: {formatDate(record.publishDate)}</span>
<span className="rounded-full border border-[#d4dced] px-2 py-1">Cierre: {formatDate(record.closingDate)}</span>
<span className="rounded-full border border-[#d4dced] px-2 py-1">Fuente: {getSourceLabel(record.source)}</span>
</div>
)}
</div>
<Link href={`/licitations/${record.id}`} className="mt-3 inline-flex text-sm font-semibold text-[#1f3f84]">
Ver detalle
</Link>
</article>
))
)}
</CardContent>
</Card>
{row.aiRisks.length ? (
<div className="mt-2 space-y-1 text-sm">
<p className="font-semibold text-[#8d6308]">Riesgos detectados por IA:</p>
<div className="flex flex-wrap gap-2">
{row.aiRisks.slice(0, 2).map((risk) => (
<span key={risk} className="inline-flex rounded-full border border-[#f0deb0] bg-[#fff8e9] px-3 py-1 font-semibold text-[#8d6308]">
{risk}
</span>
))}
</div>
</div>
) : null}
{row.nextStep ? <p className="mt-2 text-sm font-semibold text-[#264574]">Siguiente paso sugerido: {row.nextStep}</p> : null}
<details className="mt-4 rounded-xl border border-[#dce4ef] bg-[#f9fbff] p-3">
<summary className="cursor-pointer text-lg font-semibold text-[#1f3f84] md:text-xl">Ver resumen rapido</summary>
<div className="mt-3 space-y-2 text-base text-[#5d7090] md:text-lg">
<p>Procedimiento: {row.procedureLabel}</p>
<p>Categoria: {row.categoryLabel}</p>
<p>Fuente: {row.sourceLabel}</p>
<p>Publicacion: {formatDate(row.publishDate)}</p>
<p>Cierre: {formatDate(row.closingDate)}</p>
</div>
<div className="mt-3">
<LicitationCardActions
licitationId={row.id}
initialStatus={row.reviewStatus}
normativeAnalysisHref={`/normative-analysis?sourceId=${encodeURIComponent(row.id)}`}
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Link href={`/licitations/${encodeURIComponent(row.id)}`}>
<Button size="sm">Ir al detalle</Button>
</Link>
<Link href={`/normative-analysis?sourceId=${encodeURIComponent(row.id)}`}>
<Button size="sm" variant="secondary">
Analizar en M4
</Button>
</Link>
{row.primaryPdfUrl ? (
<a href={row.primaryPdfUrl} target="_blank" rel="noreferrer">
<Button size="sm" variant="secondary">
Ver PDF principal
</Button>
</a>
) : null}
<Link href={`/gestion-licitaciones?sourceId=${encodeURIComponent(row.id)}&autocreate=1&autofill=1`}>
<Button size="sm" variant="secondary">
Crear propuesta (M5)
</Button>
</Link>
</div>
</details>
</article>
))
)}
</section>
<section className="rounded-2xl border border-[#c9dfd2] bg-[#f0faf4] px-6 py-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-2xl font-semibold text-[#17315a] [font-family:var(--font-display)] md:text-3xl">Te interesa una oportunidad?</p>
<p className="text-base text-[#5c7395] md:text-lg">Descarga las bases de licitacion y continua en Analisis Normativo (M4).</p>
</div>
<Link href="/normative-analysis" className="inline-flex">
<Button size="lg" className="rounded-2xl px-7">
Ir a Analisis Normativo (M4)
</Button>
</Link>
</div>
</section>
</PageShell>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
};
function getParam(params: Record<string, string | string[] | undefined>, 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 (
<PageShell
title="Modulo 4: Analisis Normativo"
description="Este modulo esta protegido por suscripcion de pago."
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
>
<Card>
<CardContent className="space-y-3 py-6">
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
<p className="text-sm text-[#64718a]">
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.
</p>
<Link href="/dashboard#modulos">
<Button>Ver modulos y planes</Button>
</Link>
</CardContent>
</Card>
</PageShell>
);
}
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 (
<PageShell
title="Modulo 4: Analisis Normativo"
headerMode="module"
headerBackHref="/dashboard"
headerBackLabel="Volver al Dashboard"
headerShowManual
headerPlanBadgeLabel="Plan Premium"
showPageHeading={false}
contentWidth="wide"
className="space-y-6"
>
<section className="flex items-start gap-4">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]"></span>
<div>
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Modulo 4: Analisis Normativo</h1>
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Analiza bases de licitacion, reglamentos y leyes con IA</p>
</div>
</section>
<NormativeAnalysisView
linkedSource={
linkedSource
? {
id: linkedSource.id,
title: linkedSource.title,
issuingEntity: linkedSource.supplierAwarded,
}
: null
}
autoAnalyzeOnLoad={Boolean(linkedSource)}
/>
<section className="rounded-2xl border border-[#d8e0ec] bg-[#f4f7fb] px-6 py-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-3xl font-semibold text-[#17315a] [font-family:var(--font-display)]">Navegacion</p>
<p className="text-lg text-[#5c7395]">Regresa al Modulo 3 para ver mas oportunidades o vuelve al dashboard.</p>
</div>
<div className="flex flex-wrap gap-2">
<Link href="/licitations">
<Button size="lg" className="rounded-2xl px-6">
Volver a Oportunidades
</Button>
</Link>
<Link href="/dashboard">
<Button size="lg" variant="secondary" className="rounded-2xl px-6">
Ir al Dashboard
</Button>
</Link>
</div>
</div>
</section>
</PageShell>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
};
function getParam(params: Record<string, string | string[] | undefined>, 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 (
<PageShell
title="Modulo 9: Proteccion Legal"
description="Este modulo esta protegido por suscripcion de pago."
action={<span className="rounded-full border border-[#d5ddec] bg-[#edf2fb] px-4 py-1 text-sm font-semibold text-[#5a6a87]">Bloqueado</span>}
>
<Card>
<CardContent className="space-y-3 py-6">
<h2 className="text-2xl font-semibold text-[#1f2a40]">Acceso restringido</h2>
<p className="text-sm text-[#64718a]">Modulo 9 forma parte de la ruta premium de modulos pagados.</p>
<div className="flex flex-wrap gap-2">
<Link href="/dashboard#modulos">
<Button>Ver modulos y planes</Button>
</Link>
<Link href="/gestion-contratos">
<Button variant="secondary">Ir a Modulo 8</Button>
</Link>
</div>
</CardContent>
</Card>
</PageShell>
);
}
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 (
<PageShell
title="Modulo 9: Proteccion Legal"
headerMode="module"
headerBackHref="/gestion-contratos"
headerBackLabel="M8: Contratos"
headerNextHref="/expediente-preventivo"
headerNextLabel="M10: Auditorias"
headerPlanBadgeLabel="Plan Premium"
showPageHeading={false}
contentWidth="wide"
className="space-y-6"
>
<section className="flex items-start gap-4">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#dce5f2] text-3xl text-[#274676]"></span>
<div>
<h1 className="text-4xl font-semibold text-[#132549] [font-family:var(--font-display)] md:text-5xl">Proteccion Legal</h1>
<p className="mt-2 text-xl text-[#4f6588] md:text-2xl">Diagnostico guiado, gestion de casos, escalada y generacion de escritos con trazabilidad</p>
</div>
</section>
<LegalProtectionView
initialCases={cases}
initialKpis={kpis}
initialDirectory={directory}
initialTemplates={templates}
availableContracts={contracts}
prefilledContractId={contractId}
/>
</PageShell>
);
}

View File

@@ -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 (

View File

@@ -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<string, string | string[] | undefin
export default async function TalleresDesarrolloPage({ searchParams }: PageProps) {
const user = await requireOnboardedUser();
const hasPaidModulesAccess = isAdminIdentity(user.email, user.role);
const hasPaidModulesAccess = await hasAnyPaidModuleAccess(user.id, user.email, user.role);
if (!hasPaidModulesAccess) {
return (

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { M7Dataset } from "@/lib/compliance/types";
import { ComplianceAlertsView } from "@/components/app/compliance-alerts-view";
function buildDataset(overrides?: Partial<M7Dataset>): 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(<ComplianceAlertsView dataset={buildDataset()} />);
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(
<ComplianceAlertsView
dataset={buildDataset({
kpis: {
activeLicitations: 0,
completionRate: 0,
criticalPending: 0,
upcoming7Days: 0,
},
tabs: {
plazos: [],
alertas: [],
checklist: [],
panelKpi: [],
},
})}
/>,
);
expect(html).toContain("Sin alertas de cumplimiento");
});
it("keeps responsive layout classes for desktop and mobile grids", () => {
const html = renderToStaticMarkup(<ComplianceAlertsView dataset={buildDataset()} />);
expect(html).toContain("md:grid-cols-2");
expect(html).toContain("xl:grid-cols-4");
expect(html).toContain("md:grid-cols-4");
});
});

View File

@@ -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<M7AiPlaybook | null>(null);
const [aiSuggestionId, setAiSuggestionId] = useState<string | null>(null);
const [aiFeedback, setAiFeedback] = useState<string | null>(null);
const [aiError, setAiError] = useState<string | null>(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 (
<div className="space-y-4">
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Licitaciones activas</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{dataset.kpis.activeLicitations}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Tasa de completitud</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{dataset.kpis.completionRate}%</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Pendientes criticos</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{dataset.kpis.criticalPending}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Proximos 7 dias</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{dataset.kpis.upcoming7Days}</p>
</CardContent>
</Card>
</section>
<section className="grid gap-3 md:grid-cols-4">
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Consultadas</p>
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{dataset.m3States.consulted}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Me interesa</p>
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{dataset.m3States.interested}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Activas M5</p>
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{dataset.m3States.active}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Nuevas M3</p>
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{dataset.m3States.new}</p>
</CardContent>
</Card>
</section>
{isEmpty ? (
<Card className="border-dashed border-[#cfd8e6] bg-[#fbfdff]">
<CardContent className="py-16 text-center">
<p className="text-3xl font-semibold text-[#162748] [font-family:var(--font-display)]">Sin alertas de cumplimiento</p>
<p className="mt-2 text-base text-[#5f7090]">Cuando tengas procesos activos en M5 y fechas detectadas, este tablero mostrara riesgos y vencimientos.</p>
</CardContent>
</Card>
) : null}
{aiError ? <p className="rounded-lg border border-[#f1c7ce] bg-[#fff1f4] px-3 py-2 text-sm text-[#b03f4f]">{aiError}</p> : null}
{aiFeedback ? <p className="rounded-lg border border-[#b9e6cd] bg-[#eaf9f1] px-3 py-2 text-sm text-[#1f8b63]">{aiFeedback}</p> : null}
<Tabs
defaultTab="plazos"
items={[
{
id: "plazos",
label: "Plazos",
content: (
<div className="space-y-2">
{dataset.tabs.plazos.length === 0 ? <p className="text-sm text-[#60718f]">Sin plazos detectables para los proximos dias.</p> : null}
{dataset.tabs.plazos.map((item) => (
<article key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-[#1d335a]">{item.title}</p>
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-semibold ${item.status === "overdue" ? "border-[#f1c7ce] bg-[#fff1f4] text-[#b03f4f]" : "border-[#d8e1ef] bg-[#f4f7fb] text-[#51688f]"}`}>
{item.status === "overdue" ? "Vencido" : "Programado"}
</span>
</div>
<p className="mt-1 text-xs text-[#60718f]">{item.description}</p>
<p className="mt-1 text-xs font-semibold text-[#334d77]">{formatDateTime(item.dueAt)}</p>
</article>
))}
</div>
),
},
{
id: "alertas",
label: "Alertas",
content: (
<div className="space-y-2">
{dataset.tabs.alertas.length === 0 ? <p className="text-sm text-[#60718f]">Sin alertas activas.</p> : null}
{dataset.tabs.alertas.map((item) => (
<article key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-[#1d335a]">{item.title}</p>
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-semibold ${severityClass(item.severity)}`}>{item.severity}</span>
</div>
<p className="mt-1 text-xs text-[#60718f]">{item.description}</p>
{item.dueAt ? <p className="mt-1 text-xs font-semibold text-[#334d77]">{formatDateTime(item.dueAt)}</p> : null}
</article>
))}
</div>
),
},
{
id: "checklist",
label: "Checklist",
content: (
<div className="space-y-2">
{dataset.tabs.checklist.length === 0 ? <p className="text-sm text-[#60718f]">No hay procesos activos para checklist.</p> : null}
{dataset.tabs.checklist.map((item) => (
<article key={item.proposalId} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<p className="text-sm font-semibold text-[#1d335a]">{item.proposalTitle}</p>
<p className="mt-1 text-xs text-[#60718f]">
Obligatorios resueltos: {item.mandatoryResolved}/{item.mandatoryTotal} ({item.completionRate}%)
</p>
<p className="mt-1 text-xs text-[#60718f]">Firma: {item.signaturePolicyStatus.replaceAll("_", " ")}</p>
</article>
))}
</div>
),
},
{
id: "panel-kpi",
label: "Panel KPI",
content: (
<div className="grid gap-2 md:grid-cols-2">
{dataset.tabs.panelKpi.map((item) => (
<article key={item.id} className={`rounded-xl border p-3 ${toneClass(item.tone)}`}>
<p className="text-xs font-semibold uppercase tracking-[0.08em]">{item.label}</p>
<p className="mt-1 text-2xl font-semibold">{item.value}</p>
</article>
))}
</div>
),
},
{
id: "plan-ia",
label: "Plan IA",
content: (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" disabled={isLoadingAiPlaybook} onClick={() => void generateAiPlaybook()}>
{isLoadingAiPlaybook ? "Generando plan IA..." : "Generar plan IA"}
</Button>
{aiPlaybook ? (
<Button
type="button"
variant="secondary"
onClick={() => {
void setSuggestionDecision("dismiss");
setAiPlaybook(null);
setAiFeedback("Plan IA descartado.");
}}
>
Descartar plan IA
</Button>
) : null}
</div>
{!aiPlaybook ? <p className="text-sm text-[#60718f]">Sin plan IA generado todavia.</p> : null}
{aiPlaybook ? (
<>
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<p className="text-sm font-semibold text-[#1d335a]">Incidentes predichos</p>
<ul className="mt-2 space-y-2 text-xs text-[#60718f]">
{aiPlaybook.predictedIncidents.map((incident) => (
<li key={`${incident.title}-${incident.timeHorizon}`} className="rounded-lg border border-[#e4ebf6] px-2 py-1">
<p className="font-semibold text-[#233b62]">{incident.title}</p>
<p>
Probabilidad {incident.likelihood} · Impacto {incident.impact} · Horizonte {incident.timeHorizon}
</p>
</li>
))}
</ul>
</article>
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<p className="text-sm font-semibold text-[#1d335a]">Orden de prioridad</p>
<ol className="mt-2 space-y-1 text-xs text-[#60718f]">
{aiPlaybook.priorityOrder.map((item, index) => (
<li key={`${item}-${index}`}>
{index + 1}. {item}
</li>
))}
</ol>
</article>
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<p className="text-sm font-semibold text-[#1d335a]">Acciones preventivas</p>
<ul className="mt-2 space-y-2 text-xs text-[#60718f]">
{aiPlaybook.preventiveActions.map((action) => (
<li key={`${action.action}-${action.ownerSuggestion}`} className="rounded-lg border border-[#e4ebf6] px-2 py-1">
<p className="font-semibold text-[#233b62]">{action.action}</p>
<p>
Responsable sugerido: {action.ownerSuggestion} · Fecha objetivo: {action.targetDate}
</p>
</li>
))}
</ul>
</article>
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<p className="text-sm font-semibold text-[#1d335a]">Recomendaciones de escalamiento</p>
<ul className="mt-2 space-y-1 text-xs text-[#60718f]">
{aiPlaybook.escalationAdvice.map((item) => (
<li key={item}>- {item}</li>
))}
</ul>
<div className="mt-3">
<Button
type="button"
size="sm"
onClick={() => {
void setSuggestionDecision("accept");
setAiFeedback("Plan IA marcado como aceptado.");
}}
>
Aceptar plan IA
</Button>
</div>
</article>
</>
) : null}
</div>
),
},
]}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -43,13 +43,13 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
<div className="space-y-4">
<Card>
<CardHeader>
<h3 className="text-3xl font-semibold text-[#142447] [font-family:var(--font-display)]">Tu Indice de Madurez</h3>
<h3 className="text-2xl font-semibold text-[#142447] [font-family:var(--font-display)] md:text-3xl">Tu Indice de Madurez</h3>
<p className="text-sm text-[#60718f]">Visualiza tu progreso hacia el siguiente nivel de preparacion.</p>
</CardHeader>
<CardContent className="space-y-6">
<div className="text-center">
<p className="text-7xl font-semibold text-[#20a777]">{Math.round(snapshot.overallMaturity)}%</p>
<p className="text-2xl font-semibold text-[#213252]">Puntaje Global</p>
<p className="text-5xl font-semibold text-[#20a777] md:text-6xl">{Math.round(snapshot.overallMaturity)}%</p>
<p className="text-xl font-semibold text-[#213252]">Puntaje Global</p>
<span className="mt-2 inline-flex rounded-full bg-[#24a977] px-4 py-1 text-sm font-semibold text-white">
Nivel {snapshot.maturityLevel.label}
</span>
@@ -69,7 +69,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
<div className="rounded-2xl border border-[#d6e0ee] bg-[#f5faf7] p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-3xl font-semibold text-[#18305f]">
<p className="text-2xl font-semibold text-[#18305f] md:text-3xl">
Proximo nivel: <span className="text-[#d39b1c]">{snapshot.nextLevel?.label ?? "Avanzado"}</span>
</p>
{snapshot.nextLevel ? (
@@ -101,7 +101,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">Tu RADAR Empresarial</h3>
<h3 className="text-2xl font-semibold text-[#152647] [font-family:var(--font-display)] md:text-3xl">Tu RADAR Empresarial</h3>
<p className="text-sm text-[#60718f]">Visualiza tus fortalezas y areas de mejora en 5 dimensiones.</p>
</CardHeader>
<CardContent>
@@ -116,7 +116,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
<Card>
<CardHeader>
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">Puntaje por Dimension</h3>
<h3 className="text-2xl font-semibold text-[#152647] [font-family:var(--font-display)] md:text-3xl">Puntaje por Dimension</h3>
<p className="text-sm text-[#60718f]">Cada respuesta afirmativa suma 20 puntos. Maximo 100 por dimension.</p>
</CardHeader>
<CardContent className="space-y-4">
@@ -125,7 +125,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-[#1f2f4c]">{statusIcon(dimension.gapLevel)}</span>
<p className="text-3xl font-semibold text-[#1a2c4b]">{dimension.moduleName}</p>
<p className="text-xl font-semibold text-[#1a2c4b] md:text-2xl">{dimension.moduleName}</p>
<Link href={`/talleres-desarrollo?dimension=${dimension.moduleKey}`}>
<Button size="sm" className="h-8 rounded-full bg-[#25a878] px-4 text-sm hover:bg-[#1f9368]">
Ver talleres
@@ -134,7 +134,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
</div>
<div className="flex items-center gap-2">
<span className={`rounded-full border px-3 py-1 text-sm font-semibold ${gapTone(dimension.gapLevel)}`}>{dimension.gapLabel}</span>
<span className="text-2xl font-semibold text-[#152747]">{Math.round(dimension.displayScore)}%</span>
<span className="text-xl font-semibold text-[#152747]">{Math.round(dimension.displayScore)}%</span>
</div>
</div>
<div className="h-4 overflow-hidden rounded-full bg-[#d9e2ee]">
@@ -152,7 +152,7 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
<Card>
<CardHeader>
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">Analisis de Brechas por Dimension</h3>
<h3 className="text-2xl font-semibold text-[#152647] [font-family:var(--font-display)] md:text-3xl">Analisis de Brechas por Dimension</h3>
<p className="text-sm text-[#60718f]">Identificacion de riesgos asociados a cada area de tu empresa.</p>
</CardHeader>
<CardContent className="overflow-x-auto">
@@ -193,13 +193,13 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
<Card>
<CardHeader>
<h3 className="text-4xl font-semibold text-[#152647] [font-family:var(--font-display)]">Recomendaciones Estrategicas Generales</h3>
<h3 className="text-2xl font-semibold text-[#152647] [font-family:var(--font-display)] md:text-3xl">Recomendaciones Estrategicas Generales</h3>
<p className="text-sm text-[#60718f]">3 acciones prioritarias para mejorar tu posicion competitiva.</p>
</CardHeader>
<CardContent className="space-y-3">
{snapshot.recommendations.map((recommendation, index) => (
<article key={recommendation.id} className="rounded-2xl border border-[#d4dceb] bg-[#fdfefe] p-4">
<p className="text-4xl font-semibold text-[#102345]">
<p className="text-2xl font-semibold text-[#102345] md:text-3xl">
{index + 1}. {recommendation.title}
</p>
<p className="mt-1 text-sm text-[#5f7395]">{recommendation.description}</p>
@@ -211,19 +211,19 @@ export function DashboardMaturitySection({ snapshot }: { snapshot: TalleresSnaps
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="space-y-2 py-5">
<p className="text-4xl font-semibold text-[#132447] [font-family:var(--font-display)]">Que es RADAR?</p>
<p className="text-2xl font-semibold text-[#132447] [font-family:var(--font-display)] md:text-3xl">Que es RADAR?</p>
<p className="text-sm text-[#60718f]">El diagnostico RADAR evalua 5 dimensiones clave para identificar tu nivel de preparacion.</p>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-2 py-5">
<p className="text-4xl font-semibold text-[#132447] [font-family:var(--font-display)]">Que es CRECE?</p>
<p className="text-2xl font-semibold text-[#132447] [font-family:var(--font-display)] md:text-3xl">Que es CRECE?</p>
<p className="text-sm text-[#60718f]">Es tu ruta de fortalecimiento empresarial con capacitacion, rediseno y preparacion.</p>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-2 py-5">
<p className="text-4xl font-semibold text-[#132447] [font-family:var(--font-display)]">Indice de Madurez</p>
<p className="text-2xl font-semibold text-[#132447] [font-family:var(--font-display)] md:text-3xl">Indice de Madurez</p>
<p className="text-sm text-[#60718f]">Mide tu evolucion de Inicial a En Desarrollo, Preparado y Avanzado.</p>
</CardContent>
</Card>

View File

@@ -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<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [diagnosisStep, setDiagnosisStep] = useState(1);
const [diagnosisId, setDiagnosisId] = useState<string | null>(null);
const [diagnosisRecommendation, setDiagnosisRecommendation] = useState<DiagnosisRecommendation | null>(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<ApiPayload["escalation"] | null>(null);
const [documentDraft, setDocumentDraft] = useState({
legalCaseId: "",
caseType: "CONTRACT_BREACH",
severity: "MEDIUM",
templateKey: "",
counterparty: "",
companyName: "",
amountAtRisk: "",
description: "",
objective: "",
title: "",
});
const [generatedDocuments, setGeneratedDocuments] = useState<Array<{ id: string; title: string; content: string; createdAt: string }>>([]);
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 (
<div className="space-y-4">
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Casos abiertos</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.openCases}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Alta severidad</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.highSeverityCases}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Monto en riesgo</p>
<p className="mt-2 text-3xl font-semibold text-[#15294d]">{kpis.amountAtRisk.toLocaleString("es-MX")}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Casos resueltos</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.resolvedCases}</p>
</CardContent>
</Card>
</section>
{errorMessage ? <p className="rounded-xl border border-[#efc4c4] bg-[#fff1f1] px-3 py-2 text-sm text-[#ad3f3f]">{errorMessage}</p> : null}
{message ? <p className="rounded-xl border border-[#bde5ce] bg-[#ecf9f1] px-3 py-2 text-sm text-[#1f7f4f]">{message}</p> : null}
<Tabs
defaultTab="diagnosis"
items={[
{
id: "diagnosis",
label: "Diagnostico",
content: (
<Card>
<CardContent className="space-y-3 py-5">
<p className="text-sm text-[#60718f]">Wizard de 4 pasos para sugerir severidad, tipo de caso y ruta de escalada.</p>
<p className="text-xs font-semibold text-[#415a82]">Paso {diagnosisStep} de 4</p>
<label className="space-y-1 text-xs font-semibold text-[#314764]">
Contrato vinculado (opcional)
<select
value={diagnosisContractId}
onChange={(event) => setDiagnosisContractId(event.target.value)}
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
disabled={Boolean(diagnosisId)}
>
<option value="">Sin contrato</option>
{contractOptions.map((contract) => (
<option key={contract.id} value={contract.id}>
{contract.isPrefilled ? `${contract.label} (prefill)` : contract.label}
</option>
))}
</select>
</label>
<div className="grid gap-2 md:grid-cols-2">
<label className="space-y-1 text-xs font-semibold text-[#314764]">
Tipo de incumplimiento
<select
value={diagnosisAnswers.breachType}
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, breachType: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
>
<option value="contract_breach">Incumplimiento contractual</option>
<option value="payment_retention">Retencion de pagos</option>
<option value="unjust_sanction">Sancion injusta</option>
<option value="contract_dispute">Disputa contractual</option>
</select>
</label>
<label className="space-y-1 text-xs font-semibold text-[#314764]">
Estado procesal
<select
value={diagnosisAnswers.processStage}
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, processStage: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
>
<option value="none">Sin proceso</option>
<option value="informal_notice">Aviso informal</option>
<option value="formal_notice">Requerimiento formal</option>
<option value="ongoing_claim">Reclamacion en curso</option>
<option value="trial">Via jurisdiccional</option>
</select>
</label>
<label className="space-y-1 text-xs font-semibold text-[#314764]">
Evidencia disponible
<select
value={diagnosisAnswers.evidenceLevel}
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, evidenceLevel: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
>
<option value="none">Sin evidencia</option>
<option value="basic">Basica</option>
<option value="solid">Solida</option>
<option value="robust">Robusta</option>
</select>
</label>
<label className="space-y-1 text-xs font-semibold text-[#314764]">
Urgencia
<select
value={diagnosisAnswers.urgency}
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, urgency: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
>
<option value="low">Baja</option>
<option value="medium">Media</option>
<option value="high">Alta</option>
</select>
</label>
<label className="space-y-1 text-xs font-semibold text-[#314764]">
Objetivo legal
<select
value={diagnosisAnswers.objective}
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, objective: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
>
<option value="payment">Recuperar pago</option>
<option value="correction">Correccion</option>
<option value="challenge">Impugnacion</option>
<option value="mediation">Mediacion</option>
</select>
</label>
<label className="space-y-1 text-xs font-semibold text-[#314764]">
Monto en riesgo
<input
type="number"
value={diagnosisAnswers.amountAtRisk}
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, amountAtRisk: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
/>
</label>
</div>
<input
value={diagnosisAnswers.counterparty}
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, counterparty: event.target.value }))}
placeholder="Contraparte"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
/>
<textarea
value={diagnosisAnswers.description}
onChange={(event) => setDiagnosisAnswers((prev) => ({ ...prev, description: event.target.value }))}
placeholder="Descripcion del caso"
className="min-h-[110px] w-full rounded-lg border border-[#cfd8e7] px-3 py-2 text-sm"
/>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => submitDiagnosisStep(false)} disabled={isBusy}>
Guardar paso
</Button>
<Button onClick={() => submitDiagnosisStep(true)} disabled={isBusy}>
Finalizar y abrir caso
</Button>
</div>
{diagnosisRecommendation ? (
<div className="rounded-xl border border-[#ced9ec] bg-[#f4f8ff] p-3 text-sm text-[#2d4b7b]">
<p>
Sugerencia: {diagnosisRecommendation.suggestedCaseType} / {diagnosisRecommendation.suggestedSeverity}
</p>
<p className="mt-1">Ruta: {diagnosisRecommendation.suggestedRoute.map((item) => item.title).join(" -> ")}</p>
<p className="mt-1">Escritos: {diagnosisRecommendation.recommendedTemplates.join(", ")}</p>
</div>
) : null}
</CardContent>
</Card>
),
},
{
id: "cases",
label: "Casos",
content: (
<div className="space-y-3">
<Dialog triggerLabel="Nuevo Caso" title="Nuevo Caso" description="Registro manual de caso legal">
<div className="space-y-2">
<select
value={newCase.contractId}
onChange={(event) => setNewCase((prev) => ({ ...prev, contractId: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
>
<option value="">Sin contrato</option>
{contractOptions.map((contract) => (
<option key={contract.id} value={contract.id}>
{contract.isPrefilled ? `${contract.label} (prefill)` : contract.label}
</option>
))}
</select>
<input
value={newCase.counterparty}
onChange={(event) => setNewCase((prev) => ({ ...prev, counterparty: event.target.value }))}
placeholder="Contraparte"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
/>
<textarea
value={newCase.description}
onChange={(event) => setNewCase((prev) => ({ ...prev, description: event.target.value }))}
placeholder="Descripcion"
className="min-h-[90px] w-full rounded-lg border border-[#cfd8e7] px-3 py-2 text-sm"
/>
<Button onClick={createCase} disabled={isBusy}>
Guardar caso
</Button>
</div>
</Dialog>
{cases.length === 0 ? <p className="text-sm text-[#60718f]">Sin casos registrados.</p> : null}
{cases.map((item) => (
<article key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-[#1d335a]">{item.caseType}</p>
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-semibold ${severityTone(item.severity)}`}>{item.severity}</span>
</div>
<p className="mt-1 text-xs text-[#60718f]">{item.counterparty}</p>
<p className="mt-1 text-xs text-[#60718f]">{item.description}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<select
value={item.status}
onChange={(event) => updateCaseStatus(item.id, event.target.value as LegalCaseView["status"])}
className="h-8 rounded-lg border border-[#cfd8e7] px-2 text-xs"
>
<option value="OPEN">OPEN</option>
<option value="IN_PROGRESS">IN_PROGRESS</option>
<option value="ESCALATED">ESCALATED</option>
<option value="RESOLVED">RESOLVED</option>
<option value="CLOSED">CLOSED</option>
</select>
<span className="text-xs text-[#60718f]">Abierto: {formatDate(item.openedAt)}</span>
</div>
</article>
))}
</div>
),
},
{
id: "escalation",
label: "Escalada",
content: (
<div className="space-y-3">
<select
value={selectedEscalationCaseId}
onChange={(event) => setSelectedEscalationCaseId(event.target.value)}
className="h-10 w-full rounded-lg border border-[#cfd8e7] bg-white px-3 text-sm"
>
<option value="">Selecciona caso</option>
{cases.map((item) => (
<option key={item.id} value={item.id}>
{item.caseType} - {item.counterparty}
</option>
))}
</select>
{currentEscalationCase && !escalation ? <Button variant="secondary" onClick={() => loadEscalation(currentEscalationCase.id)}>Cargar ruta</Button> : null}
{escalation?.steps.map((step) => (
<article key={step.key} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-[#1d335a]">{step.title}</p>
<p className="text-xs text-[#60718f]">Ventana estimada: {step.estimatedWindow}</p>
{step.requiresLawyer ? <p className="text-xs font-semibold text-[#9b4f2c]">Requiere abogado</p> : null}
</div>
<label className="inline-flex items-center gap-2 text-xs font-semibold text-[#314764]">
<input
type="checkbox"
checked={Boolean(step.completedAt)}
disabled={step.isBlocked}
onChange={(event) => {
if (!selectedEscalationCaseId) {
return;
}
void toggleEscalationStep(selectedEscalationCaseId, step.key, event.target.checked, step.notes ?? "");
}}
/>
Completado
</label>
</div>
<ul className="mt-2 list-disc pl-5 text-xs text-[#60718f]">
{step.checklist.map((item) => (
<li key={`${step.key}-${item}`}>{item}</li>
))}
</ul>
</article>
))}
</div>
),
},
{
id: "documents",
label: "Escritos",
content: (
<div className="space-y-3">
<Card>
<CardContent className="space-y-2 py-5">
<select
value={documentDraft.legalCaseId}
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, legalCaseId: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
>
<option value="">Sin caso vinculado</option>
{cases.map((item) => (
<option key={item.id} value={item.id}>
{item.caseType} - {item.counterparty}
</option>
))}
</select>
<select
value={documentDraft.templateKey}
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, templateKey: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
>
<option value="">Plantilla automatica por tipo de caso</option>
{templates.map((template) => (
<option key={template.key} value={template.key}>
{template.title}
</option>
))}
</select>
<input
value={documentDraft.counterparty}
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, counterparty: event.target.value }))}
placeholder="Contraparte"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
/>
<input
value={documentDraft.companyName}
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, companyName: event.target.value }))}
placeholder="Empresa"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
/>
<input
value={documentDraft.objective}
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, objective: event.target.value }))}
placeholder="Objetivo legal"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3 text-sm"
/>
<textarea
value={documentDraft.description}
onChange={(event) => setDocumentDraft((prev) => ({ ...prev, description: event.target.value }))}
placeholder="Descripcion"
className="min-h-[110px] w-full rounded-lg border border-[#cfd8e7] px-3 py-2 text-sm"
/>
<Button onClick={generateDocument} disabled={isBusy}>
Generar escrito
</Button>
</CardContent>
</Card>
{generatedDocuments.map((item) => (
<article key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<p className="text-sm font-semibold text-[#1d335a]">{item.title}</p>
<p className="mt-1 text-xs text-[#60718f]">{formatDate(item.createdAt)}</p>
<textarea readOnly value={item.content} className="mt-2 min-h-[160px] w-full rounded-lg border border-[#cfd8e7] p-2 text-xs" />
</article>
))}
</div>
),
},
{
id: "directory",
label: "Directorio",
content: (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<select
value={directoryFilters.jurisdictionLevel}
onChange={(event) => setDirectoryFilters((prev) => ({ ...prev, jurisdictionLevel: event.target.value }))}
className="h-10 rounded-lg border border-[#cfd8e7] px-3 text-sm"
>
<option value="">Todas las jurisdicciones</option>
<option value="FEDERAL">FEDERAL</option>
<option value="STATE">STATE</option>
<option value="MUNICIPAL">MUNICIPAL</option>
</select>
<input
value={directoryFilters.q}
onChange={(event) => setDirectoryFilters((prev) => ({ ...prev, q: event.target.value }))}
placeholder="Buscar entidad"
className="h-10 min-w-[260px] rounded-lg border border-[#cfd8e7] px-3 text-sm"
/>
<Button variant="secondary" onClick={reloadDirectory}>
Filtrar
</Button>
</div>
{directory.length === 0 ? <p className="text-sm text-[#60718f]">Sin entidades en el filtro actual.</p> : null}
{directory.map((item) => (
<article key={item.id} className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<p className="text-sm font-semibold text-[#1d335a]">{item.name}</p>
<p className="mt-1 text-xs text-[#60718f]">{item.jurisdictionLevel} - {item.scopeTags.join(", ")}</p>
<p className="mt-1 text-xs text-[#60718f]">{item.websiteUrl ?? "Sin sitio web"}</p>
</article>
))}
</div>
),
},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { LicitationReviewStatusView } from "@/lib/licitations/preferences";
import { Button } from "@/components/ui/button";
type LicitationCardActionsProps = {
licitationId: string;
initialStatus: LicitationReviewStatusView;
normativeAnalysisHref: string;
};
type ApiPayload = {
ok?: boolean;
error?: string;
status?: LicitationReviewStatusView;
};
export function LicitationCardActions({ licitationId, initialStatus, normativeAnalysisHref }: LicitationCardActionsProps) {
const router = useRouter();
const [status, setStatus] = useState<LicitationReviewStatusView>(initialStatus);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
async function updateStatus(nextStatus: LicitationReviewStatusView, shouldRefresh = true) {
setErrorMessage(null);
setIsSaving(true);
try {
const response = await fetch(`/api/licitations/preferences/${encodeURIComponent(licitationId)}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ status: nextStatus }),
});
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
if (!response.ok || !payload.ok || !payload.status) {
setErrorMessage(payload.error ?? "No fue posible actualizar el estado.");
return false;
}
setStatus(payload.status);
if (shouldRefresh) {
router.refresh();
}
return true;
} catch {
setErrorMessage("No fue posible actualizar el estado.");
return false;
} finally {
setIsSaving(false);
}
}
async function handleToggleReviewed() {
const nextStatus = status === "REVIEWED" ? "NEW" : "REVIEWED";
await updateStatus(nextStatus);
}
async function handleInterested() {
const ok = await updateStatus("INTERESTED", false);
if (!ok) {
return;
}
window.location.href = normativeAnalysisHref;
}
async function handleDiscardOrRestore() {
const nextStatus = status === "DISCARDED" ? "NEW" : "DISCARDED";
await updateStatus(nextStatus);
}
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="secondary" onClick={handleToggleReviewed} disabled={isSaving}>
{status === "REVIEWED" ? "Quitar consultada" : "Marcar consultada"}
</Button>
<Button size="sm" onClick={handleInterested} disabled={isSaving}>
Me interesa
</Button>
<Button size="sm" variant="ghost" onClick={handleDiscardOrRestore} disabled={isSaving}>
{status === "DISCARDED" ? "Restaurar" : "Descartar"}
</Button>
</div>
{errorMessage ? <p className="text-xs font-semibold text-[#b93a4b]">{errorMessage}</p> : null}
</div>
);
}

View File

@@ -17,7 +17,11 @@ export function LicitationsSyncButton() {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
body: JSON.stringify({
includePnt: true,
includeLicitaya: true,
force: true,
}),
});
const payload = (await response.json().catch(() => ({}))) as {
@@ -25,6 +29,19 @@ export function LicitationsSyncButton() {
error?: string;
payload?: {
processedMunicipalities?: number;
processedScopes?: number;
processedLicitayaBuckets?: number;
results?: Array<{
sourceResults?: Array<{
status?: string;
warnings?: string[];
stats?: {
totalFetched?: number;
inserted?: number;
updated?: number;
};
}>;
}>;
};
};
@@ -33,8 +50,41 @@ export function LicitationsSyncButton() {
return;
}
const processed = payload.payload?.processedMunicipalities ?? 0;
setMessage(`Sincronizacion completada. Municipios procesados: ${processed}.`);
const processedMunicipalities = payload.payload?.processedMunicipalities ?? 0;
const processedScopes = payload.payload?.processedScopes ?? processedMunicipalities;
const licitayaBuckets = payload.payload?.processedLicitayaBuckets ?? 0;
const sourceResults = payload.payload?.results?.flatMap((result) => result.sourceResults ?? []) ?? [];
const totals = sourceResults.reduce(
(acc, sourceResult) => {
acc.fetched += sourceResult.stats?.totalFetched ?? 0;
acc.inserted += sourceResult.stats?.inserted ?? 0;
acc.updated += sourceResult.stats?.updated ?? 0;
acc.warnings += sourceResult.warnings?.length ?? 0;
if (sourceResult.status === "FAILED") {
acc.failed += 1;
} else if (sourceResult.status === "PARTIAL") {
acc.partial += 1;
}
return acc;
},
{
fetched: 0,
inserted: 0,
updated: 0,
warnings: 0,
failed: 0,
partial: 0,
},
);
setMessage(
`Sync listo. Municipios: ${processedMunicipalities}. Alcances: ${processedScopes}. LicitaYa: ${licitayaBuckets}. ` +
`Detectados: ${totals.fetched}. Nuevos: ${totals.inserted}. Actualizados: ${totals.updated}. ` +
`Parciales: ${totals.partial}. Fallidos: ${totals.failed}. Advertencias: ${totals.warnings}.`,
);
} catch {
setMessage("No se pudo ejecutar la sincronizacion.");
} finally {

View File

@@ -43,6 +43,15 @@ type EvidenceDraft = {
type SaveState = "idle" | "saving" | "saved" | "error";
type AiQuestionSuggestion = {
questionId: string;
suggestedAnswerOptionId: string;
rationale: string;
missingEvidence: string[];
confidence: number | null;
suggestionId: string;
};
function toEvidenceDraft(
evidence: {
notes: string;
@@ -102,6 +111,11 @@ export function ModuleQuestionnaire({
const [saveStateByQuestionId, setSaveStateByQuestionId] = useState<Record<string, SaveState>>({});
const [saveErrorByQuestionId, setSaveErrorByQuestionId] = useState<Record<string, string>>({});
const [aiSuggestionsByQuestionId, setAiSuggestionsByQuestionId] = useState<Record<string, AiQuestionSuggestion>>({});
const [isGeneratingAi, setIsGeneratingAi] = useState(false);
const [aiMessage, setAiMessage] = useState<string | null>(null);
const [aiError, setAiError] = useState<string | null>(null);
const [actingSuggestionId, setActingSuggestionId] = useState<string | null>(null);
const answeredCount = useMemo(() => {
return questions.reduce((total, question) => {
@@ -150,12 +164,14 @@ export function ModuleQuestionnaire({
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as { error?: string };
setQuestionSaveState(questionId, "error", payload.error ?? "No se pudo guardar la respuesta.");
return;
return false;
}
setQuestionSaveState(questionId, "saved");
return true;
} catch {
setQuestionSaveState(questionId, "error", "No se pudo guardar la respuesta.");
return false;
}
}
@@ -191,6 +207,118 @@ export function ModuleQuestionnaire({
void persistQuestionResponse(questionId, answerOptionId, draft);
}
async function setSuggestionDecision(suggestionId: string, decision: "accept" | "dismiss") {
const response = await fetch(`/api/ai/suggestions/${encodeURIComponent(suggestionId)}/decision`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ decision }),
});
return response.ok;
}
async function generateAiSuggestions() {
setIsGeneratingAi(true);
setAiError(null);
setAiMessage(null);
try {
const response = await fetch("/api/diagnostic/ai/suggestions", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
moduleKey,
}),
});
const payload = (await response.json().catch(() => ({}))) as {
ok?: boolean;
error?: string;
suggestions?: AiQuestionSuggestion[];
};
if (!response.ok || !payload.ok || !payload.suggestions) {
setAiError(payload.error ?? "No fue posible generar sugerencias IA.");
return;
}
const byQuestionId = payload.suggestions.reduce<Record<string, AiQuestionSuggestion>>((accumulator, suggestion) => {
accumulator[suggestion.questionId] = suggestion;
return accumulator;
}, {});
setAiSuggestionsByQuestionId(byQuestionId);
setAiMessage(payload.suggestions.length ? "Sugerencias IA actualizadas." : "No hay preguntas pendientes para sugerir.");
} catch {
setAiError("No fue posible generar sugerencias IA.");
} finally {
setIsGeneratingAi(false);
}
}
async function applyAiSuggestion(questionId: string) {
const suggestion = aiSuggestionsByQuestionId[questionId];
if (!suggestion) {
return;
}
setActingSuggestionId(suggestion.suggestionId);
setAiError(null);
setAiMessage(null);
try {
setAnswers((previous) => ({
...previous,
[questionId]: suggestion.suggestedAnswerOptionId,
}));
const draft = evidenceByQuestionId[questionId] ?? { notes: "", links: "" };
const persisted = await persistQuestionResponse(questionId, suggestion.suggestedAnswerOptionId, draft);
if (!persisted) {
setAiError("La sugerencia se aplico localmente, pero no se pudo guardar en el servidor.");
return;
}
await setSuggestionDecision(suggestion.suggestionId, "accept");
setAiSuggestionsByQuestionId((previous) => {
const next = { ...previous };
delete next[questionId];
return next;
});
setAiMessage("Sugerencia IA aplicada.");
} finally {
setActingSuggestionId(null);
}
}
async function dismissAiSuggestion(questionId: string) {
const suggestion = aiSuggestionsByQuestionId[questionId];
if (!suggestion) {
return;
}
setActingSuggestionId(suggestion.suggestionId);
setAiError(null);
setAiMessage(null);
try {
await setSuggestionDecision(suggestion.suggestionId, "dismiss");
setAiSuggestionsByQuestionId((previous) => {
const next = { ...previous };
delete next[questionId];
return next;
});
setAiMessage("Sugerencia IA descartada.");
} finally {
setActingSuggestionId(null);
}
}
if (questions.length === 0) {
return (
<Card>
@@ -231,6 +359,13 @@ export function ModuleQuestionnaire({
</p>
</div>
<Progress value={completion} showValue />
<div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" variant="secondary" disabled={isGeneratingAi} onClick={() => void generateAiSuggestions()}>
{isGeneratingAi ? "Generando sugerencias IA..." : "Generar sugerencias IA"}
</Button>
{aiMessage ? <p className="text-sm text-[#1f7c4d]">{aiMessage}</p> : null}
{aiError ? <p className="text-sm text-[#b63d3d]">{aiError}</p> : null}
</div>
</CardContent>
</Card>
@@ -241,6 +376,9 @@ export function ModuleQuestionnaire({
const evidenceDraft = evidenceByQuestionId[question.id] ?? { notes: "", links: "" };
const saveState = saveStateByQuestionId[question.id] ?? "idle";
const saveError = saveErrorByQuestionId[question.id] ?? null;
const aiSuggestion = aiSuggestionsByQuestionId[question.id] ?? null;
const suggestedOptionLabel =
question.options.find((option) => option.id === aiSuggestion?.suggestedAnswerOptionId)?.label ?? "Opcion sugerida";
return (
<div key={question.id} className="rounded-xl border border-[#d9e2f0] p-4">
@@ -315,6 +453,41 @@ export function ModuleQuestionnaire({
</p>
) : null}
</div>
{aiSuggestion ? (
<div className="mt-4 rounded-lg border border-[#d7e2f4] bg-[#f6f9ff] p-3">
<p className="text-sm font-semibold text-[#1d335a]">Sugerencia IA</p>
<p className="mt-1 text-sm text-[#405578]">
Opcion sugerida: <span className="font-semibold">{suggestedOptionLabel}</span>
</p>
<p className="mt-1 text-sm text-[#506688]">{aiSuggestion.rationale}</p>
{aiSuggestion.missingEvidence.length ? (
<p className="mt-1 text-xs text-[#60718f]">Evidencia faltante: {aiSuggestion.missingEvidence.join(" | ")}</p>
) : null}
{typeof aiSuggestion.confidence === "number" ? (
<p className="mt-1 text-xs font-semibold text-[#334d77]">Confianza: {Math.round(aiSuggestion.confidence * 100)}%</p>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
disabled={actingSuggestionId === aiSuggestion.suggestionId}
onClick={() => void applyAiSuggestion(question.id)}
>
Aplicar
</Button>
<Button
type="button"
size="sm"
variant="secondary"
disabled={actingSuggestionId === aiSuggestion.suggestionId}
onClick={() => void dismissAiSuggestion(question.id)}
>
Descartar
</Button>
</div>
</div>
) : null}
</div>
);
})}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { isAdminIdentity } from "@/lib/auth/admin";
import { KontiaMark } from "@/components/app/kontia-mark";
import { getCurrentUser } from "@/lib/auth/user";
import { cn } from "@/lib/utils";
import { getHeaderVisibility, type HeaderModeRequest } from "@/lib/layout/header";
type PageShellProps = {
children: React.ReactNode;
@@ -10,98 +11,139 @@ type PageShellProps = {
description?: string;
action?: React.ReactNode;
className?: string;
showPageHeading?: boolean;
headerMode?: HeaderModeRequest;
headerBackHref?: string;
headerBackLabel?: string;
headerNextHref?: string;
headerNextLabel?: string;
headerShowManual?: boolean;
headerPlanBadgeLabel?: string;
headerAction?: React.ReactNode;
headerShowLogout?: boolean;
contentWidth?: "default" | "wide";
};
const navLinks = [
{ href: "/dashboard", label: "Dashboard" },
{ href: "/diagnostic", label: "Diagnostico" },
{ href: "/dashboard#modulos", label: "Modulos" },
{ href: "/talleres-desarrollo", label: "Talleres" },
{ href: "/licitations", label: "Licitaciones" },
{ href: "/results", label: "Results" },
{ href: "/recommendations", label: "Recommendations" },
{ href: "/manual", label: "Manual" },
];
function HeaderBadge({ label }: { label: string }) {
return <span className="rounded-full bg-[#0f2a5f] px-4 py-1.5 text-sm font-semibold text-white">{label}</span>;
}
export async function PageShell({ children, title, description, action, className }: PageShellProps) {
export async function PageShell({
children,
title,
description,
action,
className,
showPageHeading = true,
headerMode = "auto",
headerBackHref = "/dashboard",
headerBackLabel = "Volver al Dashboard",
headerNextHref,
headerNextLabel,
headerShowManual = false,
headerPlanBadgeLabel,
headerAction,
headerShowLogout = true,
contentWidth = "default",
}: PageShellProps) {
const currentUser = await getCurrentUser();
const isAdmin = currentUser ? isAdminIdentity(currentUser.email, currentUser.role) : false;
const visibility = getHeaderVisibility(Boolean(currentUser), headerMode);
const containerClassName = contentWidth === "wide" ? "max-w-[1560px]" : "max-w-[1200px]";
return (
<div className="min-h-screen bg-[#eff2f7]">
<header className="border-b border-[#d8dde7] bg-[#eff2f7]">
<div className="mx-auto flex w-full max-w-[1200px] items-center justify-between gap-3 px-4 py-3 sm:px-6 lg:px-8">
<Link href="/">
<KontiaMark />
</Link>
<div className={cn("mx-auto flex w-full items-center justify-between gap-3 px-4 py-4 sm:px-6 lg:px-8", containerClassName)}>
{visibility.showBackLink ? (
<Link href={headerBackHref} className="inline-flex items-center gap-3 text-3xl font-semibold text-[#1a2c4f] [font-family:var(--font-display)]">
<span className="text-2xl" aria-hidden>
</span>
<span className="text-base font-semibold [font-family:var(--font-sans)]">{headerBackLabel}</span>
</Link>
) : (
<Link href="/">
<KontiaMark />
</Link>
)}
<nav className="hidden items-center gap-5 md:flex">
{currentUser ? (
navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-sm font-medium text-[#516180] transition-colors hover:text-[#223f7c]"
>
{link.label}
</Link>
))
) : (
<>
<Link href="/#metodologia" className="text-sm font-medium text-[#516180] transition-colors hover:text-[#223f7c]">
Metodologia
</Link>
<Link href="/#modulos" className="text-sm font-medium text-[#516180] transition-colors hover:text-[#223f7c]">
Modulos
</Link>
<Link href="/#beneficios" className="text-sm font-medium text-[#516180] transition-colors hover:text-[#223f7c]">
Beneficios
</Link>
</>
)}
{isAdmin && currentUser ? (
<Link href="/admin" className="text-sm font-semibold text-[#1f3f82] transition-colors hover:text-[#17336c]">
{visibility.showMarketingNav ? (
<nav className="hidden items-center gap-10 text-base font-medium text-[#4f5f7d] lg:flex">
<Link href="/#metodologia" className="transition-colors hover:text-[#223f7c]">
Metodologia
</Link>
<Link href="/#modulos" className="transition-colors hover:text-[#223f7c]">
Modulos
</Link>
<Link href="/#beneficios" className="transition-colors hover:text-[#223f7c]">
Beneficios
</Link>
<Link href="/#contacto" className="transition-colors hover:text-[#223f7c]">
Contacto
</Link>
</nav>
) : null}
<div className="flex items-center gap-2 sm:gap-3">
{visibility.showUserControls && headerShowManual ? (
<Link href="/manual" className="inline-flex h-11 items-center gap-2 rounded-2xl border border-[#cfd8e7] bg-[#f7f9fd] px-5 text-sm font-semibold text-[#1a2d51] transition hover:bg-white">
<span aria-hidden></span>
Manual
</Link>
) : null}
{visibility.showUserControls && headerNextHref && headerNextLabel ? (
<Link href={headerNextHref} className="inline-flex items-center gap-2 text-sm font-semibold text-[#445d86] hover:text-[#1f3f84]">
{headerNextLabel}
<span aria-hidden></span>
</Link>
) : null}
{visibility.showUserControls && headerAction ? headerAction : null}
{visibility.showUserControls && headerPlanBadgeLabel ? <HeaderBadge label={headerPlanBadgeLabel} /> : null}
{isAdmin && currentUser && visibility.mode === "app" ? (
<Link href="/admin" className="rounded-xl border border-[#cfd8e7] px-3 py-2 text-sm font-semibold text-[#2f486f] transition-colors hover:bg-white">
Admin
</Link>
) : null}
</nav>
{currentUser ? (
<div className="flex items-center gap-2">
<p className="hidden text-xs font-semibold text-[#5f6c84] sm:block">{currentUser.email}</p>
{visibility.showUserControls && headerShowLogout ? (
<form action="/api/auth/logout" method="post">
<button
type="submit"
className="rounded-xl border border-[#ccd6e5] px-3 py-1.5 text-xs font-semibold text-[#334a73] transition-colors hover:bg-white"
className="inline-flex h-11 items-center rounded-2xl border border-[#ccd6e5] px-4 text-sm font-semibold text-[#334a73] transition-colors hover:bg-white"
>
Logout
Salir
</button>
</form>
</div>
) : (
<div className="flex items-center gap-2 text-xs font-semibold text-[#5f6c84]">
<Link href="/login" className="rounded-xl px-3 py-2 text-sm font-semibold text-[#13254a] transition-colors hover:text-[#214485]">
Iniciar Sesion
</Link>
<Link
href="/register"
className="rounded-2xl bg-[#1f3f84] px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#17356f]"
>
Comenzar Gratis
</Link>
</div>
)}
) : null}
{visibility.showAuthButtons ? (
<>
<Link href="/login" className="rounded-xl px-3 py-2 text-sm font-semibold text-[#13254a] transition-colors hover:text-[#214485]">
Iniciar Sesion
</Link>
<Link href="/register" className="rounded-2xl bg-[#1f3f84] px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#17356f]">
Comenzar Gratis
</Link>
</>
) : null}
</div>
</div>
</header>
<main className="mx-auto w-full max-w-[1200px] px-4 py-8 sm:px-6 lg:px-8">
<section className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-4xl font-semibold text-[#142447] [font-family:var(--font-display)] md:text-5xl">{title}</h1>
{description ? <p className="mt-1 text-sm text-[#60718f]">{description}</p> : null}
</div>
{action ? <div>{action}</div> : null}
</section>
<main className={cn("mx-auto w-full px-4 py-8 sm:px-6 lg:px-8", containerClassName)}>
{showPageHeading ? (
<section className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-3xl font-semibold text-[#142447] [font-family:var(--font-display)] md:text-4xl">{title}</h1>
{description ? <p className="mt-1 text-sm text-[#60718f]">{description}</p> : null}
</div>
{action ? <div>{action}</div> : null}
</section>
) : null}
<section className={cn("space-y-4", className)}>{children}</section>
</main>

View File

@@ -0,0 +1,779 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { AUDIT_QUESTIONNAIRE } from "@/lib/audits/scoring";
import type {
AuditKpiSnapshot,
AuditSimulationView,
InstitutionalDossierFreshness,
InstitutionalDossierLoadStrategy,
InstitutionalDossierPayload,
} from "@/lib/audits/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 PreventiveDossierViewProps = {
initialSimulations: AuditSimulationView[];
initialKpis: AuditKpiSnapshot;
initialDossier: InstitutionalDossierPayload | null;
initialFreshness: InstitutionalDossierFreshness | null;
};
type ApiPayload = {
ok?: boolean;
error?: string;
simulations?: AuditSimulationView[];
simulation?: AuditSimulationView;
kpis?: AuditKpiSnapshot;
dossier?: InstitutionalDossierPayload;
freshness?: InstitutionalDossierFreshness;
};
type AuditAiFindings = {
auditorLikelyFindings: {
area: string;
finding: string;
severity: "alto" | "medio" | "bajo";
}[];
missingEvidence: string[];
topRisks: string[];
remediationPlan: {
step: string;
priority: "alta" | "media" | "baja";
ownerSuggestion: string;
targetDate: string;
}[];
confidence: "low" | "medium" | "high";
};
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 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`;
}
export function PreventiveDossierView({ initialSimulations, initialKpis, initialDossier, initialFreshness }: PreventiveDossierViewProps) {
const [simulations, setSimulations] = useState(initialSimulations);
const [kpis, setKpis] = useState(initialKpis);
const [dossier, setDossier] = useState(initialDossier);
const [freshness, setFreshness] = useState<InstitutionalDossierFreshness | null>(initialFreshness);
const [selectedSimulationId, setSelectedSimulationId] = useState(initialSimulations[0]?.id ?? "");
const [newSimulation, setNewSimulation] = useState({
name: "",
auditType: "Preventiva integral",
});
const [answers, setAnswers] = useState<Record<string, string>>(() =>
Object.fromEntries(AUDIT_QUESTIONNAIRE.map((question) => [question.key, "si"])),
);
const [isBusy, setIsBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isLoadingAiFindings, setIsLoadingAiFindings] = useState(false);
const [aiFindings, setAiFindings] = useState<AuditAiFindings | null>(null);
const [aiSuggestionId, setAiSuggestionId] = useState<string | null>(null);
const continuityWarnings = useMemo(() => {
if (!dossier) {
return [];
}
const missingComponents = dossier.components
.filter((component) => component.totalItems > 0 && component.withEvidence < component.totalItems)
.map((component) => `Continuidad incompleta en ${component.label}: ${component.withEvidence}/${component.totalItems} con evidencia.`);
return missingComponents;
}, [dossier]);
const selectedSimulation = useMemo(
() => simulations.find((item) => item.id === selectedSimulationId) ?? null,
[simulations, selectedSimulationId],
);
async function reloadSimulations() {
const response = await fetch("/api/audits/simulations");
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
if (!response.ok || !payload.ok || !payload.simulations || !payload.kpis) {
throw new Error(payload.error ?? "No fue posible recargar simulaciones.");
}
setSimulations(payload.simulations);
setKpis(payload.kpis);
if (!selectedSimulationId && payload.simulations[0]?.id) {
setSelectedSimulationId(payload.simulations[0].id);
}
}
async function createSimulation() {
setIsBusy(true);
setErrorMessage(null);
try {
const response = await fetch("/api/audits/simulations", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(newSimulation),
});
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
if (!response.ok || !payload.ok || !payload.simulation) {
setErrorMessage(payload.error ?? "No fue posible crear la simulacion.");
return;
}
setNewSimulation({
name: "",
auditType: "Preventiva integral",
});
setSelectedSimulationId(payload.simulation.id);
setMessage("Simulacion creada.");
await reloadSimulations();
} catch {
setErrorMessage("No fue posible crear la simulacion.");
} finally {
setIsBusy(false);
}
}
async function scoreSimulation() {
if (!selectedSimulationId) {
setErrorMessage("Selecciona una simulacion para calificar.");
return;
}
setIsBusy(true);
setErrorMessage(null);
try {
const response = await fetch(`/api/audits/simulations/${encodeURIComponent(selectedSimulationId)}/score`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
responses: AUDIT_QUESTIONNAIRE.map((question) => ({
questionKey: question.key,
answer: answers[question.key] ?? "si",
evidenceRefs: [],
})),
}),
});
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
if (!response.ok || !payload.ok || !payload.simulation) {
setErrorMessage(payload.error ?? "No fue posible calcular la simulacion.");
return;
}
setMessage("Simulacion calificada.");
await reloadSimulations();
} catch {
setErrorMessage("No fue posible calcular la simulacion.");
} finally {
setIsBusy(false);
}
}
async function loadDossier(strategy: InstitutionalDossierLoadStrategy) {
setIsBusy(true);
setErrorMessage(null);
try {
const response = await fetch(`/api/audits/expediente?strategy=${encodeURIComponent(strategy)}`);
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
if (!response.ok || !payload.ok || !payload.dossier || !payload.freshness) {
setErrorMessage(payload.error ?? "No fue posible cargar el expediente.");
return;
}
setDossier(payload.dossier);
setFreshness(payload.freshness);
setMessage(
payload.freshness.source === "snapshot"
? "Expediente cargado desde snapshot."
: "Expediente cargado en modo vivo.",
);
} catch {
setErrorMessage("No fue posible cargar el expediente.");
} finally {
setIsBusy(false);
}
}
async function refreshDossier() {
setIsBusy(true);
setErrorMessage(null);
try {
const response = await fetch("/api/audits/expediente/refresh", {
method: "POST",
});
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
if (!response.ok || !payload.ok || !payload.dossier) {
setErrorMessage(payload.error ?? "No fue posible refrescar el expediente.");
return;
}
setDossier(payload.dossier);
setFreshness(payload.freshness ?? null);
setMessage("Snapshot del expediente institucional actualizado.");
await reloadSimulations();
} catch {
setErrorMessage("No fue posible refrescar el expediente.");
} finally {
setIsBusy(false);
}
}
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 generateAiFindings() {
setIsLoadingAiFindings(true);
setErrorMessage(null);
try {
const response = await fetch("/api/audits/ai/findings", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
simulationId: selectedSimulationId || undefined,
}),
});
const payload = (await response.json().catch(() => ({}))) as {
ok?: boolean;
error?: string;
auditorLikelyFindings?: AuditAiFindings["auditorLikelyFindings"];
missingEvidence?: AuditAiFindings["missingEvidence"];
topRisks?: AuditAiFindings["topRisks"];
remediationPlan?: AuditAiFindings["remediationPlan"];
confidence?: AuditAiFindings["confidence"];
suggestionId?: string;
};
if (!response.ok || !payload.ok) {
setErrorMessage(payload.error ?? "No fue posible generar dictamen IA.");
return;
}
setAiFindings({
auditorLikelyFindings: payload.auditorLikelyFindings ?? [],
missingEvidence: payload.missingEvidence ?? [],
topRisks: payload.topRisks ?? [],
remediationPlan: payload.remediationPlan ?? [],
confidence: payload.confidence ?? "low",
});
setAiSuggestionId(payload.suggestionId ?? null);
setMessage("Dictamen IA generado.");
} catch {
setErrorMessage("No fue posible generar dictamen IA.");
} finally {
setIsLoadingAiFindings(false);
}
}
return (
<div className="space-y-4">
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Auditorias completadas</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.completedAudits}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Ultima calificacion</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.lastScore ?? 0}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">Contratos registrados</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.registeredContracts}</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#60718f]">PDFs resguardados</p>
<p className="mt-2 text-4xl font-semibold text-[#15294d]">{kpis.securedPdfs}</p>
</CardContent>
</Card>
</section>
{errorMessage ? <p className="rounded-xl border border-[#efc4c4] bg-[#fff1f1] px-3 py-2 text-sm text-[#ad3f3f]">{errorMessage}</p> : null}
{message ? <p className="rounded-xl border border-[#bde5ce] bg-[#ecf9f1] px-3 py-2 text-sm text-[#1f7f4f]">{message}</p> : null}
<Tabs
defaultTab="simulator"
items={[
{
id: "simulator",
label: "Simulador",
content: (
<div className="space-y-3">
<Dialog triggerLabel="Nueva Simulacion" title="Nueva Simulacion" description="Configura simulacion de auditoria preventiva">
<div className="space-y-2">
<input
value={newSimulation.name}
onChange={(event) => setNewSimulation((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Nombre"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
/>
<input
value={newSimulation.auditType}
onChange={(event) => setNewSimulation((prev) => ({ ...prev, auditType: event.target.value }))}
placeholder="Tipo de auditoria"
className="h-10 w-full rounded-lg border border-[#cfd8e7] px-3"
/>
<Button onClick={createSimulation} disabled={isBusy}>
Guardar simulacion
</Button>
</div>
</Dialog>
<select
value={selectedSimulationId}
onChange={(event) => setSelectedSimulationId(event.target.value)}
className="h-10 w-full rounded-lg border border-[#cfd8e7] bg-white px-3 text-sm"
>
<option value="">Selecciona simulacion</option>
{simulations.map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.status})
</option>
))}
</select>
<Card>
<CardContent className="space-y-2 py-5">
<p className="text-sm font-semibold text-[#1d335a]">Cuestionario de scoring</p>
{AUDIT_QUESTIONNAIRE.map((question) => (
<label key={question.key} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-[#dbe3f1] px-3 py-2 text-sm">
<span className="text-[#2e466e]">{question.prompt}</span>
<select
value={answers[question.key] ?? "si"}
onChange={(event) =>
setAnswers((prev) => ({
...prev,
[question.key]: event.target.value,
}))
}
className="h-8 rounded-lg border border-[#cfd8e7] bg-white px-2 text-xs"
>
<option value="si">Si</option>
<option value="parcial">Parcial</option>
<option value="no">No</option>
</select>
</label>
))}
<Button onClick={scoreSimulation} disabled={isBusy || !selectedSimulationId}>
Calificar simulacion
</Button>
</CardContent>
</Card>
{selectedSimulation ? (
<Card>
<CardContent className="space-y-2 py-5">
<p className="text-sm font-semibold text-[#1d335a]">Resultado actual</p>
<p className="text-sm text-[#60718f]">Status: {selectedSimulation.status}</p>
<p className="text-sm text-[#60718f]">Score: {selectedSimulation.overallScore ?? 0}</p>
<p className="text-sm text-[#60718f]">Completada: {formatDate(selectedSimulation.completedAt)}</p>
{selectedSimulation.sections.map((section) => (
<article key={section.id} className="rounded-lg border border-[#dbe3f1] bg-white p-2 text-xs text-[#506180]">
<p className="font-semibold">{section.key}</p>
<p>Status: {section.status}</p>
<p>Score: {section.score ?? 0}</p>
</article>
))}
</CardContent>
</Card>
) : null}
</div>
),
},
{
id: "dossier",
label: "Expediente",
content: (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => void loadDossier("live")} disabled={isBusy}>
Cargar modo vivo (default)
</Button>
<Button variant="secondary" onClick={() => void loadDossier("snapshot")} disabled={isBusy}>
Cargar ultimo snapshot
</Button>
<Button onClick={refreshDossier} disabled={isBusy}>
Generar nuevo snapshot
</Button>
</div>
{freshness ? (
<Card>
<CardContent className="space-y-2 py-5 text-sm text-[#385073]">
<p>
Estrategia solicitada: <strong>{freshness.strategy}</strong> | Fuente actual: <strong>{freshness.source}</strong>
</p>
<p>Generado: {formatDate(freshness.generatedAt)}</p>
<p>
Estado de frescura:{" "}
<span className={freshness.isStale ? "font-semibold text-[#ad3f3f]" : "font-semibold text-[#1f7f4f]"}>
{freshness.isStale ? "Desactualizado" : "Vigente"}
</span>{" "}
(umbral: {freshness.staleAfterMinutes} min)
</p>
{freshness.snapshotId ? <p>Snapshot ID: {freshness.snapshotId}</p> : null}
</CardContent>
</Card>
) : null}
{continuityWarnings.length > 0 ? (
<Card>
<CardContent className="space-y-2 py-5">
<p className="text-sm font-semibold text-[#8d6308]">Advertencias de continuidad</p>
{continuityWarnings.map((warning) => (
<p key={warning} className="rounded-lg border border-[#f0deb0] bg-[#fff8e9] px-2 py-1 text-xs text-[#8d6308]">
{warning}
</p>
))}
</CardContent>
</Card>
) : null}
{!dossier ? <p className="text-sm text-[#60718f]">Sin expediente generado todavia.</p> : null}
{dossier ? (
<>
<Card>
<CardContent className="grid gap-2 py-5 text-sm text-[#385073] md:grid-cols-3 xl:grid-cols-4">
<p>Contratos: {dossier.summary.contracts}</p>
<p>PDF contratos: {dossier.summary.contractDocuments}</p>
<p>Propuestas: {dossier.summary.proposals}</p>
<p>PDF propuestas: {dossier.summary.proposalDocuments}</p>
<p>Casos legales: {dossier.summary.legalCases}</p>
<p>Escritos legales: {dossier.summary.legalDocuments}</p>
<p>Alertas alto riesgo: {dossier.summary.highRiskAlerts}</p>
</CardContent>
</Card>
<div className="grid gap-3 md:grid-cols-2">
{dossier.components.map((component) => (
<article key={component.key} className="rounded-xl border border-[#d8e1ef] bg-white p-3 text-sm">
<p className="font-semibold text-[#1d335a]">{component.label}</p>
<p className="mt-1 text-[#60718f]">Estado: {component.status}</p>
<p className="mt-1 text-[#60718f]">
Evidencia: {component.withEvidence}/{component.totalItems}
</p>
</article>
))}
</div>
<Card>
<CardContent className="space-y-2 py-5">
<p className="text-sm font-semibold text-[#1d335a]">Propuestas en expediente (M5)</p>
{dossier.proposals.length === 0 ? <p className="text-sm text-[#60718f]">Sin propuestas.</p> : null}
{dossier.proposals.map((item) => (
<article key={item.id} className="rounded-lg border border-[#dbe3f1] bg-white p-2 text-xs text-[#506180]">
<p className="font-semibold">{item.title}</p>
<p>
{item.status} | Documentos {item.documents}
</p>
<div className="mt-1 flex flex-wrap gap-2">
{item.links.map((link) => (
<Link key={`${item.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
{link.label}
</Link>
))}
</div>
{item.documentEntries.length > 0 ? (
<details className="mt-2 rounded-md border border-[#e4ebf6] p-2">
<summary className="cursor-pointer font-semibold">Ver documentos y trazabilidad ({item.documentEntries.length})</summary>
<div className="mt-2 space-y-2">
{item.documentEntries.map((document) => (
<article key={document.id} className="rounded-md border border-[#edf2fa] bg-[#f9fbff] p-2">
<p className="font-semibold">{document.fileName}</p>
<p className="mt-1 inline-flex rounded-full border border-[#c9d9f0] bg-white px-2 py-0.5 text-[11px] font-semibold text-[#315b96]">
Por que este archivo
</p>
<p>
{document.mimeType} | {formatFileSize(document.sizeBytes)} | {formatDate(document.createdAt)}
</p>
<p className="mt-1">{document.traceLabel}</p>
<div className="mt-1 flex flex-wrap gap-2">
{document.links.map((link) => (
<Link key={`${document.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
{link.label}
</Link>
))}
</div>
</article>
))}
</div>
</details>
) : null}
</article>
))}
</CardContent>
</Card>
<Card>
<CardContent className="space-y-2 py-5">
<p className="text-sm font-semibold text-[#1d335a]">Contratos en expediente (M8)</p>
{dossier.contracts.length === 0 ? <p className="text-sm text-[#60718f]">Sin contratos.</p> : null}
{dossier.contracts.map((item) => (
<article key={item.id} className="rounded-lg border border-[#dbe3f1] bg-white p-2 text-xs text-[#506180]">
<p className="font-semibold">{item.title}</p>
<p>
{item.status} | Entregables {item.deliverables} | Pagos {item.payments} | Documentos {item.documents}
</p>
<div className="mt-1 flex flex-wrap gap-2">
{item.links.map((link) => (
<Link key={`${item.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
{link.label}
</Link>
))}
</div>
{item.documentEntries.length > 0 ? (
<details className="mt-2 rounded-md border border-[#e4ebf6] p-2">
<summary className="cursor-pointer font-semibold">Ver documentos y trazabilidad ({item.documentEntries.length})</summary>
<div className="mt-2 space-y-2">
{item.documentEntries.map((document) => (
<article key={document.id} className="rounded-md border border-[#edf2fa] bg-[#f9fbff] p-2">
<p className="font-semibold">{document.fileName}</p>
<p className="mt-1 inline-flex rounded-full border border-[#c9d9f0] bg-white px-2 py-0.5 text-[11px] font-semibold text-[#315b96]">
Por que este archivo
</p>
<p>
{document.kind ? `${document.kind} | ` : ""}
{document.mimeType} | {formatFileSize(document.sizeBytes)} | {formatDate(document.createdAt)}
</p>
<p className="mt-1">{document.traceLabel}</p>
<div className="mt-1 flex flex-wrap gap-2">
{document.links.map((link) => (
<Link key={`${document.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
{link.label}
</Link>
))}
</div>
</article>
))}
</div>
</details>
) : null}
</article>
))}
</CardContent>
</Card>
<Card>
<CardContent className="space-y-2 py-5">
<p className="text-sm font-semibold text-[#1d335a]">Casos legales en expediente (M9)</p>
{dossier.legal.length === 0 ? <p className="text-sm text-[#60718f]">Sin casos legales.</p> : null}
{dossier.legal.map((item) => (
<article key={item.id} className="rounded-lg border border-[#dbe3f1] bg-white p-2 text-xs text-[#506180]">
<p className="font-semibold">
{item.caseType} | {item.severity}
</p>
<p>
{item.status} | Escritos {item.documents}
</p>
<div className="mt-1 flex flex-wrap gap-2">
{item.links.map((link) => (
<Link key={`${item.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
{link.label}
</Link>
))}
</div>
{item.documentEntries.length > 0 ? (
<details className="mt-2 rounded-md border border-[#e4ebf6] p-2">
<summary className="cursor-pointer font-semibold">Ver documentos y trazabilidad ({item.documentEntries.length})</summary>
<div className="mt-2 space-y-2">
{item.documentEntries.map((document) => (
<article key={document.id} className="rounded-md border border-[#edf2fa] bg-[#f9fbff] p-2">
<p className="font-semibold">{document.fileName}</p>
<p className="mt-1 inline-flex rounded-full border border-[#c9d9f0] bg-white px-2 py-0.5 text-[11px] font-semibold text-[#315b96]">
Por que este archivo
</p>
<p>
{document.kind ? `${document.kind} | ` : ""}
{document.mimeType} | {formatFileSize(document.sizeBytes)} | {formatDate(document.createdAt)}
</p>
<p className="mt-1">{document.traceLabel}</p>
<div className="mt-1 flex flex-wrap gap-2">
{document.links.map((link) => (
<Link key={`${document.id}-${link.href}`} href={link.href} className="text-[#2b5fab] underline">
{link.label}
</Link>
))}
</div>
</article>
))}
</div>
</details>
) : null}
</article>
))}
</CardContent>
</Card>
</>
) : null}
</div>
),
},
{
id: "dictamen-ia",
label: "Dictamen IA",
content: (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" disabled={isLoadingAiFindings} onClick={() => void generateAiFindings()}>
{isLoadingAiFindings ? "Generando dictamen IA..." : "Generar dictamen IA"}
</Button>
{aiFindings ? (
<Button
type="button"
variant="secondary"
onClick={() => {
void setSuggestionDecision("dismiss");
setAiFindings(null);
setMessage("Dictamen IA descartado.");
}}
>
Descartar dictamen IA
</Button>
) : null}
</div>
{!aiFindings ? <p className="text-sm text-[#60718f]">Genera dictamen IA para simular observaciones de auditor.</p> : null}
{aiFindings ? (
<>
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<p className="text-sm font-semibold text-[#1d335a]">Hallazgos probables del auditor</p>
<ul className="mt-2 space-y-2 text-xs text-[#60718f]">
{aiFindings.auditorLikelyFindings.map((item) => (
<li key={`${item.area}-${item.finding}`} className="rounded-lg border border-[#e4ebf6] px-2 py-1">
<p className="font-semibold text-[#233b62]">
{item.area} · {item.severity}
</p>
<p>{item.finding}</p>
</li>
))}
</ul>
</article>
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<p className="text-sm font-semibold text-[#1d335a]">Evidencia faltante</p>
<ul className="mt-2 space-y-1 text-xs text-[#60718f]">
{aiFindings.missingEvidence.map((item) => (
<li key={item}>- {item}</li>
))}
</ul>
</article>
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<p className="text-sm font-semibold text-[#1d335a]">Top riesgos</p>
<ul className="mt-2 space-y-1 text-xs text-[#60718f]">
{aiFindings.topRisks.map((item) => (
<li key={item}>- {item}</li>
))}
</ul>
</article>
<article className="rounded-xl border border-[#d8e1ef] bg-white p-3">
<p className="text-sm font-semibold text-[#1d335a]">Plan de remediacion</p>
<ul className="mt-2 space-y-2 text-xs text-[#60718f]">
{aiFindings.remediationPlan.map((item) => (
<li key={`${item.step}-${item.ownerSuggestion}`} className="rounded-lg border border-[#e4ebf6] px-2 py-1">
<p className="font-semibold text-[#233b62]">{item.step}</p>
<p>
Prioridad {item.priority} · Responsable sugerido: {item.ownerSuggestion} · Fecha objetivo: {item.targetDate}
</p>
</li>
))}
</ul>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => {
void setSuggestionDecision("accept");
setMessage("Dictamen IA marcado como aceptado.");
}}
>
Aceptar dictamen IA
</Button>
<p className="text-xs font-semibold text-[#405578]">Confianza IA: {aiFindings.confidence}</p>
</div>
</article>
</>
) : null}
</div>
),
},
]}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,555 @@
"use client";
import Link from "next/link";
import { useMemo, useRef, useState } from "react";
import type { ProposalView } from "@/lib/proposals/types";
import { getProposalStatusLabel } from "@/lib/proposals/status";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
type ProposalsManagementViewProps = {
initialProposals: ProposalView[];
initialSourceLicitationId?: string | null;
};
type ApiPayload = {
ok?: boolean;
error?: string;
proposal?: ProposalView;
proposals?: ProposalView[];
};
type DraftState = {
title: string;
issuingEntity: string;
summary: string;
status: ProposalView["status"];
};
const STATUS_OPTIONS: ProposalView["status"][] = ["DRAFT", "IN_PROGRESS", "SUBMITTED", "ARCHIVED"];
function statusTone(status: ProposalView["status"]) {
if (status === "SUBMITTED") {
return "border-[#bce5d1] bg-[#ebf9f1] text-[#1e8b63]";
}
if (status === "IN_PROGRESS") {
return "border-[#c6d9f4] bg-[#ebf3ff] text-[#1f4f95]";
}
if (status === "ARCHIVED") {
return "border-[#d8e0ed] bg-[#f3f6fb] text-[#566b8f]";
}
return "border-[#f0dfb8] bg-[#fff9ea] text-[#946807]";
}
function formatDate(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return "Fecha no disponible";
}
return parsed.toLocaleString("es-MX", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function ProposalsManagementView({ initialProposals, initialSourceLicitationId }: ProposalsManagementViewProps) {
const [proposals, setProposals] = useState<ProposalView[]>(initialProposals);
const [query, setQuery] = useState("");
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, DraftState>>(() =>
Object.fromEntries(
initialProposals.map((proposal) => [
proposal.id,
{
title: proposal.title,
issuingEntity: proposal.issuingEntity,
summary: proposal.summary,
status: proposal.status,
},
]),
),
);
const [newProposal, setNewProposal] = useState({
title: "",
issuingEntity: "",
summary: "",
sourceLicitationId: initialSourceLicitationId ?? "",
});
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const filteredProposals = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) {
return proposals;
}
return proposals.filter((proposal) => {
const composed = `${proposal.title} ${proposal.issuingEntity} ${proposal.summary}`.toLowerCase();
return composed.includes(normalized);
});
}, [proposals, query]);
function setDraftField(id: string, field: keyof DraftState, value: string) {
setDrafts((previous) => ({
...previous,
[id]: {
...(previous[id] ?? {
title: "",
issuingEntity: "",
summary: "",
status: "DRAFT",
}),
[field]: value,
},
}));
}
async function reloadProposals(nextQuery = "") {
const response = await fetch(`/api/proposals?q=${encodeURIComponent(nextQuery)}`);
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
if (!response.ok || !payload.ok || !payload.proposals) {
throw new Error(payload.error ?? "No fue posible obtener propuestas.");
}
setProposals(payload.proposals);
setDrafts(
Object.fromEntries(
payload.proposals.map((proposal) => [
proposal.id,
{
title: proposal.title,
issuingEntity: proposal.issuingEntity,
summary: proposal.summary,
status: proposal.status,
},
]),
),
);
}
async function createProposal() {
setErrorMessage(null);
setMessage(null);
setIsSubmitting(true);
try {
const response = await fetch("/api/proposals", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(newProposal),
});
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
if (!response.ok || !payload.ok || !payload.proposal) {
setErrorMessage(payload.error ?? "No fue posible crear la propuesta.");
return;
}
const createdProposal = payload.proposal as ProposalView;
setProposals((previous) => [createdProposal, ...previous]);
setDrafts((previous) => ({
...previous,
[createdProposal.id]: {
title: createdProposal.title,
issuingEntity: createdProposal.issuingEntity,
summary: createdProposal.summary,
status: createdProposal.status,
},
}));
setNewProposal({
title: "",
issuingEntity: "",
summary: "",
sourceLicitationId: initialSourceLicitationId ?? "",
});
setIsCreateOpen(false);
setMessage("Propuesta creada correctamente.");
} catch {
setErrorMessage("No fue posible crear la propuesta.");
} finally {
setIsSubmitting(false);
}
}
async function saveProposal(id: string) {
const draft = drafts[id];
if (!draft) {
return;
}
setErrorMessage(null);
setMessage(null);
try {
const response = await fetch(`/api/proposals/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(draft),
});
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
if (!response.ok || !payload.ok || !payload.proposal) {
setErrorMessage(payload.error ?? "No fue posible guardar cambios.");
return;
}
const updatedProposal = payload.proposal as ProposalView;
setProposals((previous) => previous.map((proposal) => (proposal.id === id ? updatedProposal : proposal)));
setMessage("Cambios guardados.");
} catch {
setErrorMessage("No fue posible guardar cambios.");
}
}
async function deleteProposal(id: string) {
setErrorMessage(null);
setMessage(null);
try {
const response = await fetch(`/api/proposals/${id}`, {
method: "DELETE",
});
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
if (!response.ok || !payload.ok) {
setErrorMessage(payload.error ?? "No fue posible eliminar la propuesta.");
return;
}
setProposals((previous) => previous.filter((proposal) => proposal.id !== id));
setDrafts((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
setMessage("Propuesta eliminada.");
} catch {
setErrorMessage("No fue posible eliminar la propuesta.");
}
}
async function uploadDocument(id: string, file: File) {
setErrorMessage(null);
setMessage(null);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`/api/proposals/${id}/documents`, {
method: "POST",
body: formData,
});
const payload = (await response.json().catch(() => ({}))) as ApiPayload;
if (!response.ok || !payload.ok || !payload.proposal) {
setErrorMessage(payload.error ?? "No fue posible subir el documento.");
return;
}
const updatedProposal = payload.proposal as ProposalView;
setProposals((previous) => previous.map((proposal) => (proposal.id === id ? updatedProposal : proposal)));
setDrafts((previous) => ({
...previous,
[id]: {
...(previous[id] ?? {
title: updatedProposal.title,
issuingEntity: updatedProposal.issuingEntity,
summary: updatedProposal.summary,
status: updatedProposal.status,
}),
status: updatedProposal.status,
},
}));
setMessage("Documento subido correctamente.");
} catch {
setErrorMessage("No fue posible subir el documento.");
}
}
async function deleteDocument(proposalId: string, documentId: string) {
setErrorMessage(null);
setMessage(null);
try {
const response = await fetch(`/api/proposals/${proposalId}/documents/${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;
}
await reloadProposals(query);
setMessage("Documento eliminado.");
} catch {
setErrorMessage("No fue posible eliminar el documento.");
}
}
return (
<div className="space-y-4">
<section className="flex flex-wrap items-center justify-between gap-3">
<label className="w-full max-w-2xl space-y-1 text-sm font-semibold text-[#314764]">
Buscar por titulo o entidad
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Buscar por titulo o entidad..."
className="h-12 w-full rounded-xl border border-[#cfd8e6] bg-white px-4 text-base"
/>
</label>
<Button size="lg" className="rounded-2xl px-6" onClick={() => setIsCreateOpen(true)}>
+ Nueva Propuesta
</Button>
</section>
{errorMessage ? <p className="rounded-xl border border-[#efc4c4] bg-[#fff1f1] px-3 py-2 text-sm text-[#ad3f3f]">{errorMessage}</p> : null}
{message ? <p className="rounded-xl border border-[#bde5ce] bg-[#ecf9f1] px-3 py-2 text-sm text-[#1f7f4f]">{message}</p> : null}
{filteredProposals.length === 0 ? (
<Card className="border-[#cfd8e7]">
<CardContent className="py-16 text-center">
<p className="text-3xl font-semibold text-[#162748] [font-family:var(--font-display)] md:text-4xl">No hay propuestas</p>
<p className="mx-auto mt-2 max-w-3xl text-base text-[#5f7090] md:text-lg">Comienza creando tu primera propuesta de licitacion. El asistente te guiara paso a paso.</p>
<Button className="mt-6 rounded-2xl px-8" size="lg" onClick={() => setIsCreateOpen(true)}>
+ Crear Primera Propuesta
</Button>
</CardContent>
</Card>
) : (
<section className="grid gap-4 xl:grid-cols-2">
{filteredProposals.map((proposal) => {
const draft = drafts[proposal.id] ?? {
title: proposal.title,
issuingEntity: proposal.issuingEntity,
summary: proposal.summary,
status: proposal.status,
};
return (
<article key={proposal.id} className="rounded-2xl border border-[#d7dfed] bg-white p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#5d7091]">Actualizada {formatDate(proposal.updatedAt)}</p>
<h3 className="mt-1 text-2xl font-semibold text-[#14264a]">{proposal.title}</h3>
<p className="mt-1 text-xs font-semibold text-[#60718f]">
Paso {proposal.currentStep}/6 · {proposal.completionPercent}% completado
</p>
{proposal.readyForSubmissionAt ? (
<p className="mt-1 text-xs font-semibold text-[#1f8b63]">Lista para envio desde {formatDate(proposal.readyForSubmissionAt)}</p>
) : null}
</div>
<span className={`inline-flex rounded-full border px-3 py-1 text-sm font-semibold ${statusTone(draft.status)}`}>
{getProposalStatusLabel(draft.status)}
</span>
</div>
<div className="mt-4 grid gap-3">
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Titulo
<input
value={draft.title}
onChange={(event) => setDraftField(proposal.id, "title", event.target.value)}
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
/>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Entidad emisora
<input
value={draft.issuingEntity}
onChange={(event) => setDraftField(proposal.id, "issuingEntity", event.target.value)}
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
/>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Resumen
<textarea
value={draft.summary}
onChange={(event) => setDraftField(proposal.id, "summary", event.target.value)}
rows={3}
className="w-full rounded-lg border border-[#cfd8e6] px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Estatus
<select
value={draft.status}
onChange={(event) => setDraftField(proposal.id, "status", event.target.value)}
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
>
{STATUS_OPTIONS.map((statusOption) => (
<option key={statusOption} value={statusOption}>
{getProposalStatusLabel(statusOption)}
</option>
))}
</select>
</label>
</div>
<div className="mt-4 rounded-xl border border-[#d9e1ee] bg-[#f8fbff] p-3">
<div
className="flex min-h-[110px] cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-[#ccd7ea] bg-white text-center"
onDragOver={(event) => {
event.preventDefault();
}}
onDrop={(event) => {
event.preventDefault();
const dropped = event.dataTransfer.files?.[0];
if (dropped) {
void uploadDocument(proposal.id, dropped);
}
}}
onClick={() => inputRefs.current[proposal.id]?.click()}
>
<p className="text-sm font-semibold text-[#1f3051]">Arrastra un documento aqui</p>
<p className="text-xs text-[#627391]">o haz clic para seleccionar (PDF, DOC, DOCX, XLSX, JPG, PNG)</p>
</div>
<input
ref={(element) => {
inputRefs.current[proposal.id] = element;
}}
type="file"
className="hidden"
accept=".pdf,.doc,.docx,.xlsx,.jpg,.jpeg,.png"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void uploadDocument(proposal.id, file);
}
event.target.value = "";
}}
/>
{proposal.documents.length > 0 ? (
<ul className="mt-3 space-y-2 text-sm text-[#4a5f84]">
{proposal.documents.map((document) => (
<li key={document.id} className="flex items-center justify-between gap-2 rounded-lg border border-[#d8e2ef] bg-white px-3 py-2">
<span>{document.fileName}</span>
<button
type="button"
className="text-xs font-semibold text-[#b23f50]"
onClick={() => {
void deleteDocument(proposal.id, document.id);
}}
>
Eliminar
</button>
</li>
))}
</ul>
) : (
<p className="mt-3 text-xs text-[#627391]">Sin documentos cargados.</p>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Link href={`/gestion-licitaciones/${encodeURIComponent(proposal.id)}?autofill=1`}>
<Button size="sm">Continuar</Button>
</Link>
<Link href={`/gestion-contratos?proposalId=${encodeURIComponent(proposal.id)}&autocreate=1`}>
<Button size="sm" variant="secondary">
Crear contrato M8
</Button>
</Link>
<Button size="sm" onClick={() => void saveProposal(proposal.id)}>
Guardar cambios
</Button>
<Button size="sm" variant="secondary" onClick={() => void deleteProposal(proposal.id)}>
Eliminar propuesta
</Button>
</div>
</article>
);
})}
</section>
)}
{isCreateOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#0c1731]/55 p-4" role="dialog" aria-modal="true">
<div className="w-full max-w-2xl rounded-2xl bg-white p-5">
<h3 className="text-3xl font-semibold text-[#14264a] [font-family:var(--font-display)]">Nueva Propuesta</h3>
<div className="mt-4 grid gap-3">
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Titulo
<input
value={newProposal.title}
onChange={(event) => setNewProposal((previous) => ({ ...previous, title: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
/>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Entidad emisora
<input
value={newProposal.issuingEntity}
onChange={(event) => setNewProposal((previous) => ({ ...previous, issuingEntity: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
/>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
Resumen
<textarea
value={newProposal.summary}
onChange={(event) => setNewProposal((previous) => ({ ...previous, summary: event.target.value }))}
rows={4}
className="w-full rounded-lg border border-[#cfd8e6] px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1 text-sm font-semibold text-[#33415c]">
ID de oportunidad origen (opcional)
<input
value={newProposal.sourceLicitationId}
onChange={(event) => setNewProposal((previous) => ({ ...previous, sourceLicitationId: event.target.value }))}
className="h-10 w-full rounded-lg border border-[#cfd8e6] px-3 text-sm"
placeholder="Se autocompleta si vienes desde Modulo 3"
/>
</label>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button variant="secondary" onClick={() => setIsCreateOpen(false)}>
Cancelar
</Button>
<Button onClick={() => void createProposal()} disabled={isSubmitting}>
{isSubmitting ? "Creando..." : "Crear propuesta"}
</Button>
</div>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -25,6 +25,34 @@ type StrategicDiagnosticWizardProps = {
type TabKey = StrategicSectionKey | "results";
type StrategicAiInsights = {
sectionGaps: {
sectionKey: StrategicSectionKey;
gap: string;
impact: string;
urgency: "alta" | "media" | "baja";
}[];
priorityActions: {
title: string;
description: string;
priority: "alta" | "media" | "baja";
ownerSuggestion: string;
targetDateSuggestion: string;
}[];
suggestedEvidence: {
sectionKey: StrategicSectionKey;
category: string;
reason: string;
}[];
suggestedFieldValues: {
sectionKey: StrategicSectionKey;
fieldPath: string;
suggestedValue: string;
rationale: string;
}[];
confidence: "low" | "medium" | "high";
};
const tabItems: { key: TabKey; label: string }[] = [
{ key: "technical", label: "Capacidades Tecnicas" },
{ key: "experience", label: "Experiencia" },
@@ -135,6 +163,40 @@ function SectionScoreBar({ title, subtitle, score }: { title: string; subtitle:
);
}
function setNestedValue(target: unknown, pathSegments: string[], value: string): unknown {
if (!pathSegments.length || !target || typeof target !== "object" || Array.isArray(target)) {
return target;
}
const [head, ...tail] = pathSegments;
const record = target as Record<string, unknown>;
if (!(head in record)) {
return target;
}
if (tail.length === 0) {
if (typeof record[head] !== "string") {
return target;
}
return {
...record,
[head]: value,
};
}
const nextValue = setNestedValue(record[head], tail, value);
if (nextValue === record[head]) {
return target;
}
return {
...record,
[head]: nextValue,
};
}
export function StrategicDiagnosticWizard({
initialData,
initialScores,
@@ -160,6 +222,10 @@ export function StrategicDiagnosticWizard({
organization: STRATEGIC_EVIDENCE_CATEGORY_OPTIONS.organization[0] ?? "Otro",
publicProcurement: STRATEGIC_EVIDENCE_CATEGORY_OPTIONS.publicProcurement[0] ?? "Otro",
});
const [aiInsights, setAiInsights] = useState<StrategicAiInsights | null>(null);
const [aiSuggestionId, setAiSuggestionId] = useState<string | null>(null);
const [isLoadingAiInsights, setIsLoadingAiInsights] = useState(false);
const [isApplyingAiField, setIsApplyingAiField] = useState<string | null>(null);
const progressPercent = useMemo(() => {
return Math.round((scores.completedSections / scores.totalSections) * 100);
@@ -185,6 +251,20 @@ export function StrategicDiagnosticWizard({
return tabItems[index + 1]?.key ?? "results";
}
async function setAiDecision(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 persistData(section: TabKey, forceCompleted = false) {
setIsSaving(true);
setSavingSection(section);
@@ -284,6 +364,103 @@ export function StrategicDiagnosticWizard({
}
}
async function generateAiInsights() {
setIsLoadingAiInsights(true);
clearFeedback();
try {
const response = await fetch("/api/strategic-diagnostic/ai/insights", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
data,
evidenceMetadata: {
technical: {
count: evidenceBySection.technical.length,
categories: Array.from(new Set(evidenceBySection.technical.map((item) => item.category))),
},
experience: {
count: evidenceBySection.experience.length,
categories: Array.from(new Set(evidenceBySection.experience.map((item) => item.category))),
},
organization: {
count: evidenceBySection.organization.length,
categories: Array.from(new Set(evidenceBySection.organization.map((item) => item.category))),
},
publicProcurement: {
count: evidenceBySection.publicProcurement.length,
categories: Array.from(new Set(evidenceBySection.publicProcurement.map((item) => item.category))),
},
},
}),
});
const payload = (await response.json().catch(() => ({}))) as {
ok?: boolean;
error?: string;
sectionGaps?: StrategicAiInsights["sectionGaps"];
priorityActions?: StrategicAiInsights["priorityActions"];
suggestedEvidence?: StrategicAiInsights["suggestedEvidence"];
suggestedFieldValues?: StrategicAiInsights["suggestedFieldValues"];
confidence?: StrategicAiInsights["confidence"];
suggestionId?: string;
};
if (!response.ok || !payload.ok) {
setErrorMessage(payload.error ?? "No fue posible generar plan IA.");
return;
}
setAiInsights({
sectionGaps: payload.sectionGaps ?? [],
priorityActions: payload.priorityActions ?? [],
suggestedEvidence: payload.suggestedEvidence ?? [],
suggestedFieldValues: payload.suggestedFieldValues ?? [],
confidence: payload.confidence ?? "low",
});
setAiSuggestionId(payload.suggestionId ?? null);
setSuccessMessage("Plan sugerido por IA generado.");
} catch {
setErrorMessage("No fue posible generar plan IA.");
} finally {
setIsLoadingAiInsights(false);
}
}
async function applySuggestedFieldValue(fieldPath: string, value: string) {
const segments = fieldPath.split(".").map((segment) => segment.trim()).filter(Boolean);
if (!segments.length) {
return;
}
setIsApplyingAiField(fieldPath);
clearFeedback();
try {
let applied = false;
setData((previous) => {
const nextValue = setNestedValue(previous, segments, value);
applied = nextValue !== previous;
return (nextValue as StrategicDiagnosticData) ?? previous;
});
if (!applied) {
setErrorMessage("Este campo no acepta aplicacion directa desde IA. Aplica manualmente.");
return;
}
await setAiDecision("accept");
setSuccessMessage(`Valor sugerido aplicado: ${fieldPath}.`);
} catch {
setErrorMessage("No fue posible aplicar el valor sugerido.");
} finally {
setIsApplyingAiField(null);
}
}
function addListItem(section: "technical" | "experience", field: "coverageRegions" | "certifications" | "sectorsServed", rawValue: string) {
const value = rawValue.trim();
@@ -1239,6 +1416,95 @@ export function StrategicDiagnosticWizard({
</CardContent>
</Card>
<Card className="border-[#cfdcf4] bg-[#f7f9ff]">
<CardHeader>
<h4 className="text-3xl font-semibold text-[#1b2a46]">Plan sugerido por IA</h4>
<p className="text-sm text-[#60718e]">Asistencia opcional. Nunca se aplican cambios automaticamente.</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" disabled={isLoadingAiInsights} onClick={() => void generateAiInsights()}>
{isLoadingAiInsights ? "Generando plan IA..." : "Generar plan IA"}
</Button>
{aiInsights ? (
<Button
type="button"
variant="secondary"
onClick={() => {
void setAiDecision("dismiss");
setAiInsights(null);
setSuccessMessage("Plan IA descartado.");
}}
>
Descartar plan IA
</Button>
) : null}
{aiInsights ? (
<p className="text-xs font-semibold text-[#405578]">Confianza IA: {aiInsights.confidence}</p>
) : null}
</div>
{!aiInsights ? <p className="text-sm text-[#60718e]">Genera un plan IA para ver brechas y acciones priorizadas.</p> : null}
{aiInsights ? (
<div className="space-y-4">
<div>
<p className="text-lg font-semibold text-[#1d335a]">Brechas por seccion</p>
<ul className="mt-2 space-y-2 text-sm text-[#4f6385]">
{aiInsights.sectionGaps.map((item) => (
<li key={`${item.sectionKey}-${item.gap}`} className="rounded-lg border border-[#d8e1ef] bg-white px-3 py-2">
<p className="font-semibold">
{item.sectionKey} · {item.urgency}
</p>
<p>{item.gap}</p>
<p className="text-xs">{item.impact}</p>
</li>
))}
</ul>
</div>
<div>
<p className="text-lg font-semibold text-[#1d335a]">Acciones prioritarias</p>
<ul className="mt-2 space-y-2 text-sm text-[#4f6385]">
{aiInsights.priorityActions.map((item) => (
<li key={`${item.title}-${item.priority}`} className="rounded-lg border border-[#d8e1ef] bg-white px-3 py-2">
<p className="font-semibold">{item.title}</p>
<p>{item.description}</p>
<p className="text-xs">
Prioridad: {item.priority} · Responsable sugerido: {item.ownerSuggestion} · Fecha objetivo: {item.targetDateSuggestion}
</p>
</li>
))}
</ul>
</div>
<div>
<p className="text-lg font-semibold text-[#1d335a]">Campos sugeridos para aplicar</p>
<ul className="mt-2 space-y-2 text-sm text-[#4f6385]">
{aiInsights.suggestedFieldValues.map((item) => (
<li key={`${item.fieldPath}-${item.suggestedValue}`} className="rounded-lg border border-[#d8e1ef] bg-white px-3 py-2">
<p className="font-semibold">{item.fieldPath}</p>
<p className="mt-1 text-xs">{item.rationale}</p>
<p className="mt-1 rounded bg-[#f5f8ff] px-2 py-1 text-xs font-semibold text-[#1d335a]">{item.suggestedValue}</p>
<div className="mt-2">
<Button
type="button"
size="sm"
disabled={isApplyingAiField === item.fieldPath}
onClick={() => void applySuggestedFieldValue(item.fieldPath, item.suggestedValue)}
>
{isApplyingAiField === item.fieldPath ? "Aplicando..." : "Aplicar valor"}
</Button>
</div>
</li>
))}
</ul>
</div>
</div>
) : null}
</CardContent>
</Card>
<Card className="border-[#9bd7bf] bg-[#f6fffb]">
<CardHeader>
<h4 className="text-3xl font-semibold text-[#1b2a46]">Modulo 2 Completado! Siguiente paso recomendado</h4>
@@ -1255,8 +1521,8 @@ export function StrategicDiagnosticWizard({
<Link href="/licitations">
<Button variant="secondary">Ir a Modulo 3</Button>
</Link>
<Link href="/manual">
<Button variant="secondary">Ir a Analisis Normativo (Modulo 4)</Button>
<Link href="/normative-analysis">
<Button variant="secondary">Ir a Modulo 4: Analisis Normativo</Button>
</Link>
</div>
</CardContent>

430
src/lib/ai/openai.ts Normal file
View File

@@ -0,0 +1,430 @@
import "server-only";
import { z } from "zod";
import type { AiConfidence, AiEnvelope, AiUsage, AiWarning } from "@/lib/ai/types";
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
const DEFAULT_SMART_MODEL = "gpt-4.1";
const DEFAULT_SMART_FALLBACK_MODEL = "gpt-4.1-mini";
const DEFAULT_TIMEOUT_MS = 75_000;
const DEFAULT_MAX_PROMPT_CHARS = 55_000;
const DEFAULT_RETRIES = 1;
type OpenAiChatCompletionResponse = {
choices?: Array<{
message?: {
content?: string | null;
};
}>;
model?: string;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
error?: {
message?: string;
};
};
type OpenAiCallBaseInput<T> = {
promptVersion: string;
systemPrompt: string;
userPrompt: string;
outputSchema: z.ZodType<T>;
model?: string | null;
fallbackModel?: string | null;
timeoutMs?: number;
maxPromptChars?: number;
retries?: number;
temperature?: number;
};
type OpenAiJsonSchemaInput<T> = OpenAiCallBaseInput<T> & {
schemaName: string;
jsonSchema: Record<string, unknown>;
};
type OpenAiJsonObjectInput<T> = OpenAiCallBaseInput<T> & {
initialWarnings?: AiWarning[];
};
type OpenAiExecutionResult<T> = {
data: T;
model: string | null;
usage: AiUsage | null;
rawResponse: Record<string, unknown> | null;
warnings: AiWarning[];
};
function parsePositiveInteger(value: string | undefined, fallback: number) {
const parsed = Number.parseInt((value ?? "").trim(), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function getOpenAiApiKey() {
return process.env.OPENAI_API_KEY?.trim() || process.env.API_KEY?.trim() || process.env.api_key?.trim() || "";
}
function getSmartModel() {
return process.env.OPENAI_SMART_MODEL?.trim() || DEFAULT_SMART_MODEL;
}
function getSmartFallbackModel() {
return process.env.OPENAI_SMART_FALLBACK_MODEL?.trim() || DEFAULT_SMART_FALLBACK_MODEL;
}
function getOpenAiBaseUrl() {
return (process.env.OPENAI_API_BASE_URL?.trim() || DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
}
function truncatePrompt(value: string, maxChars: number) {
if (value.length <= maxChars) {
return {
prompt: value,
truncated: false,
};
}
return {
prompt: `${value.slice(0, maxChars)}\n\n[TEXT_TRUNCATED_TO_${maxChars}_CHARS]`,
truncated: true,
};
}
function usageFromPayload(payload: OpenAiChatCompletionResponse): AiUsage {
return {
promptTokens: payload.usage?.prompt_tokens ?? null,
completionTokens: payload.usage?.completion_tokens ?? null,
totalTokens: payload.usage?.total_tokens ?? null,
};
}
function extractJsonObject(rawContent: string) {
const trimmed = rawContent.trim();
try {
return JSON.parse(trimmed) as unknown;
} catch {
// Continue with safe extraction.
}
const withoutFence = trimmed
.replace(/^```json\s*/i, "")
.replace(/^```\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
try {
return JSON.parse(withoutFence) as unknown;
} catch {
// Continue with bracket extraction.
}
const firstBrace = withoutFence.indexOf("{");
const lastBrace = withoutFence.lastIndexOf("}");
if (firstBrace >= 0 && lastBrace > firstBrace) {
return JSON.parse(withoutFence.slice(firstBrace, lastBrace + 1)) as unknown;
}
throw new Error("La respuesta de OpenAI no contiene un JSON valido.");
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function inferConfidence(value: unknown): AiConfidence | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const confidence = record.confidence;
if (confidence === "low" || confidence === "medium" || confidence === "high") {
return confidence;
}
if (typeof confidence === "number") {
if (confidence >= 0.8) {
return "high";
}
if (confidence >= 0.5) {
return "medium";
}
return "low";
}
return null;
}
async function executeOpenAiCall<T>(
args: {
promptVersion: string;
systemPrompt: string;
userPrompt: string;
outputSchema: z.ZodType<T>;
model: string;
responseFormat: Record<string, unknown>;
timeoutMs: number;
retries: number;
temperature: number;
},
): Promise<OpenAiExecutionResult<T>> {
const apiKey = getOpenAiApiKey();
if (!apiKey) {
throw new Error("No se encontro API key para OpenAI (OPENAI_API_KEY o api_key).");
}
const warnings: AiWarning[] = [];
const baseUrl = getOpenAiBaseUrl();
const requestBody = {
model: args.model,
temperature: args.temperature,
response_format: args.responseFormat,
messages: [
{ role: "system", content: args.systemPrompt },
{ role: "user", content: args.userPrompt },
],
};
let lastError: unknown = null;
for (let attempt = 0; attempt <= args.retries; attempt += 1) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), args.timeoutMs);
try {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
signal: controller.signal,
});
const payload = (await response.json().catch(() => ({}))) as OpenAiChatCompletionResponse;
if (!response.ok) {
const apiMessage = payload.error?.message ? ` ${payload.error.message}` : "";
const retryable = response.status >= 500 || response.status === 429;
if (retryable && attempt < args.retries) {
warnings.push({
code: "retrying_openai_request",
message: `OpenAI devolvio ${response.status}. Reintentando (${attempt + 1}/${args.retries + 1}).`,
});
continue;
}
throw new Error(`OpenAI devolvio ${response.status}.${apiMessage}`);
}
const rawContent = payload.choices?.[0]?.message?.content;
if (!rawContent || typeof rawContent !== "string") {
throw new Error("OpenAI no devolvio contenido util.");
}
const parsedJson = extractJsonObject(rawContent);
const parsed = args.outputSchema.safeParse(parsedJson);
if (!parsed.success) {
throw new Error(`La salida de OpenAI no cumple el esquema esperado: ${parsed.error.issues[0]?.message ?? "sin detalle"}`);
}
return {
data: parsed.data,
model: payload.model ?? args.model,
usage: usageFromPayload(payload),
rawResponse: payload as unknown as Record<string, unknown>,
warnings,
};
} catch (error) {
lastError = error;
if (attempt < args.retries) {
warnings.push({
code: "retrying_openai_request",
message: `${getErrorMessage(error)} Reintentando (${attempt + 1}/${args.retries + 1}).`,
});
continue;
}
} finally {
clearTimeout(timer);
}
}
throw new Error(getErrorMessage(lastError));
}
export async function callOpenAiJsonObjectFallback<T>(input: OpenAiJsonObjectInput<T>): Promise<AiEnvelope<T>> {
const model = input.model?.trim() || input.fallbackModel?.trim() || getSmartFallbackModel();
const timeoutMs = input.timeoutMs ?? parsePositiveInteger(process.env.OPENAI_SMART_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
const maxPromptChars = input.maxPromptChars ?? parsePositiveInteger(process.env.OPENAI_SMART_MAX_CHARS, DEFAULT_MAX_PROMPT_CHARS);
const retries = input.retries ?? DEFAULT_RETRIES;
const temperature = typeof input.temperature === "number" ? input.temperature : 0;
const { prompt, truncated } = truncatePrompt(input.userPrompt, maxPromptChars);
const warnings: AiWarning[] = [...(input.initialWarnings ?? [])];
if (truncated) {
warnings.push({
code: "prompt_truncated",
message: `El prompt fue truncado a ${maxPromptChars} caracteres para controlar costo y latencia.`,
});
}
const requestJson: Record<string, unknown> = {
model,
temperature,
response_format: {
type: "json_object",
},
messages: [
{ role: "system", content: input.systemPrompt },
{ role: "user", content: prompt },
],
};
try {
const result = await executeOpenAiCall({
promptVersion: input.promptVersion,
systemPrompt: input.systemPrompt,
userPrompt: prompt,
outputSchema: input.outputSchema,
model,
responseFormat: {
type: "json_object",
},
timeoutMs,
retries,
temperature,
});
return {
data: result.data,
engine: "openai_json_object",
model: result.model,
usage: result.usage,
warnings: [...warnings, ...result.warnings],
confidence: inferConfidence(result.data),
promptVersion: input.promptVersion,
truncated,
requestJson,
responseJson: result.rawResponse,
};
} catch (error) {
warnings.push({
code: "openai_json_object_failed",
message: getErrorMessage(error),
});
return {
data: null,
engine: "empty_assist",
model: null,
usage: null,
warnings,
confidence: null,
promptVersion: input.promptVersion,
truncated,
requestJson,
responseJson: null,
};
}
}
export async function callOpenAiJsonSchema<T>(input: OpenAiJsonSchemaInput<T>): Promise<AiEnvelope<T>> {
const model = input.model?.trim() || getSmartModel();
const timeoutMs = input.timeoutMs ?? parsePositiveInteger(process.env.OPENAI_SMART_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
const maxPromptChars = input.maxPromptChars ?? parsePositiveInteger(process.env.OPENAI_SMART_MAX_CHARS, DEFAULT_MAX_PROMPT_CHARS);
const retries = input.retries ?? DEFAULT_RETRIES;
const temperature = typeof input.temperature === "number" ? input.temperature : 0;
const { prompt, truncated } = truncatePrompt(input.userPrompt, maxPromptChars);
const warnings: AiWarning[] = [];
if (truncated) {
warnings.push({
code: "prompt_truncated",
message: `El prompt fue truncado a ${maxPromptChars} caracteres para controlar costo y latencia.`,
});
}
const requestJson: Record<string, unknown> = {
model,
temperature,
response_format: {
type: "json_schema",
json_schema: {
name: input.schemaName,
strict: true,
schema: input.jsonSchema,
},
},
messages: [
{ role: "system", content: input.systemPrompt },
{ role: "user", content: prompt },
],
};
try {
const result = await executeOpenAiCall({
promptVersion: input.promptVersion,
systemPrompt: input.systemPrompt,
userPrompt: prompt,
outputSchema: input.outputSchema,
model,
responseFormat: {
type: "json_schema",
json_schema: {
name: input.schemaName,
strict: true,
schema: input.jsonSchema,
},
},
timeoutMs,
retries,
temperature,
});
return {
data: result.data,
engine: "openai_json_schema",
model: result.model,
usage: result.usage,
warnings: [...warnings, ...result.warnings],
confidence: inferConfidence(result.data),
promptVersion: input.promptVersion,
truncated,
requestJson,
responseJson: result.rawResponse,
};
} catch (error) {
warnings.push({
code: "openai_json_schema_failed",
message: getErrorMessage(error),
});
return callOpenAiJsonObjectFallback({
...input,
userPrompt: prompt,
initialWarnings: warnings,
model: input.fallbackModel?.trim() || getSmartFallbackModel(),
timeoutMs,
maxPromptChars,
retries,
temperature,
});
}
}

204
src/lib/ai/suggestions.ts Normal file
View File

@@ -0,0 +1,204 @@
import "server-only";
import { createHash } from "crypto";
import { AiSuggestionStatus, type Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import type { AiConfidence, AiDecision, AiEnvelope, AiSuggestionStatus as AiSuggestionStatusView } from "@/lib/ai/types";
type CreateAiSuggestionInput = {
userId: string;
moduleKey: string;
featureKey: string;
subjectType: string;
subjectId: string;
inputForHash: unknown;
requestJson: unknown;
responseJson: unknown;
confidence: AiConfidence | number | null;
engine: string;
model: string | null;
usageJson: unknown;
warningsJson: unknown;
promptVersion: string;
};
function stableStringify(value: unknown): string {
if (value === null || value === undefined) {
return "null";
}
if (typeof value === "string") {
return JSON.stringify(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
}
if (typeof value === "object") {
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
}
return JSON.stringify(String(value));
}
function normalizeConfidence(value: AiConfidence | number | null): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
if (value >= 0 && value <= 1) {
return value;
}
if (value >= 0 && value <= 100) {
return value / 100;
}
return null;
}
if (value === "high") {
return 0.9;
}
if (value === "medium") {
return 0.65;
}
if (value === "low") {
return 0.35;
}
return null;
}
function toJsonValue(value: unknown): Prisma.InputJsonValue {
return (value ?? null) as Prisma.InputJsonValue;
}
export function createAiInputHash(input: unknown): string {
return createHash("sha256").update(stableStringify(input)).digest("hex");
}
export async function storeAiSuggestion(input: CreateAiSuggestionInput) {
const inputHash = createAiInputHash(input.inputForHash);
const existing = await prisma.aiSuggestion.findFirst({
where: {
userId: input.userId,
moduleKey: input.moduleKey,
featureKey: input.featureKey,
subjectType: input.subjectType,
subjectId: input.subjectId,
inputHash,
},
});
if (existing) {
return {
suggestionId: existing.id,
inputHash,
status: existing.status as AiSuggestionStatusView,
reused: true,
};
}
const created = await prisma.aiSuggestion.create({
data: {
userId: input.userId,
moduleKey: input.moduleKey,
featureKey: input.featureKey,
subjectType: input.subjectType,
subjectId: input.subjectId,
inputHash,
requestJson: toJsonValue(input.requestJson),
responseJson: toJsonValue(input.responseJson),
confidence: normalizeConfidence(input.confidence),
engine: input.engine,
model: input.model,
usageJson: toJsonValue(input.usageJson),
warningsJson: toJsonValue(input.warningsJson),
promptVersion: input.promptVersion,
status: AiSuggestionStatus.GENERATED,
},
select: {
id: true,
status: true,
},
});
return {
suggestionId: created.id,
inputHash,
status: created.status as AiSuggestionStatusView,
reused: false,
};
}
export async function storeAiSuggestionFromEnvelope<T>(
input: Omit<CreateAiSuggestionInput, "requestJson" | "responseJson" | "confidence" | "engine" | "model" | "usageJson" | "warningsJson" | "promptVersion"> & {
envelope: AiEnvelope<T>;
responsePayload: unknown;
},
) {
return storeAiSuggestion({
userId: input.userId,
moduleKey: input.moduleKey,
featureKey: input.featureKey,
subjectType: input.subjectType,
subjectId: input.subjectId,
inputForHash: input.inputForHash,
requestJson: input.envelope.requestJson,
responseJson: input.responsePayload,
confidence: input.envelope.confidence,
engine: input.envelope.engine,
model: input.envelope.model,
usageJson: input.envelope.usage,
warningsJson: input.envelope.warnings,
promptVersion: input.envelope.promptVersion,
});
}
export async function applyAiSuggestionDecision(userId: string, suggestionId: string, decision: AiDecision) {
const suggestion = await prisma.aiSuggestion.findFirst({
where: {
id: suggestionId,
userId,
},
select: {
id: true,
status: true,
},
});
if (!suggestion) {
return null;
}
const status: AiSuggestionStatus = decision === "accept" ? AiSuggestionStatus.ACCEPTED : AiSuggestionStatus.DISMISSED;
const updated = await prisma.aiSuggestion.update({
where: { id: suggestionId },
data: {
status,
actedAt: new Date(),
},
select: {
id: true,
status: true,
actedAt: true,
updatedAt: true,
},
});
return {
id: updated.id,
status: updated.status as AiSuggestionStatusView,
actedAt: updated.actedAt?.toISOString() ?? null,
updatedAt: updated.updatedAt.toISOString(),
};
}

31
src/lib/ai/types.ts Normal file
View File

@@ -0,0 +1,31 @@
export type AiUsage = {
promptTokens: number | null;
completionTokens: number | null;
totalTokens: number | null;
};
export type AiWarning = {
code: string;
message: string;
};
export type AiConfidence = "low" | "medium" | "high";
export type AiSuggestionStatus = "GENERATED" | "ACCEPTED" | "DISMISSED" | "EXPIRED";
export type AiDecision = "accept" | "dismiss";
export type AiEngine = "openai_json_schema" | "openai_json_object" | "empty_assist";
export type AiEnvelope<T> = {
data: T | null;
engine: AiEngine;
model: string | null;
usage: AiUsage | null;
warnings: AiWarning[];
confidence: AiConfidence | null;
promptVersion: string;
truncated: boolean;
requestJson: Record<string, unknown>;
responseJson: Record<string, unknown> | null;
};

View File

@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { prismaMock, getM7DatasetForUserMock } = vi.hoisted(() => ({
prismaMock: {
proposal: {
findMany: vi.fn(),
},
contractRecord: {
findMany: vi.fn(),
},
legalCase: {
findMany: vi.fn(),
},
},
getM7DatasetForUserMock: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({
prisma: prismaMock,
}));
vi.mock("@/lib/compliance/server", () => ({
getM7DatasetForUser: getM7DatasetForUserMock,
}));
import { buildInstitutionalDossier } from "@/lib/audits/server";
describe("M4 -> M5 -> M8 -> M9 -> M10 continuity integration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("preserves document lineage and module links in M10 dossier payload", async () => {
const createdAt = new Date("2026-04-15T10:00:00.000Z");
prismaMock.proposal.findMany.mockResolvedValue([
{
id: "proposal-1",
title: "Propuesta Integral",
status: "IN_PROGRESS",
sourceLicitationId: "licit-1",
documents: [
{
id: "proposal-doc-1",
fileName: "propuesta.pdf",
mimeType: "application/pdf",
sizeBytes: 101,
createdAt,
},
],
},
]);
prismaMock.contractRecord.findMany.mockResolvedValue([
{
id: "contract-1",
title: "Contrato Marco",
status: "ACTIVE",
sourceProposal: {
id: "proposal-1",
title: "Propuesta Integral",
},
deliverables: [{ id: "del-1" }],
payments: [{ id: "pay-1" }],
documents: [
{
id: "contract-doc-1",
fileName: "contrato-firmado.pdf",
mimeType: "application/pdf",
sizeBytes: 202,
kind: "SIGNED_CONTRACT",
createdAt,
},
],
},
]);
prismaMock.legalCase.findMany.mockResolvedValue([
{
id: "legal-1",
caseType: "CONTRACT_BREACH",
severity: "HIGH",
status: "OPEN",
contractId: "contract-1",
documents: [
{
id: "legal-doc-1",
title: "Escrito inicial",
templateKey: "general_delivery_record",
createdAt,
},
],
},
]);
getM7DatasetForUserMock.mockResolvedValue({
tabs: {
alertas: [{ severity: "high" }, { severity: "medium" }],
},
});
const dossier = await buildInstitutionalDossier("user-1");
expect(dossier.summary.proposals).toBe(1);
expect(dossier.summary.contracts).toBe(1);
expect(dossier.summary.legalCases).toBe(1);
expect(dossier.summary.highRiskAlerts).toBe(1);
expect(dossier.proposals[0]?.links.some((link) => link.href === "/licitations/licit-1")).toBe(true);
expect(dossier.contracts[0]?.links.some((link) => link.href === "/gestion-licitaciones/proposal-1")).toBe(true);
expect(dossier.contracts[0]?.documentEntries[0]?.traceLabel).toContain("propuesta M5");
expect(dossier.contracts[0]?.documentEntries[0]?.links.some((link) => link.href === "/api/contracts/contract-1/documents/contract-doc-1")).toBe(true);
expect(dossier.legal[0]?.links.some((link) => link.href === "/gestion-contratos?contractId=contract-1")).toBe(true);
expect(dossier.legal[0]?.documentEntries[0]?.traceLabel).toContain("contract-1");
});
});

166
src/lib/audits/scoring.ts Normal file
View File

@@ -0,0 +1,166 @@
import type { AuditSimulationSectionStatus } from "@prisma/client";
import { AUDIT_SECTION_KEYS, type AuditScoreOutput, type AuditSectionKey } from "@/lib/audits/types";
type AnswerRow = {
questionKey: string;
answer: string;
};
type QuestionDefinition = {
key: string;
section: AuditSectionKey;
prompt: string;
};
export const AUDIT_QUESTIONNAIRE: QuestionDefinition[] = [
{
key: "fiscal_obligaciones_al_dia",
section: "cumplimiento_fiscal",
prompt: "La empresa tiene cumplimiento fiscal actualizado?",
},
{
key: "fiscal_opinion_positiva_sat",
section: "cumplimiento_fiscal",
prompt: "Cuenta con opinion positiva del SAT vigente?",
},
{
key: "laboral_imss_infonavit",
section: "cumplimiento_laboral",
prompt: "Existen evidencias de cumplimiento IMSS/INFONAVIT?",
},
{
key: "laboral_expedientes_personal",
section: "cumplimiento_laboral",
prompt: "Se mantienen expedientes laborales completos?",
},
{
key: "legal_contratos_resguardados",
section: "documentacion_legal",
prompt: "Los contratos y anexos se encuentran resguardados?",
},
{
key: "legal_actas_poderes",
section: "documentacion_legal",
prompt: "Las actas/poderes estan actualizados y disponibles?",
},
{
key: "operativo_control_entregables",
section: "control_operativo",
prompt: "Existe control formal de entregables y evidencias?",
},
{
key: "operativo_seguimiento_pagos",
section: "control_operativo",
prompt: "Se monitorean pagos y facturacion con trazabilidad?",
},
{
key: "finanzas_conciliaciones",
section: "transparencia_financiera",
prompt: "Hay conciliaciones y soporte de movimientos financieros?",
},
{
key: "finanzas_reportes_riesgo",
section: "transparencia_financiera",
prompt: "Se generan reportes de riesgo y controles preventivos?",
},
];
const ANSWER_SCORE_MAP: Record<string, number> = {
si: 100,
yes: 100,
parcial: 60,
partial: 60,
en_proceso: 60,
no: 20,
};
function normalizeAnswer(answer: string) {
return answer
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim()
.replace(/\s+/g, "_");
}
function scoreAnswer(answer: string) {
const normalized = normalizeAnswer(answer);
return ANSWER_SCORE_MAP[normalized] ?? 40;
}
function scoreToStatus(score: number): AuditSimulationSectionStatus {
if (score >= 80) {
return "READY";
}
if (score >= 50) {
return "WARNING";
}
return "CRITICAL";
}
export function scoreAuditResponses(rows: AnswerRow[]): AuditScoreOutput {
const answersByQuestion = new Map(rows.map((row) => [row.questionKey, row.answer]));
const sectionAccumulator = new Map<AuditSectionKey, { total: number; count: number; missing: number }>();
for (const key of AUDIT_SECTION_KEYS) {
sectionAccumulator.set(key, { total: 0, count: 0, missing: 0 });
}
for (const question of AUDIT_QUESTIONNAIRE) {
const answer = answersByQuestion.get(question.key);
const bucket = sectionAccumulator.get(question.section);
if (!bucket) {
continue;
}
if (!answer) {
bucket.missing += 1;
continue;
}
bucket.total += scoreAnswer(answer);
bucket.count += 1;
}
const sections = AUDIT_SECTION_KEYS.map((sectionKey) => {
const bucket = sectionAccumulator.get(sectionKey) ?? { total: 0, count: 0, missing: 0 };
const score = bucket.count > 0 ? Math.round(bucket.total / bucket.count) : 0;
const status = scoreToStatus(score);
const findings: string[] = [];
const recommendations: string[] = [];
if (bucket.missing > 0) {
findings.push(`${bucket.missing} reactivos sin respuesta en esta seccion.`);
}
if (status === "CRITICAL") {
findings.push("Riesgo critico de incumplimiento en esta seccion.");
recommendations.push("Implementar plan correctivo inmediato y evidencia documental.");
} else if (status === "WARNING") {
findings.push("Se identifican brechas relevantes de control.");
recommendations.push("Cerrar brechas operativas con responsables y fechas.");
} else {
findings.push("Seccion con nivel operativo favorable.");
recommendations.push("Mantener controles y actualizar evidencia periodicamente.");
}
return {
key: sectionKey,
score,
status,
findings,
recommendations,
};
});
const overallScore = sections.length > 0 ? Math.round(sections.reduce((sum, item) => sum + item.score, 0) / sections.length) : 0;
return {
overallScore,
sections,
};
}

640
src/lib/audits/server.ts Normal file
View File

@@ -0,0 +1,640 @@
import "server-only";
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { getM7DatasetForUser } from "@/lib/compliance/server";
import { scoreAuditResponses } from "@/lib/audits/scoring";
import type {
AuditKpiSnapshot,
AuditSimulationCreateInput,
AuditSimulationScoreInput,
AuditSimulationView,
InstitutionalDossierFreshness,
InstitutionalDossierLoadStrategy,
InstitutionalDossierPayload,
} from "@/lib/audits/types";
function parseStringArray(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
}
const DEFAULT_DOSSIER_STALE_MINUTES = 180;
function resolveDossierStaleMinutes() {
const raw = process.env.DOSSIER_STALE_MINUTES?.trim();
if (!raw) {
return DEFAULT_DOSSIER_STALE_MINUTES;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_DOSSIER_STALE_MINUTES;
}
return parsed;
}
export const INSTITUTIONAL_DOSSIER_STALE_MINUTES = resolveDossierStaleMinutes();
function computeDossierFreshness(args: {
generatedAt: string;
source: "live" | "snapshot";
strategy: InstitutionalDossierLoadStrategy;
snapshotId: string | null;
}) {
const generatedMs = new Date(args.generatedAt).getTime();
const thresholdMs = INSTITUTIONAL_DOSSIER_STALE_MINUTES * 60 * 1000;
const isStale = !Number.isFinite(generatedMs) || Date.now() - generatedMs > thresholdMs;
return {
generatedAt: args.generatedAt,
isStale,
source: args.source,
strategy: args.strategy,
snapshotId: args.snapshotId,
staleAfterMinutes: INSTITUTIONAL_DOSSIER_STALE_MINUTES,
} satisfies InstitutionalDossierFreshness;
}
function syncPayloadGeneratedAt(payload: InstitutionalDossierPayload, generatedAt: string): InstitutionalDossierPayload {
return {
...payload,
generatedAt,
};
}
function mapSimulation(row: {
id: string;
name: string;
auditType: string;
status: "DRAFT" | "COMPLETED";
overallScore: number | null;
completedAt: Date | null;
createdAt: Date;
updatedAt: Date;
sections: Array<{
id: string;
simulationId: string;
key: string;
score: number | null;
status: "READY" | "WARNING" | "CRITICAL";
findingsJson: Prisma.JsonValue | null;
recommendationsJson: Prisma.JsonValue | null;
}>;
responses: Array<{
id: string;
simulationId: string;
questionKey: string;
answer: string;
evidenceRefsJson: Prisma.JsonValue | null;
}>;
}): AuditSimulationView {
return {
id: row.id,
name: row.name,
auditType: row.auditType,
status: row.status,
overallScore: row.overallScore,
completedAt: row.completedAt ? row.completedAt.toISOString() : null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
sections: row.sections.map((section) => ({
id: section.id,
simulationId: section.simulationId,
key: section.key as AuditSimulationView["sections"][number]["key"],
score: section.score,
status: section.status,
findings: parseStringArray(section.findingsJson),
recommendations: parseStringArray(section.recommendationsJson),
})),
responses: row.responses.map((response) => ({
id: response.id,
simulationId: response.simulationId,
questionKey: response.questionKey,
answer: response.answer,
evidenceRefs: parseStringArray(response.evidenceRefsJson),
})),
};
}
export async function listAuditSimulationsForUser(userId: string) {
const rows = await prisma.auditSimulation.findMany({
where: { userId },
include: {
sections: {
orderBy: [{ key: "asc" }],
},
responses: {
orderBy: [{ createdAt: "desc" }],
},
},
orderBy: [{ updatedAt: "desc" }],
take: 100,
});
return rows.map((row) => mapSimulation(row));
}
export async function createAuditSimulationForUser(userId: string, input: AuditSimulationCreateInput) {
const created = await prisma.auditSimulation.create({
data: {
userId,
name: input.name,
auditType: input.auditType,
status: "DRAFT",
},
include: {
sections: true,
responses: true,
},
});
return mapSimulation(created);
}
export async function scoreAuditSimulationForUser(userId: string, simulationId: string, input: AuditSimulationScoreInput) {
const owned = await prisma.auditSimulation.findFirst({
where: {
id: simulationId,
userId,
},
select: { id: true },
});
if (!owned) {
return null;
}
const scoring = scoreAuditResponses(input.responses);
await prisma.$transaction([
prisma.auditChecklistResponse.deleteMany({
where: {
simulationId,
},
}),
prisma.auditChecklistResponse.createMany({
data: input.responses.map((item) => ({
simulationId,
questionKey: item.questionKey,
answer: item.answer,
evidenceRefsJson: (item.evidenceRefs ?? []) as Prisma.InputJsonValue,
})),
}),
...scoring.sections.map((section) =>
prisma.auditSimulationSection.upsert({
where: {
simulationId_key: {
simulationId,
key: section.key,
},
},
update: {
score: section.score,
status: section.status,
findingsJson: section.findings as Prisma.InputJsonValue,
recommendationsJson: section.recommendations as Prisma.InputJsonValue,
},
create: {
simulationId,
key: section.key,
score: section.score,
status: section.status,
findingsJson: section.findings as Prisma.InputJsonValue,
recommendationsJson: section.recommendations as Prisma.InputJsonValue,
},
}),
),
prisma.auditSimulation.update({
where: { id: simulationId },
data: {
overallScore: scoring.overallScore,
status: "COMPLETED",
completedAt: new Date(),
},
}),
]);
const updated = await prisma.auditSimulation.findUnique({
where: { id: simulationId },
include: {
sections: {
orderBy: [{ key: "asc" }],
},
responses: {
orderBy: [{ createdAt: "desc" }],
},
},
});
return updated ? mapSimulation(updated) : null;
}
export async function getAuditSimulationDetail(userId: string, simulationId: string) {
const row = await prisma.auditSimulation.findFirst({
where: {
id: simulationId,
userId,
},
include: {
sections: {
orderBy: [{ key: "asc" }],
},
responses: {
orderBy: [{ createdAt: "desc" }],
},
},
});
return row ? mapSimulation(row) : null;
}
export async function getAuditKpisForUser(userId: string): Promise<AuditKpiSnapshot> {
const [completedAudits, latestCompleted, contractsCount, contractPdfs, proposalPdfs] = await Promise.all([
prisma.auditSimulation.count({
where: {
userId,
status: "COMPLETED",
},
}),
prisma.auditSimulation.findFirst({
where: {
userId,
status: "COMPLETED",
},
orderBy: [{ completedAt: "desc" }],
select: {
overallScore: true,
},
}),
prisma.contractRecord.count({
where: {
userId,
},
}),
prisma.contractDocument.count({
where: {
contract: {
userId,
},
},
}),
prisma.proposalDocument.count({
where: {
userId,
},
}),
]);
return {
completedAudits,
lastScore: latestCompleted?.overallScore ?? null,
registeredContracts: contractsCount,
securedPdfs: contractPdfs + proposalPdfs,
};
}
export async function buildInstitutionalDossier(userId: string): Promise<InstitutionalDossierPayload> {
const [proposals, contracts, legalCases, m7] = await Promise.all([
prisma.proposal.findMany({
where: { userId },
include: {
documents: {
orderBy: [{ createdAt: "desc" }],
select: {
id: true,
fileName: true,
mimeType: true,
sizeBytes: true,
createdAt: true,
},
},
},
orderBy: [{ updatedAt: "desc" }],
take: 200,
}),
prisma.contractRecord.findMany({
where: { userId },
include: {
sourceProposal: {
select: {
id: true,
title: true,
},
},
documents: {
orderBy: [{ createdAt: "desc" }],
select: {
id: true,
fileName: true,
mimeType: true,
sizeBytes: true,
kind: true,
createdAt: true,
},
},
deliverables: {
select: { id: true },
},
payments: {
select: { id: true },
},
},
orderBy: [{ updatedAt: "desc" }],
take: 200,
}),
prisma.legalCase.findMany({
where: { userId },
include: {
documents: {
orderBy: [{ createdAt: "desc" }],
select: {
id: true,
title: true,
templateKey: true,
createdAt: true,
},
},
},
orderBy: [{ updatedAt: "desc" }],
take: 200,
}),
getM7DatasetForUser(userId),
]);
const proposalDocuments = proposals.reduce((sum, item) => sum + item.documents.length, 0);
const contractDocuments = contracts.reduce((sum, item) => sum + item.documents.length, 0);
const legalDocuments = legalCases.reduce((sum, item) => sum + item.documents.length, 0);
const highRiskAlerts = m7.tabs.alertas.filter((item) => item.severity === "high").length;
const components = [
{
key: "m5" as const,
label: "M5 Propuestas",
totalItems: proposals.length,
withEvidence: proposals.filter((item) => item.documents.length > 0).length,
},
{
key: "m8" as const,
label: "M8 Contratos",
totalItems: contracts.length,
withEvidence: contracts.filter((item) => item.documents.length > 0).length,
},
{
key: "m9" as const,
label: "M9 Casos Legales",
totalItems: legalCases.length,
withEvidence: legalCases.filter((item) => item.documents.length > 0).length,
},
{
key: "m7" as const,
label: "M7 Alertas",
totalItems: m7.tabs.alertas.length,
withEvidence: Math.max(0, m7.tabs.alertas.length - highRiskAlerts),
},
].map((component) => ({
...component,
status: component.totalItems > 0 && component.withEvidence >= component.totalItems ? "completo" as const : "incompleto" as const,
}));
return {
generatedAt: new Date().toISOString(),
summary: {
contracts: contracts.length,
contractDocuments,
proposals: proposals.length,
proposalDocuments,
legalCases: legalCases.length,
legalDocuments,
highRiskAlerts,
},
components,
contracts: contracts.map((item) => ({
id: item.id,
title: item.title,
status: item.status,
deliverables: item.deliverables.length,
payments: item.payments.length,
documents: item.documents.length,
links: [
{
label: "Abrir contrato en M8",
href: `/gestion-contratos?contractId=${encodeURIComponent(item.id)}`,
},
{
label: "Abrir continuidad legal en M9",
href: `/proteccion-legal?contractId=${encodeURIComponent(item.id)}`,
},
...(item.sourceProposal
? [
{
label: "Ver propuesta fuente en M5",
href: `/gestion-licitaciones/${encodeURIComponent(item.sourceProposal.id)}`,
},
]
: []),
],
documentEntries: item.documents.map((document) => ({
id: document.id,
fileName: document.fileName,
kind: document.kind,
mimeType: document.mimeType,
sizeBytes: document.sizeBytes,
createdAt: document.createdAt.toISOString(),
traceLabel: item.sourceProposal
? `Evidencia contractual de M8 ligada a propuesta M5: ${item.sourceProposal.title}`
: "Evidencia contractual cargada en M8.",
links: [
{
label: "Ver contexto en M8",
href: `/gestion-contratos?contractId=${encodeURIComponent(item.id)}&documentId=${encodeURIComponent(document.id)}`,
},
{
label: "Descargar evidencia",
href: `/api/contracts/${encodeURIComponent(item.id)}/documents/${encodeURIComponent(document.id)}`,
},
...(item.sourceProposal
? [
{
label: "Rastrear origen en M5",
href: `/gestion-licitaciones/${encodeURIComponent(item.sourceProposal.id)}`,
},
]
: []),
],
})),
})),
proposals: proposals.map((item) => ({
id: item.id,
title: item.title,
status: item.status,
documents: item.documents.length,
links: [
{
label: "Abrir propuesta en M5",
href: `/gestion-licitaciones/${encodeURIComponent(item.id)}`,
},
...(item.sourceLicitationId
? [
{
label: "Ver licitacion origen",
href: `/licitations/${encodeURIComponent(item.sourceLicitationId)}`,
},
]
: []),
],
documentEntries: item.documents.map((document) => ({
id: document.id,
fileName: document.fileName,
kind: null,
mimeType: document.mimeType,
sizeBytes: document.sizeBytes,
createdAt: document.createdAt.toISOString(),
traceLabel: item.sourceLicitationId
? `Documento de propuesta M5 vinculado a licitacion ${item.sourceLicitationId}.`
: "Documento de propuesta cargado en M5.",
links: [
{
label: "Ver contexto en M5",
href: `/gestion-licitaciones/${encodeURIComponent(item.id)}?documentId=${encodeURIComponent(document.id)}`,
},
{
label: "Descargar evidencia",
href: `/api/proposals/${encodeURIComponent(item.id)}/documents/${encodeURIComponent(document.id)}`,
},
],
})),
})),
legal: legalCases.map((item) => ({
id: item.id,
caseType: item.caseType,
severity: item.severity,
status: item.status,
documents: item.documents.length,
links: [
{
label: "Abrir caso en M9",
href: item.contractId
? `/proteccion-legal?contractId=${encodeURIComponent(item.contractId)}`
: "/proteccion-legal",
},
...(item.contractId
? [
{
label: "Ver contrato asociado en M8",
href: `/gestion-contratos?contractId=${encodeURIComponent(item.contractId)}`,
},
]
: []),
],
documentEntries: item.documents.map((document) => ({
id: document.id,
fileName: document.title,
kind: document.templateKey,
mimeType: "text/plain",
sizeBytes: 0,
createdAt: document.createdAt.toISOString(),
traceLabel: item.contractId
? `Escrito legal de M9 vinculado al contrato ${item.contractId}.`
: "Escrito legal generado en M9.",
links: [
{
label: "Ver contexto en M9",
href: item.contractId
? `/proteccion-legal?contractId=${encodeURIComponent(item.contractId)}`
: "/proteccion-legal",
},
],
})),
})),
};
}
export async function refreshInstitutionalDossierSnapshot(userId: string) {
const payload = await buildInstitutionalDossier(userId);
const snapshot = await prisma.institutionalDossierSnapshot.create({
data: {
userId,
payloadJson: payload as Prisma.InputJsonValue,
},
});
const generatedAt = snapshot.generatedAt.toISOString();
const syncedPayload = syncPayloadGeneratedAt(payload, generatedAt);
return {
id: snapshot.id,
generatedAt,
payload: syncedPayload,
freshness: computeDossierFreshness({
generatedAt,
source: "snapshot",
strategy: "snapshot",
snapshotId: snapshot.id,
}),
};
}
export async function getLatestInstitutionalDossierSnapshot(userId: string) {
const latest = await prisma.institutionalDossierSnapshot.findFirst({
where: {
userId,
},
orderBy: [{ generatedAt: "desc" }],
select: {
id: true,
generatedAt: true,
payloadJson: true,
},
});
if (!latest) {
return null;
}
const generatedAt = latest.generatedAt.toISOString();
const payload = syncPayloadGeneratedAt(latest.payloadJson as InstitutionalDossierPayload, generatedAt);
return {
id: latest.id,
generatedAt,
payload,
};
}
export async function getInstitutionalDossierForUser(userId: string, strategy: InstitutionalDossierLoadStrategy = "live") {
if (strategy === "snapshot") {
const latest = await getLatestInstitutionalDossierSnapshot(userId);
if (latest) {
return {
dossier: latest.payload,
freshness: computeDossierFreshness({
generatedAt: latest.generatedAt,
source: "snapshot",
strategy,
snapshotId: latest.id,
}),
};
}
}
const live = await buildInstitutionalDossier(userId);
const generatedAt = live.generatedAt;
return {
dossier: live,
freshness: computeDossierFreshness({
generatedAt,
source: "live",
strategy,
snapshotId: null,
}),
};
}

176
src/lib/audits/types.ts Normal file
View File

@@ -0,0 +1,176 @@
import type { AuditSimulationSectionStatus, AuditSimulationStatus } from "@prisma/client";
import { z } from "zod";
export const AUDIT_SECTION_KEYS = [
"cumplimiento_fiscal",
"cumplimiento_laboral",
"documentacion_legal",
"control_operativo",
"transparencia_financiera",
] as const;
export type AuditSectionKey = (typeof AUDIT_SECTION_KEYS)[number];
export type AuditKpiSnapshot = {
completedAudits: number;
lastScore: number | null;
registeredContracts: number;
securedPdfs: number;
};
export type AuditSimulationView = {
id: string;
name: string;
auditType: string;
status: AuditSimulationStatus;
overallScore: number | null;
completedAt: string | null;
createdAt: string;
updatedAt: string;
sections: AuditSimulationSectionView[];
responses: AuditChecklistResponseView[];
};
export type AuditSimulationSectionView = {
id: string;
simulationId: string;
key: AuditSectionKey;
score: number | null;
status: AuditSimulationSectionStatus;
findings: string[];
recommendations: string[];
};
export type AuditChecklistResponseView = {
id: string;
simulationId: string;
questionKey: string;
answer: string;
evidenceRefs: string[];
};
export type AuditSectionScore = {
key: AuditSectionKey;
score: number;
status: AuditSimulationSectionStatus;
findings: string[];
recommendations: string[];
};
export type AuditScoreOutput = {
overallScore: number;
sections: AuditSectionScore[];
};
export type InstitutionalDossierComponentStatus = {
key: "m5" | "m8" | "m9" | "m7";
label: string;
status: "completo" | "incompleto";
totalItems: number;
withEvidence: number;
};
export type InstitutionalDossierLoadStrategy = "live" | "snapshot";
export type InstitutionalDossierEvidenceLink = {
label: string;
href: string;
};
export type InstitutionalDossierEvidenceDocument = {
id: string;
fileName: string;
kind: string | null;
mimeType: string;
sizeBytes: number;
createdAt: string;
traceLabel: string;
links: InstitutionalDossierEvidenceLink[];
};
export type InstitutionalDossierPayload = {
generatedAt: string;
summary: {
contracts: number;
contractDocuments: number;
proposals: number;
proposalDocuments: number;
legalCases: number;
legalDocuments: number;
highRiskAlerts: number;
};
components: InstitutionalDossierComponentStatus[];
contracts: Array<{
id: string;
title: string;
status: string;
deliverables: number;
payments: number;
documents: number;
links: InstitutionalDossierEvidenceLink[];
documentEntries: InstitutionalDossierEvidenceDocument[];
}>;
proposals: Array<{
id: string;
title: string;
status: string;
documents: number;
links: InstitutionalDossierEvidenceLink[];
documentEntries: InstitutionalDossierEvidenceDocument[];
}>;
legal: Array<{
id: string;
caseType: string;
severity: string;
status: string;
documents: number;
links: InstitutionalDossierEvidenceLink[];
documentEntries: InstitutionalDossierEvidenceDocument[];
}>;
aiFindings?: AuditAiFindings | null;
};
export type InstitutionalDossierFreshness = {
generatedAt: string;
isStale: boolean;
source: "live" | "snapshot";
strategy: InstitutionalDossierLoadStrategy;
snapshotId: string | null;
staleAfterMinutes: number;
};
export type AuditAiFindings = {
auditorLikelyFindings: {
area: string;
finding: string;
severity: "alto" | "medio" | "bajo";
}[];
missingEvidence: string[];
topRisks: string[];
remediationPlan: {
step: string;
priority: "alta" | "media" | "baja";
ownerSuggestion: string;
targetDate: string;
}[];
confidence: "low" | "medium" | "high";
suggestionId: string;
};
export const AuditSimulationCreateInputSchema = z.object({
name: z.string().trim().min(2).max(220),
auditType: z.string().trim().min(2).max(120),
});
export const AuditSimulationScoreInputSchema = z.object({
responses: z.array(
z.object({
questionKey: z.string().trim().min(2).max(120),
answer: z.string().trim().min(1).max(120),
evidenceRefs: z.array(z.string().trim().min(1).max(260)).optional(),
}),
),
});
export type AuditSimulationCreateInput = z.infer<typeof AuditSimulationCreateInputSchema>;
export type AuditSimulationScoreInput = z.infer<typeof AuditSimulationScoreInputSchema>;

View File

@@ -0,0 +1,67 @@
import "server-only";
import { type ModulePlanKey, type UserRole } from "@prisma/client";
import { isAdminIdentity } from "@/lib/auth/admin";
import { prisma } from "@/lib/prisma";
import { getModulePlanByModuleNumber } from "@/lib/payments/plans";
function isSubscriptionValid(subscription: { isActive: boolean; startsAt: Date; expiresAt: Date | null }, now: Date) {
if (!subscription.isActive) {
return false;
}
if (subscription.startsAt.getTime() > now.getTime()) {
return false;
}
if (subscription.expiresAt && subscription.expiresAt.getTime() < now.getTime()) {
return false;
}
return true;
}
export async function getActivePlanKeysForUser(userId: string) {
const now = new Date();
const subscriptions = await prisma.modulePlanSubscription.findMany({
where: {
userId,
isActive: true,
},
select: {
planKey: true,
isActive: true,
startsAt: true,
expiresAt: true,
},
});
return new Set(
subscriptions.filter((item) => isSubscriptionValid(item, now)).map((item) => item.planKey as ModulePlanKey),
);
}
export async function hasAnyPaidModuleAccess(userId: string, email: string, role: UserRole) {
if (isAdminIdentity(email, role)) {
return true;
}
const plans = await getActivePlanKeysForUser(userId);
return plans.size > 0;
}
export async function hasModuleAccess(user: { id: string; email: string; role: UserRole }, moduleNumber: number) {
if (isAdminIdentity(user.email, user.role)) {
return true;
}
const targetPlan = getModulePlanByModuleNumber(moduleNumber);
if (!targetPlan) {
return true;
}
const activePlans = await getActivePlanKeysForUser(user.id);
return activePlans.has(targetPlan.key);
}

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { buildM7Dataset, computeM3Counters } from "@/lib/compliance/m7";
import { ProposalWorkflowStateSchema } from "@/lib/proposals/workflow-state";
function buildWorkflowWithDeadline(dateIso: string) {
return ProposalWorkflowStateSchema.parse({
step: 5,
info: {
title: "Licitacion infraestructura",
issuingEntity: "Gobierno municipal",
procedureType: "Publica",
jurisdiction: "Estatal",
state: "Nuevo Leon",
municipality: "Monterrey",
sector: "Obra publica",
description: "Proceso de prueba",
convocatoriaUrl: "https://example.com/convocatoria",
},
requirements: [
{
id: "req-critical",
title: "Garantia critica",
description: "Incumplimiento puede descalificar la propuesta",
category: "legal",
mandatory: true,
source: "critical_requirement",
status: "pending",
note: "",
evidences: [],
},
],
technicalSections: [{ id: "tech-1", title: "Alcance tecnico", description: "Detalle tecnico", completed: true }],
economicItems: [{ id: "eco-1", concept: "Servicio", unit: "lote", quantity: 1, unitPrice: 1000 }],
milestones: [{ id: "mil-1", title: "Entrega", dateIso, location: "", note: "", source: "manual" }],
signatureCompliance: {
policyStatus: "condicionado",
policyName: "Politica piloto",
jurisdictionLabel: "Nuevo Leon",
sourceUrl: "https://www.nl.gob.mx/",
minimumEvidence: ["Acuse"],
notes: "",
validatedByLegal: false,
},
readyMarked: false,
});
}
describe("M3 -> M5 -> M7 integration", () => {
it("combines M3 preferences with active M5 workflow into M7 KPI snapshot", () => {
const now = new Date("2026-04-02T10:00:00.000Z");
const m3 = computeM3Counters({
totalOpenLicitations: 8,
preferences: [{ status: "REVIEWED" }, { status: "INTERESTED" }, { status: "INTERESTED" }],
activeLinked: 1,
});
const dataset = buildM7Dataset({
proposals: [
{
id: "proposal-1",
title: "Propuesta M5 activa",
status: "IN_PROGRESS",
sourceLicitationId: "licit-1",
workflowDraft: buildWorkflowWithDeadline("2026-04-03T09:00:00.000Z"),
updatedAt: now,
},
],
m3,
dueVerifications: [],
now,
});
expect(dataset.kpis.activeLicitations).toBe(1);
expect(dataset.m3States.consulted).toBe(1);
expect(dataset.m3States.interested).toBe(2);
expect(dataset.m3States.active).toBe(1);
expect(dataset.tabs.checklist.length).toBe(1);
expect(dataset.tabs.panelKpi.some((item) => item.label === "M3 Consultadas")).toBe(true);
});
it("raises critical pending alert when high-risk pending items meet near deadlines", () => {
const now = new Date("2026-04-02T10:00:00.000Z");
const m3 = computeM3Counters({
totalOpenLicitations: 3,
preferences: [],
activeLinked: 1,
});
const dataset = buildM7Dataset({
proposals: [
{
id: "proposal-critical",
title: "Propuesta critica",
status: "DRAFT",
sourceLicitationId: "licit-2",
workflowDraft: buildWorkflowWithDeadline("2026-04-02T12:00:00.000Z"),
updatedAt: now,
},
],
m3,
dueVerifications: [
{
sourceId: "nl-reg",
sourceTitle: "Reglamento NL",
authorityName: "Gobierno NL",
dueAt: "2026-04-02T11:00:00.000Z",
overdue: false,
},
],
now,
});
expect(dataset.kpis.criticalPending).toBeGreaterThan(0);
expect(dataset.tabs.alertas.some((item) => item.kind === "critical_requirement_pending")).toBe(true);
expect(dataset.tabs.alertas.some((item) => item.kind === "deadline_soon")).toBe(true);
});
});

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import { buildM7Dataset, computeM3Counters } from "@/lib/compliance/m7";
import { ProposalWorkflowStateSchema } from "@/lib/proposals/workflow-state";
function buildWorkflow(overrides?: Partial<ReturnType<typeof ProposalWorkflowStateSchema.parse>>) {
const base = ProposalWorkflowStateSchema.parse({
step: 5,
info: {
title: "Proceso de prueba",
issuingEntity: "Entidad",
procedureType: "Publica",
jurisdiction: "Estatal",
state: "Nuevo Leon",
municipality: "Monterrey",
sector: "Tecnologia",
description: "Servicio",
convocatoriaUrl: "https://example.com",
},
requirements: [
{
id: "req-1",
title: "Requisito critico",
description: "Debe cumplirse",
category: "legal",
mandatory: true,
source: "critical_requirement",
status: "pending",
note: "",
evidences: [],
},
],
technicalSections: [
{
id: "tech-1",
title: "Tecnica",
description: "Seccion",
completed: true,
},
],
economicItems: [
{
id: "eco-1",
concept: "Concepto",
unit: "pieza",
quantity: 1,
unitPrice: 100,
},
],
milestones: [
{
id: "mil-1",
title: "Entrega",
dateIso: "2026-04-03T10:00:00.000Z",
location: "",
note: "",
source: "manual",
},
],
signatureCompliance: {
policyStatus: "requiere_validacion_legal",
policyName: "Politica",
jurisdictionLabel: "Nuevo Leon",
sourceUrl: "",
minimumEvidence: ["Opinion legal"],
notes: "",
validatedByLegal: false,
},
readyMarked: false,
});
return {
...base,
...(overrides ?? {}),
};
}
describe("buildM7Dataset", () => {
it("computes KPIs and tabs using M3+M5 inputs", () => {
const now = new Date("2026-04-02T10:00:00.000Z");
const m3 = computeM3Counters({
totalOpenLicitations: 10,
preferences: [{ status: "REVIEWED" }, { status: "INTERESTED" }],
activeLinked: 2,
});
const dataset = buildM7Dataset({
proposals: [
{
id: "p-1",
title: "Propuesta activa",
status: "IN_PROGRESS",
sourceLicitationId: "l-1",
workflowDraft: buildWorkflow(),
updatedAt: now,
},
{
id: "p-2",
title: "Archivada",
status: "ARCHIVED",
sourceLicitationId: "l-2",
workflowDraft: buildWorkflow(),
updatedAt: now,
},
],
m3,
dueVerifications: [
{
sourceId: "nl-1",
sourceTitle: "Reglamento NL",
authorityName: "Gobierno NL",
dueAt: "2026-04-04T09:00:00.000Z",
overdue: false,
},
],
now,
});
expect(dataset.kpis.activeLicitations).toBe(1);
expect(dataset.kpis.upcoming7Days).toBeGreaterThanOrEqual(2);
expect(dataset.kpis.criticalPending).toBeGreaterThan(0);
expect(dataset.m3States.consulted).toBe(1);
expect(dataset.m3States.interested).toBe(1);
});
});

View File

@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
listRegulationChangeAlerts,
resetRegulationsStateForTests,
runPeriodicNormativeVerification,
verifyOfficialNormativeSources,
} from "@/lib/compliance/regulations";
function buildResponse(etag: string, status = 200) {
return new Response("", {
status,
headers: {
etag,
"last-modified": "Thu, 02 Apr 2026 00:00:00 GMT",
"content-length": "100",
"content-type": "text/html",
},
});
}
describe("regulations verification", () => {
beforeEach(async () => {
await resetRegulationsStateForTests();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-02T10:00:00.000Z"));
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.useRealTimers();
await resetRegulationsStateForTests();
});
it("detects source changes and emits regulation update alerts", async () => {
const fetchMock = vi
.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(buildResponse("v1"))
.mockResolvedValueOnce(buildResponse("v2"));
vi.stubGlobal("fetch", fetchMock);
await verifyOfficialNormativeSources(["nl-reglamento-adquisiciones-estatal"]);
const changed = await verifyOfficialNormativeSources(["nl-reglamento-adquisiciones-estatal"]);
expect(changed[0]?.status).toBe("warning");
expect(changed[0]?.changed).toBe(true);
const alerts = await listRegulationChangeAlerts(new Date("2026-04-02T10:30:00.000Z"), 30);
expect(alerts.some((item) => item.kind === "normative_regulation_update" && item.severity === "high")).toBe(true);
});
it("records failed verifications and emits pending alerts", async () => {
vi.stubGlobal(
"fetch",
vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>().mockRejectedValue(new Error("timeout")),
);
const result = await verifyOfficialNormativeSources(["spgg-reglamento-adquisiciones"]);
expect(result[0]?.status).toBe("failed");
const alerts = await listRegulationChangeAlerts(new Date("2026-04-02T11:00:00.000Z"), 30);
expect(alerts.some((item) => item.kind === "normative_verification_pending")).toBe(true);
});
it("runs periodic verification only when due", async () => {
vi.stubGlobal("fetch", vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>().mockResolvedValue(buildResponse("v1")));
const first = await runPeriodicNormativeVerification(new Date("2026-04-02T10:00:00.000Z"));
expect(first.checked).toBe(true);
expect(first.dueCount).toBeGreaterThan(0);
expect(first.results.length).toBe(first.dueCount);
const second = await runPeriodicNormativeVerification(new Date("2026-04-02T10:05:00.000Z"));
expect(second.checked).toBe(false);
expect(second.dueCount).toBe(0);
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { evaluateSignaturePolicy } from "@/lib/compliance/signature-policy";
describe("evaluateSignaturePolicy", () => {
it("returns conservative municipal rule for San Pedro", () => {
const evaluation = evaluateSignaturePolicy({
stateCode: "NL",
municipalityName: "San Pedro Garza Garcia",
documentType: "BASES_LICITACION",
});
expect(evaluation.policyStatus).toBe("requiere_validacion_legal");
expect(evaluation.evidenceRequired.length).toBeGreaterThan(0);
});
it("returns conditioned rule for Nuevo Leon state scope", () => {
const evaluation = evaluateSignaturePolicy({
stateCode: "NL",
municipalityName: "Monterrey",
documentType: "CONVOCATORIA",
});
expect(evaluation.policyStatus).toBe("condicionado");
expect(evaluation.policyName.toLowerCase()).toContain("firma electronica");
});
it("defaults to legal validation when jurisdiction is unknown", () => {
const evaluation = evaluateSignaturePolicy({
stateName: "Estado no configurado",
municipalityName: "Municipio X",
});
expect(evaluation.policyStatus).toBe("requiere_validacion_legal");
});
});

389
src/lib/compliance/m7.ts Normal file
View File

@@ -0,0 +1,389 @@
import type { LicitationReviewStatus, ProposalStatus } from "@prisma/client";
import { computeWorkflowGate, ProposalWorkflowStateSchema, type ProposalWorkflowState } from "@/lib/proposals/workflow-state";
import type {
ComplianceAlertView,
ComplianceChecklistItem,
ComplianceDeadlineItem,
CompliancePanelItem,
M7Dataset,
SignaturePolicyStatus,
} from "@/lib/compliance/types";
type ProposalM7Input = {
id: string;
title: string;
status: ProposalStatus;
sourceLicitationId: string | null;
workflowDraft: unknown;
updatedAt: Date;
};
type M3Counters = {
totalOpenLicitations: number;
reviewed: number;
interested: number;
activeLinked: number;
};
type VerificationDueItem = {
sourceId: string;
sourceTitle: string;
authorityName: string;
dueAt: string;
overdue: boolean;
};
const CRITICAL_DEADLINE_HOURS = 72;
function parseWorkflowDraft(value: unknown) {
const parsed = ProposalWorkflowStateSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
function isCriticalRequirement(requirement: ProposalWorkflowState["requirements"][number]) {
if (requirement.source === "critical_requirement") {
return true;
}
const text = `${requirement.title} ${requirement.description}`.toLowerCase();
return /\bcritic|alto\b|descalific|inhabilit|sancion/.test(text);
}
function toSignatureStatus(workflow: ProposalWorkflowState): SignaturePolicyStatus {
const candidate = (workflow as ProposalWorkflowState & { signatureCompliance?: { policyStatus?: SignaturePolicyStatus } }).signatureCompliance;
return candidate?.policyStatus ?? "requiere_validacion_legal";
}
export function isOperationalProposal(status: ProposalStatus) {
return status === "DRAFT" || status === "IN_PROGRESS";
}
function toDeadlineStatus(dateIso: string, now: Date) {
const due = new Date(dateIso);
if (Number.isNaN(due.getTime())) {
return null;
}
return {
due,
overdue: due.getTime() < now.getTime(),
};
}
function buildChecklistRows(proposals: Array<ProposalM7Input & { workflow: ProposalWorkflowState }>): ComplianceChecklistItem[] {
return proposals.map((proposal) => {
const gate = computeWorkflowGate(proposal.workflow);
const completionRate = gate.mandatoryCount > 0 ? Math.round((gate.resolvedCount / gate.mandatoryCount) * 100) : 0;
return {
proposalId: proposal.id,
proposalTitle: proposal.title,
mandatoryResolved: gate.resolvedCount,
mandatoryTotal: gate.mandatoryCount,
completionRate,
signaturePolicyStatus: toSignatureStatus(proposal.workflow),
};
});
}
function buildProposalDeadlines(proposals: Array<ProposalM7Input & { workflow: ProposalWorkflowState }>, now: Date): ComplianceDeadlineItem[] {
const rows: ComplianceDeadlineItem[] = [];
for (const proposal of proposals) {
for (const milestone of proposal.workflow.milestones) {
if (!milestone.dateIso) {
continue;
}
const timeline = toDeadlineStatus(milestone.dateIso, now);
if (!timeline) {
continue;
}
rows.push({
id: `${proposal.id}-${milestone.id}`,
proposalId: proposal.id,
licitationId: proposal.sourceLicitationId,
title: milestone.title,
description: milestone.note || `Hito de ${proposal.title}`,
dueAt: timeline.due.toISOString(),
source: "milestone",
status: timeline.overdue ? "overdue" : "upcoming",
});
}
}
return rows;
}
function buildVerificationDeadlines(items: VerificationDueItem[]): ComplianceDeadlineItem[] {
return items.map((item) => ({
id: `verification-${item.sourceId}`,
proposalId: null,
licitationId: item.sourceId,
title: `Verificacion normativa: ${item.sourceTitle}`,
description: `${item.authorityName} (${item.overdue ? "atrasada" : "programada"})`,
dueAt: item.dueAt,
source: "normative_verification",
status: item.overdue ? "overdue" : "upcoming",
}));
}
function calculateCriticalPending(proposals: Array<ProposalM7Input & { workflow: ProposalWorkflowState }>, deadlines: ComplianceDeadlineItem[], now: Date) {
const deadlineByProposal = new Map<string, ComplianceDeadlineItem[]>();
for (const item of deadlines) {
if (!item.proposalId) {
continue;
}
const existing = deadlineByProposal.get(item.proposalId) ?? [];
existing.push(item);
deadlineByProposal.set(item.proposalId, existing);
}
let total = 0;
for (const proposal of proposals) {
const proposalDeadlines = deadlineByProposal.get(proposal.id) ?? [];
const hasUrgentDeadline = proposalDeadlines.some((deadline) => {
const due = new Date(deadline.dueAt);
if (Number.isNaN(due.getTime())) {
return false;
}
const diffHours = (due.getTime() - now.getTime()) / 3_600_000;
return diffHours <= CRITICAL_DEADLINE_HOURS;
});
if (!hasUrgentDeadline) {
continue;
}
const pendingCriticalRequirements = proposal.workflow.requirements.filter((item) => item.status === "pending" && isCriticalRequirement(item));
total += pendingCriticalRequirements.length;
if (toSignatureStatus(proposal.workflow) === "requiere_validacion_legal") {
total += 1;
}
}
return total;
}
function buildAlerts(args: {
criticalPending: number;
deadlines: ComplianceDeadlineItem[];
checklist: ComplianceChecklistItem[];
now: Date;
}): ComplianceAlertView[] {
const alerts: ComplianceAlertView[] = [];
for (const deadline of args.deadlines) {
const due = new Date(deadline.dueAt);
const diffHours = (due.getTime() - args.now.getTime()) / 3_600_000;
if (deadline.status === "overdue") {
alerts.push({
id: `alert-overdue-${deadline.id}`,
proposalId: deadline.proposalId,
licitationId: deadline.licitationId,
title: `Vencido: ${deadline.title}`,
description: deadline.description,
severity: "high",
kind: "deadline_overdue",
dueAt: deadline.dueAt,
createdAt: args.now.toISOString(),
});
continue;
}
if (diffHours <= 72) {
alerts.push({
id: `alert-soon-${deadline.id}`,
proposalId: deadline.proposalId,
licitationId: deadline.licitationId,
title: `Vence pronto: ${deadline.title}`,
description: deadline.description,
severity: diffHours <= 24 ? "high" : "medium",
kind: "deadline_soon",
dueAt: deadline.dueAt,
createdAt: args.now.toISOString(),
});
}
}
if (args.criticalPending > 0) {
alerts.unshift({
id: "alert-critical-pending",
proposalId: null,
licitationId: null,
title: "Pendientes criticos detectados",
description: `${args.criticalPending} pendientes combinan severidad alta con plazo inminente o vencido.`,
severity: "high",
kind: "critical_requirement_pending",
dueAt: null,
createdAt: args.now.toISOString(),
});
}
for (const item of args.checklist) {
if (item.signaturePolicyStatus === "requiere_validacion_legal") {
alerts.push({
id: `alert-signature-${item.proposalId}`,
proposalId: item.proposalId,
licitationId: null,
title: `Firma electronica: validacion legal requerida`,
description: `La propuesta ${item.proposalTitle} conserva semaforo conservador de firma.`,
severity: "medium",
kind: "signature_policy",
dueAt: null,
createdAt: args.now.toISOString(),
});
}
}
return alerts;
}
function buildPanelRows(dataset: {
activeLicitations: number;
completionRate: number;
criticalPending: number;
upcoming7Days: number;
reviewed: number;
interested: number;
}) {
const panel: CompliancePanelItem[] = [
{
id: "panel-active",
label: "Licitaciones activas",
value: String(dataset.activeLicitations),
tone: dataset.activeLicitations > 0 ? "success" : "neutral",
},
{
id: "panel-completion",
label: "Completitud obligatoria",
value: `${dataset.completionRate}%`,
tone: dataset.completionRate >= 70 ? "success" : dataset.completionRate >= 40 ? "warning" : "danger",
},
{
id: "panel-critical",
label: "Pendientes criticos",
value: String(dataset.criticalPending),
tone: dataset.criticalPending > 0 ? "danger" : "success",
},
{
id: "panel-upcoming",
label: "Proximos 7 dias",
value: String(dataset.upcoming7Days),
tone: dataset.upcoming7Days > 0 ? "warning" : "neutral",
},
{
id: "panel-consulted",
label: "M3 Consultadas",
value: String(dataset.reviewed),
tone: "neutral",
},
{
id: "panel-interested",
label: "M3 Me interesa",
value: String(dataset.interested),
tone: "neutral",
},
];
return panel;
}
export function computeM3Counters(args: {
totalOpenLicitations: number;
preferences: Array<{ status: LicitationReviewStatus }>;
activeLinked: number;
}): M3Counters {
const reviewed = args.preferences.filter((item) => item.status === "REVIEWED").length;
const interested = args.preferences.filter((item) => item.status === "INTERESTED").length;
return {
totalOpenLicitations: Math.max(0, args.totalOpenLicitations),
reviewed,
interested,
activeLinked: Math.max(0, args.activeLinked),
};
}
export function buildM7Dataset(args: {
proposals: ProposalM7Input[];
m3: M3Counters;
dueVerifications: VerificationDueItem[];
externalAlerts?: ComplianceAlertView[];
now?: Date;
}): M7Dataset {
const now = args.now ?? new Date();
const horizon = new Date(now);
horizon.setUTCDate(horizon.getUTCDate() + 7);
const activeProposals = args.proposals
.filter((proposal) => isOperationalProposal(proposal.status))
.map((proposal) => ({
...proposal,
workflow: parseWorkflowDraft(proposal.workflowDraft),
}))
.filter((proposal): proposal is ProposalM7Input & { workflow: ProposalWorkflowState } => Boolean(proposal.workflow));
const checklist = buildChecklistRows(activeProposals);
const proposalDeadlines = buildProposalDeadlines(activeProposals, now);
const verificationDeadlines = buildVerificationDeadlines(args.dueVerifications);
const allDeadlines = [...proposalDeadlines, ...verificationDeadlines].sort((left, right) => new Date(left.dueAt).getTime() - new Date(right.dueAt).getTime());
const mandatoryResolved = checklist.reduce((sum, item) => sum + item.mandatoryResolved, 0);
const mandatoryTotal = checklist.reduce((sum, item) => sum + item.mandatoryTotal, 0);
const completionRate = mandatoryTotal > 0 ? Math.round((mandatoryResolved / mandatoryTotal) * 100) : 0;
const upcoming7Days = allDeadlines.filter((item) => {
const dueAt = new Date(item.dueAt);
return !Number.isNaN(dueAt.getTime()) && dueAt <= horizon;
}).length;
const criticalPending = calculateCriticalPending(activeProposals, allDeadlines, now);
const generatedAlerts = buildAlerts({
criticalPending,
deadlines: allDeadlines,
checklist,
now,
});
const alerts = [...generatedAlerts, ...(args.externalAlerts ?? [])];
const reviewed = args.m3.reviewed;
const interested = args.m3.interested;
const knownStatus = reviewed + interested;
const estimatedNew = Math.max(0, args.m3.totalOpenLicitations - knownStatus);
return {
generatedAt: now.toISOString(),
kpis: {
activeLicitations: activeProposals.length,
completionRate,
criticalPending,
upcoming7Days,
},
m3States: {
consulted: reviewed,
interested,
active: args.m3.activeLinked,
new: estimatedNew,
},
tabs: {
plazos: allDeadlines,
alertas: alerts,
checklist,
panelKpi: buildPanelRows({
activeLicitations: activeProposals.length,
completionRate,
criticalPending,
upcoming7Days,
reviewed,
interested,
}),
},
};
}

View File

@@ -0,0 +1,735 @@
import { createHash } from "node:crypto";
import { NormativeVerificationStatus, OfficialNormativeSourceType } from "@prisma/client";
import type {
ComplianceAlertView,
NormativeVerificationResultView,
OfficialNormativeSourceView,
OfficialNormativeSuggestionInput,
} from "@/lib/compliance/types";
import { prisma } from "@/lib/prisma";
type InternalVerificationState = {
lastKnownHash: string | null;
lastVerifiedAt: string | null;
nextCheckAt: string | null;
lastStatus: "success" | "warning" | "failed" | null;
lastMessage: string | null;
lastChangedAt: string | null;
};
const DEFAULT_CHECK_INTERVAL_DAYS = 7;
const VERIFY_TIMEOUT_MS = 15_000;
const TEST_ENV = process.env.NODE_ENV === "test";
const DEFAULT_OFFICIAL_SOURCES: OfficialNormativeSourceView[] = [
{
id: "nl-ley-adquisiciones-estatal",
stateCode: "NL",
stateName: "Nuevo Leon",
municipalityCode: null,
municipalityName: null,
authorityName: "Gobierno del Estado de Nuevo Leon",
title: "Ley de Adquisiciones, Arrendamientos y Contratacion de Servicios del Estado de Nuevo Leon",
officialUrl: "https://www.hcnl.gob.mx/trabajo_legislativo/leyes/",
sourceType: "ley",
versionLabel: null,
lastKnownHash: null,
lastVerifiedAt: null,
nextCheckAt: null,
isPilot: true,
},
{
id: "nl-reglamento-adquisiciones-estatal",
stateCode: "NL",
stateName: "Nuevo Leon",
municipalityCode: null,
municipalityName: null,
authorityName: "Gobierno del Estado de Nuevo Leon",
title: "Reglamento de la Ley de Adquisiciones del Estado de Nuevo Leon",
officialUrl: "https://www.nl.gob.mx/",
sourceType: "reglamento",
versionLabel: null,
lastKnownHash: null,
lastVerifiedAt: null,
nextCheckAt: null,
isPilot: true,
},
{
id: "spgg-reglamento-adquisiciones",
stateCode: "NL",
stateName: "Nuevo Leon",
municipalityCode: "SPGG",
municipalityName: "San Pedro Garza Garcia",
authorityName: "Municipio de San Pedro Garza Garcia",
title: "Reglamento de Adquisiciones y Contratacion de Servicios de San Pedro Garza Garcia",
officialUrl: "https://www.sanpedro.gob.mx/",
sourceType: "reglamento",
versionLabel: null,
lastKnownHash: null,
lastVerifiedAt: null,
nextCheckAt: null,
isPilot: true,
},
];
const inMemorySuggestions: Array<OfficialNormativeSuggestionInput & { id: string; createdAt: string }> = [];
const inMemoryVerificationState = new Map<string, InternalVerificationState>();
function addDaysIso(base: Date, days: number) {
const copy = new Date(base);
copy.setUTCDate(copy.getUTCDate() + days);
return copy.toISOString();
}
function normalize(value: string | null | undefined) {
return (value ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim();
}
function composeFingerprint(url: string, headers: Headers) {
const etag = headers.get("etag") ?? "";
const lastModified = headers.get("last-modified") ?? "";
const contentLength = headers.get("content-length") ?? "";
const contentType = headers.get("content-type") ?? "";
return `${url}|${etag}|${lastModified}|${contentLength}|${contentType}`;
}
function sha256(value: string) {
return createHash("sha256").update(value).digest("hex");
}
function toPrismaSourceType(value: OfficialNormativeSourceView["sourceType"]) {
if (value === "ley") {
return OfficialNormativeSourceType.LEY;
}
if (value === "reglamento") {
return OfficialNormativeSourceType.REGLAMENTO;
}
if (value === "lineamiento") {
return OfficialNormativeSourceType.LINEAMIENTO;
}
return OfficialNormativeSourceType.PORTAL;
}
function fromPrismaSourceType(value: OfficialNormativeSourceType): OfficialNormativeSourceView["sourceType"] {
if (value === OfficialNormativeSourceType.LEY) {
return "ley";
}
if (value === OfficialNormativeSourceType.REGLAMENTO) {
return "reglamento";
}
if (value === OfficialNormativeSourceType.LINEAMIENTO) {
return "lineamiento";
}
return "portal";
}
function toPrismaStatus(value: "success" | "warning" | "failed") {
if (value === "success") {
return NormativeVerificationStatus.SUCCESS;
}
if (value === "warning") {
return NormativeVerificationStatus.WARNING;
}
return NormativeVerificationStatus.FAILED;
}
function fromPrismaStatus(value: NormativeVerificationStatus | null): "success" | "warning" | "failed" | null {
if (!value) {
return null;
}
if (value === NormativeVerificationStatus.SUCCESS) {
return "success";
}
if (value === NormativeVerificationStatus.WARNING) {
return "warning";
}
return "failed";
}
function mapSourceRowToView(row: {
id: string;
stateCode: string;
stateName: string;
municipalityCode: string | null;
municipalityName: string | null;
authorityName: string;
title: string;
officialUrl: string;
sourceType: OfficialNormativeSourceType;
versionLabel: string | null;
lastKnownHash: string | null;
lastVerifiedAt: Date | null;
nextCheckAt: Date | null;
isPilot: boolean;
}) {
return {
id: row.id,
stateCode: row.stateCode,
stateName: row.stateName,
municipalityCode: row.municipalityCode,
municipalityName: row.municipalityName,
authorityName: row.authorityName,
title: row.title,
officialUrl: row.officialUrl,
sourceType: fromPrismaSourceType(row.sourceType),
versionLabel: row.versionLabel,
lastKnownHash: row.lastKnownHash,
lastVerifiedAt: row.lastVerifiedAt?.toISOString() ?? null,
nextCheckAt: row.nextCheckAt?.toISOString() ?? null,
isPilot: row.isPilot,
} satisfies OfficialNormativeSourceView;
}
async function fetchSourceMetadata(url: string) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
try {
const response = await fetch(url, {
method: "GET",
redirect: "follow",
signal: controller.signal,
});
const fingerprint = composeFingerprint(response.url || url, response.headers);
return {
ok: response.ok,
status: response.status,
hash: sha256(fingerprint),
finalUrl: response.url || url,
};
} finally {
clearTimeout(timer);
}
}
function filterSources(
sources: OfficialNormativeSourceView[],
filters?: {
stateCode?: string | null;
municipalityCode?: string | null;
pilotOnly?: boolean;
},
) {
const stateCode = normalize(filters?.stateCode);
const municipalityCode = normalize(filters?.municipalityCode);
return sources.filter((source) => {
if (filters?.pilotOnly && !source.isPilot) {
return false;
}
if (stateCode && normalize(source.stateCode) !== stateCode) {
return false;
}
if (municipalityCode && normalize(source.municipalityCode) !== municipalityCode) {
return false;
}
return true;
});
}
async function ensureDefaultSourcesPersisted() {
await Promise.all(
DEFAULT_OFFICIAL_SOURCES.map((source) =>
prisma.officialNormativeSource.upsert({
where: { id: source.id },
update: {
stateCode: source.stateCode,
stateName: source.stateName,
municipalityCode: source.municipalityCode,
municipalityName: source.municipalityName,
authorityName: source.authorityName,
title: source.title,
officialUrl: source.officialUrl,
sourceType: toPrismaSourceType(source.sourceType),
versionLabel: source.versionLabel,
isPilot: source.isPilot,
},
create: {
id: source.id,
stateCode: source.stateCode,
stateName: source.stateName,
municipalityCode: source.municipalityCode,
municipalityName: source.municipalityName,
authorityName: source.authorityName,
title: source.title,
officialUrl: source.officialUrl,
sourceType: toPrismaSourceType(source.sourceType),
versionLabel: source.versionLabel,
isPilot: source.isPilot,
},
}),
),
);
}
function listInMemorySources(filters?: {
stateCode?: string | null;
municipalityCode?: string | null;
pilotOnly?: boolean;
}) {
const sources = DEFAULT_OFFICIAL_SOURCES.map((source) => {
const state = inMemoryVerificationState.get(source.id);
return {
...source,
lastKnownHash: state?.lastKnownHash ?? source.lastKnownHash,
lastVerifiedAt: state?.lastVerifiedAt ?? source.lastVerifiedAt,
nextCheckAt: state?.nextCheckAt ?? source.nextCheckAt,
};
});
return filterSources(sources, filters);
}
export async function listOfficialNormativeSources(filters?: {
stateCode?: string | null;
municipalityCode?: string | null;
pilotOnly?: boolean;
}) {
if (TEST_ENV) {
return listInMemorySources(filters);
}
await ensureDefaultSourcesPersisted();
const rows = await prisma.officialNormativeSource.findMany({
orderBy: [{ stateCode: "asc" }, { municipalityCode: "asc" }, { title: "asc" }],
});
return filterSources(rows.map(mapSourceRowToView), filters);
}
export async function suggestOfficialNormativeSource(input: OfficialNormativeSuggestionInput) {
if (TEST_ENV) {
const suggestion = {
...input,
id: `suggestion-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
createdAt: new Date().toISOString(),
};
inMemorySuggestions.unshift(suggestion);
return suggestion;
}
const created = await prisma.officialNormativeSuggestion.create({
data: {
stateCode: input.stateCode,
municipalityCode: input.municipalityCode ?? null,
authorityName: input.authorityName,
title: input.title,
officialUrl: input.officialUrl,
sourceType: toPrismaSourceType(input.sourceType),
notes: input.notes ?? null,
},
select: {
id: true,
stateCode: true,
municipalityCode: true,
authorityName: true,
title: true,
officialUrl: true,
sourceType: true,
notes: true,
createdAt: true,
},
});
return {
id: created.id,
stateCode: created.stateCode,
municipalityCode: created.municipalityCode,
authorityName: created.authorityName,
title: created.title,
officialUrl: created.officialUrl,
sourceType: fromPrismaSourceType(created.sourceType),
notes: created.notes ?? undefined,
createdAt: created.createdAt.toISOString(),
};
}
export async function listOfficialNormativeSuggestions() {
if (TEST_ENV) {
return [...inMemorySuggestions];
}
const rows = await prisma.officialNormativeSuggestion.findMany({
orderBy: [{ createdAt: "desc" }],
take: 200,
select: {
id: true,
stateCode: true,
municipalityCode: true,
authorityName: true,
title: true,
officialUrl: true,
sourceType: true,
notes: true,
createdAt: true,
},
});
return rows.map((row) => ({
id: row.id,
stateCode: row.stateCode,
municipalityCode: row.municipalityCode,
authorityName: row.authorityName,
title: row.title,
officialUrl: row.officialUrl,
sourceType: fromPrismaSourceType(row.sourceType),
notes: row.notes ?? undefined,
createdAt: row.createdAt.toISOString(),
}));
}
export async function verifyOfficialNormativeSources(sourceIds?: string[]) {
const now = new Date();
if (TEST_ENV) {
const sources = sourceIds && sourceIds.length > 0 ? DEFAULT_OFFICIAL_SOURCES.filter((source) => sourceIds.includes(source.id)) : DEFAULT_OFFICIAL_SOURCES;
const results: NormativeVerificationResultView[] = [];
for (const source of sources) {
const previous = inMemoryVerificationState.get(source.id) ?? {
lastKnownHash: source.lastKnownHash,
lastVerifiedAt: source.lastVerifiedAt,
nextCheckAt: source.nextCheckAt,
lastStatus: null,
lastMessage: null,
lastChangedAt: null,
};
try {
const metadata = await fetchSourceMetadata(source.officialUrl);
const changed = Boolean(previous.lastKnownHash && metadata.hash !== previous.lastKnownHash);
const checkedAt = now.toISOString();
const status: "success" | "warning" | "failed" = metadata.ok ? (changed ? "warning" : "success") : "failed";
const message = metadata.ok
? changed
? "Se detecto cambio potencial en fuente normativa oficial."
: "Fuente normativa verificada sin cambios."
: "La fuente normativa no respondio correctamente.";
inMemoryVerificationState.set(source.id, {
lastKnownHash: metadata.hash,
lastVerifiedAt: checkedAt,
nextCheckAt: addDaysIso(now, DEFAULT_CHECK_INTERVAL_DAYS),
lastStatus: status,
lastMessage: message,
lastChangedAt: changed ? checkedAt : previous.lastChangedAt,
});
results.push({
sourceId: source.id,
checkedAt,
changed,
status,
httpStatus: metadata.status,
observedHash: metadata.hash,
message,
});
} catch (error) {
const checkedAt = now.toISOString();
const message = error instanceof Error ? error.message : "No fue posible verificar la fuente normativa.";
inMemoryVerificationState.set(source.id, {
lastKnownHash: previous.lastKnownHash ?? null,
lastVerifiedAt: checkedAt,
nextCheckAt: addDaysIso(now, 1),
lastStatus: "failed",
lastMessage: message,
lastChangedAt: previous.lastChangedAt ?? null,
});
results.push({
sourceId: source.id,
checkedAt,
changed: false,
status: "failed",
httpStatus: null,
observedHash: previous.lastKnownHash ?? null,
message,
});
}
}
return results;
}
await ensureDefaultSourcesPersisted();
const sources = await prisma.officialNormativeSource.findMany({
where: sourceIds && sourceIds.length > 0 ? { id: { in: sourceIds } } : undefined,
orderBy: [{ title: "asc" }],
select: {
id: true,
officialUrl: true,
lastKnownHash: true,
lastChangedAt: true,
},
});
const results: NormativeVerificationResultView[] = [];
for (const source of sources) {
try {
const metadata = await fetchSourceMetadata(source.officialUrl);
const changed = Boolean(source.lastKnownHash && metadata.hash !== source.lastKnownHash);
const checkedAt = now.toISOString();
const status: "success" | "warning" | "failed" = metadata.ok ? (changed ? "warning" : "success") : "failed";
const message = metadata.ok
? changed
? "Se detecto cambio potencial en fuente normativa oficial."
: "Fuente normativa verificada sin cambios."
: "La fuente normativa no respondio correctamente.";
await prisma.officialNormativeSource.update({
where: { id: source.id },
data: {
lastKnownHash: metadata.hash,
lastVerifiedAt: now,
nextCheckAt: new Date(addDaysIso(now, DEFAULT_CHECK_INTERVAL_DAYS)),
lastStatus: toPrismaStatus(status),
lastMessage: message,
lastChangedAt: changed ? now : source.lastChangedAt,
},
});
results.push({
sourceId: source.id,
checkedAt,
changed,
status,
httpStatus: metadata.status,
observedHash: metadata.hash,
message,
});
} catch (error) {
const message = error instanceof Error ? error.message : "No fue posible verificar la fuente normativa.";
await prisma.officialNormativeSource.update({
where: { id: source.id },
data: {
lastVerifiedAt: now,
nextCheckAt: new Date(addDaysIso(now, 1)),
lastStatus: NormativeVerificationStatus.FAILED,
lastMessage: message,
},
});
results.push({
sourceId: source.id,
checkedAt: now.toISOString(),
changed: false,
status: "failed",
httpStatus: null,
observedHash: source.lastKnownHash,
message,
});
}
}
return results;
}
export async function getNormativeSourcesDueForVerification(now = new Date()) {
const nowTime = now.getTime();
const sources = await listOfficialNormativeSources({ pilotOnly: true });
return sources
.filter((source) => {
if (!source.lastVerifiedAt || !source.nextCheckAt) {
return true;
}
const nextCheck = new Date(source.nextCheckAt);
if (Number.isNaN(nextCheck.getTime())) {
return true;
}
return nextCheck.getTime() <= nowTime;
})
.map((source) => source.id);
}
export async function runPeriodicNormativeVerification(now = new Date()) {
const dueSourceIds = await getNormativeSourcesDueForVerification(now);
if (dueSourceIds.length === 0) {
return {
checkedAt: now.toISOString(),
checked: false,
dueCount: 0,
results: [] as NormativeVerificationResultView[],
};
}
const results = await verifyOfficialNormativeSources(dueSourceIds);
return {
checkedAt: now.toISOString(),
checked: true,
dueCount: dueSourceIds.length,
results,
};
}
export async function listRegulationChangeAlerts(now = new Date(), lookbackDays = 30): Promise<ComplianceAlertView[]> {
const threshold = new Date(now);
threshold.setUTCDate(threshold.getUTCDate() - Math.max(1, lookbackDays));
if (TEST_ENV) {
const sourcesById = new Map(DEFAULT_OFFICIAL_SOURCES.map((source) => [source.id, source]));
const alerts: ComplianceAlertView[] = [];
for (const [sourceId, state] of inMemoryVerificationState.entries()) {
const source = sourcesById.get(sourceId);
if (!source || !state.lastVerifiedAt) {
continue;
}
const verifiedAt = new Date(state.lastVerifiedAt);
if (Number.isNaN(verifiedAt.getTime()) || verifiedAt < threshold) {
continue;
}
if (state.lastStatus === "warning") {
alerts.push({
id: `reg-update-${sourceId}`,
proposalId: null,
licitationId: sourceId,
title: "Cambio detectado en fuente normativa",
description: `${source.title}: ${state.lastMessage ?? "Se detectaron cambios en metadatos oficiales."}`,
severity: "high",
kind: "normative_regulation_update",
dueAt: state.lastVerifiedAt,
createdAt: state.lastVerifiedAt,
});
}
if (state.lastStatus === "failed") {
alerts.push({
id: `reg-failed-${sourceId}`,
proposalId: null,
licitationId: sourceId,
title: "Verificacion normativa fallida",
description: `${source.title}: ${state.lastMessage ?? "No fue posible verificar la fuente."}`,
severity: "medium",
kind: "normative_verification_pending",
dueAt: state.nextCheckAt,
createdAt: state.lastVerifiedAt,
});
}
}
return alerts;
}
await ensureDefaultSourcesPersisted();
const rows = await prisma.officialNormativeSource.findMany({
where: {
lastVerifiedAt: { gte: threshold },
lastStatus: { in: [NormativeVerificationStatus.WARNING, NormativeVerificationStatus.FAILED] },
},
orderBy: [{ lastVerifiedAt: "desc" }],
select: {
id: true,
title: true,
lastStatus: true,
lastMessage: true,
lastVerifiedAt: true,
nextCheckAt: true,
},
});
const alerts: ComplianceAlertView[] = [];
for (const row of rows) {
if (!row.lastVerifiedAt || !row.lastStatus) {
continue;
}
const status = fromPrismaStatus(row.lastStatus);
if (status === "warning") {
alerts.push({
id: `reg-update-${row.id}`,
proposalId: null,
licitationId: row.id,
title: "Cambio detectado en fuente normativa",
description: `${row.title}: ${row.lastMessage ?? "Se detectaron cambios en metadatos oficiales."}`,
severity: "high",
kind: "normative_regulation_update",
dueAt: row.lastVerifiedAt.toISOString(),
createdAt: row.lastVerifiedAt.toISOString(),
});
continue;
}
if (status === "failed") {
alerts.push({
id: `reg-failed-${row.id}`,
proposalId: null,
licitationId: row.id,
title: "Verificacion normativa fallida",
description: `${row.title}: ${row.lastMessage ?? "No fue posible verificar la fuente."}`,
severity: "medium",
kind: "normative_verification_pending",
dueAt: row.nextCheckAt?.toISOString() ?? null,
createdAt: row.lastVerifiedAt.toISOString(),
});
}
}
return alerts;
}
export async function getDueNormativeVerifications(now = new Date(), horizonDays = 7) {
const horizon = new Date(now);
horizon.setUTCDate(horizon.getUTCDate() + Math.max(0, horizonDays));
const sources = await listOfficialNormativeSources({ pilotOnly: true });
return sources
.map((source) => {
const dueAtIso = source.nextCheckAt ?? addDaysIso(now, 1);
const dueAt = new Date(dueAtIso);
if (Number.isNaN(dueAt.getTime())) {
return null;
}
if (dueAt > horizon) {
return null;
}
return {
sourceId: source.id,
sourceTitle: source.title,
authorityName: source.authorityName,
dueAt: dueAt.toISOString(),
overdue: dueAt.getTime() < now.getTime(),
};
})
.filter((item): item is { sourceId: string; sourceTitle: string; authorityName: string; dueAt: string; overdue: boolean } => Boolean(item))
.sort((left, right) => new Date(left.dueAt).getTime() - new Date(right.dueAt).getTime());
}
export async function resetRegulationsStateForTests() {
if (!TEST_ENV) {
return;
}
inMemoryVerificationState.clear();
inMemorySuggestions.splice(0, inMemorySuggestions.length);
}

View File

@@ -0,0 +1,168 @@
import "server-only";
import { prisma } from "@/lib/prisma";
import type { ComplianceAlertView } from "@/lib/compliance/types";
import { buildM7Dataset, computeM3Counters } from "@/lib/compliance/m7";
import { getDueNormativeVerifications, listRegulationChangeAlerts } from "@/lib/compliance/regulations";
export async function getM7DatasetForUser(userId: string, now = new Date()) {
const [proposals, preferences, totalOpenLicitations] = await Promise.all([
prisma.proposal.findMany({
where: {
userId,
},
select: {
id: true,
title: true,
status: true,
sourceLicitationId: true,
workflowDraft: true,
updatedAt: true,
},
orderBy: [{ updatedAt: "desc" }],
take: 200,
}),
prisma.licitationUserPreference.findMany({
where: {
userId,
},
select: {
status: true,
},
}),
prisma.licitation.count({
where: {
OR: [{ isOpen: true }, { closingDate: { gte: now } }],
municipality: {
isActive: true,
},
},
}),
]);
const activeLinked = new Set(
proposals
.filter((item) => item.status !== "ARCHIVED" && item.sourceLicitationId)
.map((item) => item.sourceLicitationId as string),
).size;
const m3 = computeM3Counters({
totalOpenLicitations,
preferences,
activeLinked,
});
const [dueVerifications, regulationAlerts, overdueDeliverables, disputedPayments, highSeverityCases] = await Promise.all([
getDueNormativeVerifications(now, 7),
listRegulationChangeAlerts(now, 30),
prisma.contractDeliverable.findMany({
where: {
contract: {
userId,
},
status: {
in: ["PENDING", "OVERDUE"],
},
dueDate: {
lt: now,
},
},
select: {
id: true,
title: true,
dueDate: true,
contract: {
select: {
id: true,
title: true,
},
},
},
orderBy: [{ dueDate: "asc" }],
take: 50,
}),
prisma.contractPayment.findMany({
where: {
contract: {
userId,
},
status: "DISPUTED",
},
select: {
id: true,
concept: true,
paymentDate: true,
amount: true,
contract: {
select: {
id: true,
title: true,
},
},
},
orderBy: [{ paymentDate: "desc" }],
take: 50,
}),
prisma.legalCase.findMany({
where: {
userId,
severity: "HIGH",
status: {
in: ["OPEN", "IN_PROGRESS", "ESCALATED"],
},
},
select: {
id: true,
caseType: true,
counterparty: true,
openedAt: true,
},
orderBy: [{ openedAt: "desc" }],
take: 50,
}),
]);
const crossModuleAlerts: ComplianceAlertView[] = [
...overdueDeliverables.map((item) => ({
id: `contract-deliverable-overdue-${item.id}`,
proposalId: null,
licitationId: item.contract.id,
title: `Entregable vencido: ${item.contract.title}`,
description: item.title,
severity: "high" as const,
kind: "contract_deliverable_overdue" as const,
dueAt: item.dueDate ? item.dueDate.toISOString() : null,
createdAt: now.toISOString(),
})),
...disputedPayments.map((item) => ({
id: `contract-payment-disputed-${item.id}`,
proposalId: null,
licitationId: item.contract.id,
title: `Pago en disputa: ${item.contract.title}`,
description: `${item.concept} (${Number(item.amount).toLocaleString("es-MX")} MXN)`,
severity: "high" as const,
kind: "contract_payment_disputed" as const,
dueAt: item.paymentDate.toISOString(),
createdAt: now.toISOString(),
})),
...highSeverityCases.map((item) => ({
id: `legal-high-severity-${item.id}`,
proposalId: null,
licitationId: null,
title: `Caso legal alta severidad: ${item.caseType}`,
description: `Contraparte: ${item.counterparty}`,
severity: "high" as const,
kind: "legal_case_high_severity" as const,
dueAt: item.openedAt.toISOString(),
createdAt: now.toISOString(),
})),
];
return buildM7Dataset({
proposals,
m3,
dueVerifications,
externalAlerts: [...regulationAlerts, ...crossModuleAlerts],
now,
});
}

View File

@@ -0,0 +1,133 @@
import type { SignaturePolicyEvaluation, SignaturePolicyMatrixEntry, SignaturePolicyStatus } from "@/lib/compliance/types";
const MATRIX: SignaturePolicyMatrixEntry[] = [
{
id: "nl-general-licitaciones",
stateCode: "NL",
stateName: "Nuevo Leon",
municipalityCode: null,
municipalityName: null,
appliesTo: "licitaciones",
policyStatus: "condicionado",
policyName: "Firma electronica avanzada condicionada por convocatoria",
evidenceRequired: [
"Verificacion expresa en Bases/Convocatoria de admision de firma electronica",
"Acuse de autenticidad o certificado vigente de la e.firma",
"Respaldo de representacion legal de firmantes",
],
notes: "Si no existe clausula expresa de admision, se escala a validacion legal interna.",
sourceUrl: "https://www.nl.gob.mx/",
},
{
id: "nl-san-pedro",
stateCode: "NL",
stateName: "Nuevo Leon",
municipalityCode: "SPGG",
municipalityName: "San Pedro Garza Garcia",
appliesTo: "licitaciones",
policyStatus: "requiere_validacion_legal",
policyName: "Municipio con validacion legal previa obligatoria",
evidenceRequired: [
"Reglamento municipal vigente de adquisiciones",
"Reglas de firma indicadas en convocatoria especifica",
"Opinion legal documentada antes de envio",
],
notes: "Configuracion conservadora para piloto: no asumir autorizacion automatica de firma electronica.",
sourceUrl: "https://www.sanpedro.gob.mx/",
},
];
function normalize(value: string | null | undefined) {
return (value ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim();
}
function isMatch(entry: SignaturePolicyMatrixEntry, input: { stateCode?: string | null; stateName?: string | null; municipalityName?: string | null }) {
const stateCode = normalize(input.stateCode);
const stateName = normalize(input.stateName);
const municipalityName = normalize(input.municipalityName);
const entryStateCode = normalize(entry.stateCode);
const entryStateName = normalize(entry.stateName);
const entryMunicipalityName = normalize(entry.municipalityName);
const stateMatches = (stateCode && stateCode === entryStateCode) || (stateName && stateName === entryStateName);
if (!stateMatches) {
return false;
}
if (!entryMunicipalityName) {
return true;
}
return Boolean(municipalityName && municipalityName === entryMunicipalityName);
}
export function evaluateSignaturePolicy(input: {
stateCode?: string | null;
stateName?: string | null;
municipalityName?: string | null;
documentType?: "BASES_LICITACION" | "CONVOCATORIA" | "ANEXOS" | "OTRO" | "REGLAMENTO" | "LEY";
}): SignaturePolicyEvaluation {
const exactMunicipality = MATRIX.find((entry) => Boolean(entry.municipalityName) && isMatch(entry, input));
const stateDefault = MATRIX.find((entry) => !entry.municipalityName && isMatch(entry, input));
const chosen = exactMunicipality ?? stateDefault;
if (!chosen) {
return {
policyStatus: "requiere_validacion_legal",
policyName: "Sin certeza regulatoria local",
evidenceRequired: [
"Reglamento oficial vigente de la jurisdiccion",
"Clausula explicita en la convocatoria sobre firma electronica",
"Opinion legal interna previa al envio",
],
notes: "Sin confirmacion oficial suficiente. Regla conservadora: requiere validacion legal.",
jurisdictionLabel: input.stateName?.trim() || "Jurisdiccion no definida",
sourceUrl: null,
};
}
const documentHint = input.documentType ? `Documento base: ${input.documentType}.` : "";
const jurisdictionLabel = chosen.municipalityName ? `${chosen.municipalityName}, ${chosen.stateName}` : chosen.stateName;
return {
policyStatus: chosen.policyStatus,
policyName: chosen.policyName,
evidenceRequired: chosen.evidenceRequired,
notes: `${chosen.notes}${documentHint ? ` ${documentHint}` : ""}`.trim(),
jurisdictionLabel,
sourceUrl: chosen.sourceUrl,
};
}
export function getSignaturePolicyStatusLabel(status: SignaturePolicyStatus) {
if (status === "permitido") {
return "Permitido";
}
if (status === "condicionado") {
return "Condicionado";
}
return "Requiere validacion legal";
}
export function getSignaturePolicyTone(status: SignaturePolicyStatus) {
if (status === "permitido") {
return "border-[#b9e6cd] bg-[#eaf9f1] text-[#1f8b63]";
}
if (status === "condicionado") {
return "border-[#f0deb0] bg-[#fff8e9] text-[#8d6308]";
}
return "border-[#f1c7ce] bg-[#fff1f4] text-[#b03f4f]";
}
export function listSignaturePolicyMatrix() {
return [...MATRIX];
}

156
src/lib/compliance/types.ts Normal file
View File

@@ -0,0 +1,156 @@
export type SignaturePolicyStatus = "permitido" | "condicionado" | "requiere_validacion_legal";
export type SignaturePolicyMatrixEntry = {
id: string;
stateCode: string;
stateName: string;
municipalityCode: string | null;
municipalityName: string | null;
appliesTo: "licitaciones" | "contratos" | "general";
policyStatus: SignaturePolicyStatus;
policyName: string;
evidenceRequired: string[];
notes: string;
sourceUrl: string | null;
};
export type SignaturePolicyEvaluation = {
policyStatus: SignaturePolicyStatus;
policyName: string;
evidenceRequired: string[];
notes: string;
jurisdictionLabel: string;
sourceUrl: string | null;
};
export type ComplianceAlertSeverity = "high" | "medium" | "low";
export type ComplianceAlertKind =
| "deadline_overdue"
| "deadline_soon"
| "critical_requirement_pending"
| "signature_policy"
| "normative_regulation_update"
| "normative_verification_pending"
| "contract_deliverable_overdue"
| "contract_payment_disputed"
| "legal_case_high_severity";
export type ComplianceAlertView = {
id: string;
proposalId: string | null;
licitationId: string | null;
title: string;
description: string;
severity: ComplianceAlertSeverity;
kind: ComplianceAlertKind;
dueAt: string | null;
createdAt: string;
};
export type ComplianceDeadlineItem = {
id: string;
proposalId: string | null;
licitationId: string | null;
title: string;
description: string;
dueAt: string;
source: "milestone" | "normative_verification";
status: "upcoming" | "overdue";
};
export type ComplianceChecklistItem = {
proposalId: string;
proposalTitle: string;
mandatoryResolved: number;
mandatoryTotal: number;
completionRate: number;
signaturePolicyStatus: SignaturePolicyStatus;
};
export type CompliancePanelItem = {
id: string;
label: string;
value: string;
tone: "neutral" | "success" | "warning" | "danger";
};
export type M7KpiSnapshot = {
activeLicitations: number;
completionRate: number;
criticalPending: number;
upcoming7Days: number;
};
export type M7Dataset = {
generatedAt: string;
kpis: M7KpiSnapshot;
m3States: {
consulted: number;
interested: number;
active: number;
new: number;
};
tabs: {
plazos: ComplianceDeadlineItem[];
alertas: ComplianceAlertView[];
checklist: ComplianceChecklistItem[];
panelKpi: CompliancePanelItem[];
};
aiPlaybook?: M7AiPlaybook | null;
};
export 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";
suggestionId: string;
};
export type OfficialNormativeSourceView = {
id: string;
stateCode: string;
stateName: string;
municipalityCode: string | null;
municipalityName: string | null;
authorityName: string;
title: string;
officialUrl: string;
sourceType: "ley" | "reglamento" | "lineamiento" | "portal";
versionLabel: string | null;
lastKnownHash: string | null;
lastVerifiedAt: string | null;
nextCheckAt: string | null;
isPilot: boolean;
};
export type OfficialNormativeSuggestionInput = {
stateCode: string;
municipalityCode?: string | null;
authorityName: string;
title: string;
officialUrl: string;
sourceType: OfficialNormativeSourceView["sourceType"];
notes?: string;
};
export type NormativeVerificationResultView = {
sourceId: string;
checkedAt: string;
changed: boolean;
status: "success" | "warning" | "failed";
httpStatus: number | null;
observedHash: string | null;
message: string;
};

View File

@@ -0,0 +1,203 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { prismaMock, readStoredProposalDocumentMock, storeContractDocumentFileMock } = vi.hoisted(() => ({
prismaMock: {
proposal: {
findFirst: vi.fn(),
},
contractDocument: {
findMany: vi.fn(),
create: vi.fn(),
},
},
readStoredProposalDocumentMock: vi.fn(),
storeContractDocumentFileMock: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({
prisma: prismaMock,
}));
vi.mock("@/lib/proposals/storage", async () => {
const actual = await vi.importActual<typeof import("@/lib/proposals/storage")>("@/lib/proposals/storage");
return {
...actual,
readStoredProposalDocument: readStoredProposalDocumentMock,
};
});
vi.mock("@/lib/contracts/storage", async () => {
const actual = await vi.importActual<typeof import("@/lib/contracts/storage")>("@/lib/contracts/storage");
return {
...actual,
storeContractDocumentFile: storeContractDocumentFileMock,
};
});
import {
ensureProposalPdfLinkedToContract,
getLatestProposalPdfSourceForUser,
isPdfProposalDocument,
} from "@/lib/contracts/proposal-continuity";
describe("proposal continuity", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("detects PDF proposal documents by mime type or file extension", () => {
expect(isPdfProposalDocument({ fileName: "source.pdf", mimeType: "application/octet-stream" })).toBe(true);
expect(isPdfProposalDocument({ fileName: "source.bin", mimeType: "application/pdf" })).toBe(true);
expect(isPdfProposalDocument({ fileName: "source.docx", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" })).toBe(
false,
);
});
it("returns proposal_not_found when proposal does not belong to user", async () => {
prismaMock.proposal.findFirst.mockResolvedValue(null);
const result = await getLatestProposalPdfSourceForUser("user-1", "proposal-1");
expect(result).toEqual({ status: "proposal_not_found" });
expect(readStoredProposalDocumentMock).not.toHaveBeenCalled();
});
it("returns pdf_not_found when proposal has no PDF document", async () => {
prismaMock.proposal.findFirst.mockResolvedValue({
id: "proposal-1",
title: "Propuesta sin PDF",
documents: [{ id: "doc-1", fileName: "evidencia.docx", filePath: "storage/proposals/u/p/doc-1.docx", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", sizeBytes: 20, checksumSha256: null, createdAt: new Date() }],
});
const result = await getLatestProposalPdfSourceForUser("user-1", "proposal-1");
expect(result).toEqual({
status: "pdf_not_found",
proposal: { id: "proposal-1", title: "Propuesta sin PDF" },
});
expect(readStoredProposalDocumentMock).not.toHaveBeenCalled();
});
it("returns PDF source when proposal has a PDF document", async () => {
const fileBuffer = Buffer.from("pdf");
prismaMock.proposal.findFirst.mockResolvedValue({
id: "proposal-1",
title: "Propuesta con PDF",
documents: [
{
id: "doc-2",
fileName: "contrato.pdf",
filePath: "storage/proposals/u/p/doc-2.pdf",
mimeType: "application/pdf",
sizeBytes: 120,
checksumSha256: "sha-source",
createdAt: new Date(),
},
],
});
readStoredProposalDocumentMock.mockResolvedValue(fileBuffer);
const result = await getLatestProposalPdfSourceForUser("user-1", "proposal-1");
expect(result).toEqual({
status: "ok",
source: {
proposal: { id: "proposal-1", title: "Propuesta con PDF" },
document: {
id: "doc-2",
fileName: "contrato.pdf",
filePath: "storage/proposals/u/p/doc-2.pdf",
mimeType: "application/pdf",
sizeBytes: 120,
checksumSha256: "sha-source",
createdAt: expect.any(Date),
},
fileBuffer,
},
});
expect(readStoredProposalDocumentMock).toHaveBeenCalledTimes(1);
expect(readStoredProposalDocumentMock.mock.calls[0]?.[0]).toBe("storage/proposals/u/p/doc-2.pdf");
});
it("avoids creating duplicate contract document when checksum matches", async () => {
prismaMock.contractDocument.findMany.mockResolvedValue([
{
id: "existing-doc",
fileName: "contrato.pdf",
sizeBytes: 120,
checksumSha256: "sha-source",
},
]);
const result = await ensureProposalPdfLinkedToContract({
userId: "user-1",
contractId: "contract-1",
source: {
proposal: { id: "proposal-1", title: "Propuesta" },
document: {
id: "proposal-doc",
fileName: "contrato.pdf",
filePath: "storage/proposals/u/p/doc.pdf",
mimeType: "application/pdf",
sizeBytes: 120,
checksumSha256: "sha-source",
createdAt: new Date(),
},
fileBuffer: Buffer.from("pdf"),
},
});
expect(result).toEqual({ created: false, documentId: "existing-doc" });
expect(storeContractDocumentFileMock).not.toHaveBeenCalled();
expect(prismaMock.contractDocument.create).not.toHaveBeenCalled();
});
it("stores and links proposal PDF when no duplicate exists", async () => {
prismaMock.contractDocument.findMany.mockResolvedValue([]);
storeContractDocumentFileMock.mockResolvedValue({
fileName: "contract-copy.pdf",
filePath: "storage/contracts/user-1/contract-1/copy.pdf",
mimeType: "application/pdf",
sizeBytes: 120,
checksumSha256: "sha-copy",
});
prismaMock.contractDocument.create.mockResolvedValue({
id: "new-doc",
});
const result = await ensureProposalPdfLinkedToContract({
userId: "user-1",
contractId: "contract-1",
source: {
proposal: { id: "proposal-1", title: "Propuesta" },
document: {
id: "proposal-doc",
fileName: "contrato.pdf",
filePath: "storage/proposals/u/p/doc.pdf",
mimeType: "application/pdf",
sizeBytes: 120,
checksumSha256: "sha-source",
createdAt: new Date(),
},
fileBuffer: Buffer.from("pdf"),
},
});
expect(result).toEqual({ created: true, documentId: "new-doc" });
expect(storeContractDocumentFileMock).toHaveBeenCalledWith("user-1", "contract-1", "contrato.pdf", "application/pdf", expect.any(Buffer));
expect(prismaMock.contractDocument.create).toHaveBeenCalledWith({
data: {
contractId: "contract-1",
fileName: "contract-copy.pdf",
filePath: "storage/contracts/user-1/contract-1/copy.pdf",
mimeType: "application/pdf",
sizeBytes: 120,
checksumSha256: "sha-copy",
kind: "SIGNED_CONTRACT",
},
select: {
id: true,
},
});
});
});

View File

@@ -0,0 +1,458 @@
import { z } from "zod";
import type { ContractAiExtractionOutput, ContractRiskClause } from "@/lib/contracts/types";
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
const DEFAULT_OPENAI_MODEL = "gpt-4.1-mini";
const DEFAULT_TIMEOUT_MS = 60_000;
const DEFAULT_MAX_TEXT_CHARS = 45_000;
const ContractExtractionResponseSchema = z.object({
fields: z
.object({
title: z.string().nullable().optional(),
counterpartyEntity: z.string().nullable().optional(),
contractNumber: z.string().nullable().optional(),
contractType: z.string().nullable().optional(),
startDate: z.string().nullable().optional(),
endDate: z.string().nullable().optional(),
totalAmount: z.union([z.number(), z.string()]).nullable().optional(),
currency: z.string().nullable().optional(),
description: z.string().nullable().optional(),
})
.nullable()
.optional(),
deliverables: z
.array(
z.object({
title: z.string().nullable().optional(),
dueDate: z.string().nullable().optional(),
amountLinked: z.union([z.number(), z.string()]).nullable().optional(),
notes: z.string().nullable().optional(),
}),
)
.nullable()
.optional(),
paymentMilestones: z
.array(
z.object({
concept: z.string().nullable().optional(),
paymentDate: z.string().nullable().optional(),
amount: z.union([z.number(), z.string()]).nullable().optional(),
}),
)
.nullable()
.optional(),
riskClauses: z
.array(
z.object({
title: z.string().nullable().optional(),
snippet: z.string().nullable().optional(),
riskLevel: z.enum(["low", "medium", "high"]).nullable().optional(),
}),
)
.nullable()
.optional(),
});
type OpenAiChatCompletionResponse = {
choices?: Array<{
message?: {
content?: string | null;
};
}>;
model?: string;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
error?: {
message?: string;
};
};
function parsePositiveInteger(value: string | undefined, fallback: number) {
const parsed = Number.parseInt((value ?? "").trim(), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function getOpenAiApiKey() {
return process.env.OPENAI_API_KEY?.trim() || process.env.API_KEY?.trim() || process.env.api_key?.trim() || "";
}
function compactText(value: string, maxLength = 600) {
const compacted = value.replace(/\s+/g, " ").trim();
if (!compacted) {
return null;
}
return compacted.length > maxLength ? compacted.slice(0, maxLength) : compacted;
}
function normalizeIsoDate(value: unknown): string | null {
if (!value || typeof value !== "string") {
return null;
}
const compact = value.trim();
if (!compact) {
return null;
}
const parsed = new Date(compact);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed.toISOString();
}
function normalizeAmount(value: unknown): number | null {
if (typeof value === "number") {
return Number.isFinite(value) ? Number(value.toFixed(2)) : null;
}
if (typeof value !== "string") {
return null;
}
const normalized = value
.replace(/[^\d.,-]/g, "")
.replace(/\.(?=.*\.)/g, "")
.replace(/,/g, ".");
const parsed = Number.parseFloat(normalized);
if (!Number.isFinite(parsed)) {
return null;
}
return Number(parsed.toFixed(2));
}
function clampText(fullText: string) {
const maxChars = parsePositiveInteger(process.env.OPENAI_CONTRACT_MAX_CHARS, DEFAULT_MAX_TEXT_CHARS);
if (fullText.length <= maxChars) {
return fullText;
}
return `${fullText.slice(0, maxChars)}\n\n[TEXT_TRUNCATED_TO_${maxChars}_CHARS]`;
}
function extractJsonObject(raw: string) {
const trimmed = raw.trim();
try {
return JSON.parse(trimmed) as unknown;
} catch {
// Continue with alternative parsing.
}
const noFence = trimmed
.replace(/^```json\s*/i, "")
.replace(/^```\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
try {
return JSON.parse(noFence) as unknown;
} catch {
// Continue with slicing.
}
const firstBrace = noFence.indexOf("{");
const lastBrace = noFence.lastIndexOf("}");
if (firstBrace >= 0 && lastBrace > firstBrace) {
return JSON.parse(noFence.slice(firstBrace, lastBrace + 1)) as unknown;
}
throw new Error("La respuesta de IA no contiene JSON valido.");
}
function inferContractNumber(text: string) {
const match = text.match(/(?:contrato|numero\s+de\s+contrato|expediente)\s*(?:no\.?|num(?:ero)?\.?|:)\s*([a-z0-9\-_/]{4,40})/i);
return compactText(match?.[1] ?? "", 80);
}
function inferAmount(text: string) {
const match = text.match(/(?:monto\s+total|importe\s+total|total\s+a\s+pagar)\s*[:\-]?\s*\$?\s*([\d.,]{4,20})/i);
return normalizeAmount(match?.[1] ?? null);
}
function inferCounterparty(text: string) {
const match = text.match(/(?:entidad\s+contratante|dependencia|contratante)\s*[:\-]\s*([^\n]{4,160})/i);
return compactText(match?.[1] ?? "", 220);
}
function inferTitle(text: string) {
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const contractLine = lines.find((line) => /\bcontrato\b/i.test(line));
return compactText(contractLine ?? lines[0] ?? "Contrato sin titulo", 220);
}
function inferContractType(text: string) {
if (/obra\s+publica/i.test(text)) {
return "Obra publica";
}
if (/servicio/i.test(text)) {
return "Servicios";
}
if (/adquisicion/i.test(text)) {
return "Adquisiciones";
}
return "General";
}
function inferDeliverables(text: string) {
const lines = text.split(/\r?\n/);
const rows = lines
.filter((line) => /entregable|hito|entrega/i.test(line))
.slice(0, 12)
.map((line) => ({
title: compactText(line, 220) ?? "Entregable detectado",
dueDate: null,
amountLinked: null,
notes: null,
}));
return rows;
}
function inferPaymentMilestones(text: string) {
const lines = text.split(/\r?\n/);
const rows = lines
.filter((line) => /pago|anticipo|estimacion|factura/i.test(line))
.slice(0, 12)
.map((line) => ({
concept: compactText(line, 220) ?? "Hito de pago detectado",
paymentDate: null,
amount: null,
}));
return rows;
}
function inferRiskClauses(text: string): ContractRiskClause[] {
const lines = text.split(/\r?\n/);
const catalog: Array<{ key: string; title: string; riskLevel: "medium" | "high" }> = [
{ key: "penalizacion", title: "Penalizaciones economicas", riskLevel: "high" },
{ key: "rescision", title: "Causal de rescision", riskLevel: "high" },
{ key: "garantia", title: "Garantias y fianzas", riskLevel: "medium" },
{ key: "retencion", title: "Retencion de pagos", riskLevel: "high" },
{ key: "sancion", title: "Sanciones administrativas", riskLevel: "high" },
];
const matches: ContractRiskClause[] = [];
for (const item of catalog) {
const foundLine = lines.find((line) => line.toLowerCase().includes(item.key));
if (!foundLine) {
continue;
}
matches.push({
title: item.title,
snippet: compactText(foundLine, 320) ?? item.title,
riskLevel: item.riskLevel,
requiresLegalValidation: true,
});
}
return matches;
}
function buildRegexFallback(fullText: string): ContractAiExtractionOutput {
const riskClauses = inferRiskClauses(fullText);
return {
fields: {
title: inferTitle(fullText),
counterpartyEntity: inferCounterparty(fullText),
contractNumber: inferContractNumber(fullText),
contractType: inferContractType(fullText),
startDate: null,
endDate: null,
totalAmount: inferAmount(fullText),
currency: "MXN",
description: compactText(fullText, 800),
},
deliverables: inferDeliverables(fullText),
paymentMilestones: inferPaymentMilestones(fullText),
riskClauses,
warnings: [
{
code: "CONTRACT_EXTRACTION_FALLBACK",
message: "Extraccion parcial con heuristicas. Requiere validacion legal.",
},
],
engine: "regex_fallback",
model: null,
usage: null,
};
}
function normalizeAiOutput(payload: z.infer<typeof ContractExtractionResponseSchema>): ContractAiExtractionOutput {
const fallback = buildRegexFallback("");
const fields = payload.fields ?? {};
const deliverables = Array.isArray(payload.deliverables) ? payload.deliverables : [];
const paymentMilestones = Array.isArray(payload.paymentMilestones) ? payload.paymentMilestones : [];
const riskClauses = Array.isArray(payload.riskClauses) ? payload.riskClauses : [];
return {
fields: {
title: compactText(fields.title ?? "", 220),
counterpartyEntity: compactText(fields.counterpartyEntity ?? "", 220),
contractNumber: compactText(fields.contractNumber ?? "", 120),
contractType: compactText(fields.contractType ?? "", 120),
startDate: normalizeIsoDate(fields.startDate),
endDate: normalizeIsoDate(fields.endDate),
totalAmount: normalizeAmount(fields.totalAmount),
currency: compactText(fields.currency ?? "", 10) ?? "MXN",
description: compactText(fields.description ?? "", 1200),
},
deliverables: deliverables
.map((item) => ({
title: compactText(item.title ?? "", 220),
dueDate: normalizeIsoDate(item.dueDate),
amountLinked: normalizeAmount(item.amountLinked),
notes: compactText(item.notes ?? "", 500),
}))
.filter((item): item is { title: string; dueDate: string | null; amountLinked: number | null; notes: string | null } => Boolean(item.title)),
paymentMilestones: paymentMilestones
.map((item) => ({
concept: compactText(item.concept ?? "", 220),
paymentDate: normalizeIsoDate(item.paymentDate),
amount: normalizeAmount(item.amount),
}))
.filter((item): item is { concept: string; paymentDate: string | null; amount: number | null } => Boolean(item.concept)),
riskClauses: riskClauses
.map((item) => ({
title: compactText(item.title ?? "", 160),
snippet: compactText(item.snippet ?? "", 320),
riskLevel: item.riskLevel ?? "medium",
requiresLegalValidation: true,
}))
.filter((item): item is ContractRiskClause => Boolean(item.title) && Boolean(item.snippet)),
warnings: fallback.warnings,
engine: "openai_json_object",
model: null,
usage: null,
};
}
async function callOpenAi(fullText: string) {
const apiKey = getOpenAiApiKey();
if (!apiKey) {
throw new Error("No se encontro API key para OpenAI.");
}
const model = process.env.OPENAI_CONTRACT_MODEL?.trim() || DEFAULT_OPENAI_MODEL;
const timeoutMs = parsePositiveInteger(process.env.OPENAI_CONTRACT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
const baseUrl = (process.env.OPENAI_API_BASE_URL?.trim() || DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
const systemPrompt = [
"Eres analista juridico especializado en contratos publicos mexicanos.",
"Responde unicamente JSON valido.",
"Si no hay certeza en un campo usa null.",
"Identifica riesgos contractuales y marca enfoque conservador.",
].join(" ");
const userPrompt = [
"Extrae campos del contrato y devuelve JSON con claves: fields, deliverables, paymentMilestones, riskClauses.",
"fields: title,counterpartyEntity,contractNumber,contractType,startDate,endDate,totalAmount,currency,description.",
"deliverables: [{title,dueDate,amountLinked,notes}]",
"paymentMilestones: [{concept,paymentDate,amount}]",
"riskClauses: [{title,snippet,riskLevel}]",
"Texto:",
clampText(fullText),
].join("\n\n");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: 0,
response_format: {
type: "json_object",
},
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
}),
signal: controller.signal,
});
const payload = (await response.json().catch(() => ({}))) as OpenAiChatCompletionResponse;
if (!response.ok) {
throw new Error(payload.error?.message ?? `OpenAI error ${response.status}`);
}
const content = payload.choices?.[0]?.message?.content;
if (!content) {
throw new Error("OpenAI no devolvio contenido util.");
}
const parsed = ContractExtractionResponseSchema.parse(extractJsonObject(content));
const normalized = normalizeAiOutput(parsed);
return {
...normalized,
engine: "openai_json_object" as const,
model: payload.model ?? model,
usage: {
promptTokens: payload.usage?.prompt_tokens ?? null,
completionTokens: payload.usage?.completion_tokens ?? null,
totalTokens: payload.usage?.total_tokens ?? null,
},
warnings: [
{
code: "LEGAL_VALIDATION_REQUIRED",
message: "Resultado automatizado. Requiere validacion legal antes de uso formal.",
},
],
};
} finally {
clearTimeout(timer);
}
}
export async function extractContractWithAi(fullText: string): Promise<ContractAiExtractionOutput> {
const cleaned = fullText.trim();
if (!cleaned) {
return buildRegexFallback(fullText);
}
try {
return await callOpenAi(cleaned);
} catch (error) {
const fallback = buildRegexFallback(cleaned);
fallback.warnings.push({
code: "OPENAI_CONTRACT_EXTRACTION_FAILED",
message: error instanceof Error ? error.message : "No fue posible usar IA para extraer el contrato.",
});
return fallback;
}
}

Some files were not shown because too many files have changed in this diff Show More