Files
genesis-2/diff.txt
T
Guillermo.Arrieta 80a52911e3 fix(functions): clasificar y estandarizar errores en ai-generate-plan
- Corregido el problema donde la función devolvía siempre 400; ahora se lanzan HttpError con status adecuados (405, 401, 415, 422, 404, 409, 502, 500) según el caso.
- Refactor: usar sendError/sendSuccess y HttpError desde _shared/utils.ts para respuestas consistentes y logging seguro.
- Validación: agrupar errores de parse/validación en un único mensaje numerado (cada error en nueva línea).
- OpenAI: manejo explícito de fallos (MissingEnv, JSON inválido, salida estructurada ausente) y mapeo a status apropiados.
- Supabase: distinguir errores 404/409/500 en consultas e inserts; incluir detalles en internalDetails para diagnóstico server-side.
- Eliminada dependencia de invocación HTTP a ai-structured; usar servicio compartido directo.
- Ajustes menores en mensajes y configuración (deno imports/deno.json) y tests marcados como ignorados donde corresponde.
2026-02-05 12:00:31 -06:00

378 lines
26 KiB
Plaintext

diff --git a/diff.txt b/diff.txt
index 864c396..e69de29 100644
Binary files a/diff.txt and b/diff.txt differ
diff --git a/supabase/functions/ai-generate-plan/deno.json b/supabase/functions/ai-generate-plan/deno.json
index f5db280..8b204ca 100644
--- a/supabase/functions/ai-generate-plan/deno.json
+++ b/supabase/functions/ai-generate-plan/deno.json
@@ -1,5 +1,8 @@
{
"imports": {
- "zod": "https://deno.land/x/zod@v3.22.4/mod.ts"
+ "zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
+ "openai": "npm:openai@6.16.0",
+ "@supabase/supabase-js": "npm:@supabase/supabase-js@2",
+ "@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts"
}
}
diff --git a/supabase/functions/ai-generate-plan/index.ts b/supabase/functions/ai-generate-plan/index.ts
index f4e61c1..0f3185b 100644
--- a/supabase/functions/ai-generate-plan/index.ts
+++ b/supabase/functions/ai-generate-plan/index.ts
@@ -5,6 +5,7 @@
// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { corsHeaders } from "../_shared/cors.ts";
+import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts";
import { createClient } from "npm:@supabase/supabase-js@2";
import type { Database, Json } from "../_shared/database.types.ts";
import type { AIGeneratePlanInput } from "./types.ts";
@@ -18,7 +19,7 @@ type NivelType =
type TipoCicloType =
Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"];
-Deno.serve(async (req) => {
+Deno.serve(async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const functionName = url.pathname.split("/").pop();
console.log(
@@ -37,12 +38,11 @@ Deno.serve(async (req) => {
new Date().toISOString()
}][${functionName}]: Invalid method: ${method}`,
);
- return new Response(
- JSON.stringify({ error: "Method not allowed" }),
- {
- headers: { ...corsHeaders, "Content-Type": "application/json" },
- status: 405,
- },
+ throw new HttpError(
+ 405,
+ "M├®todo no permitido.",
+ "METHOD_NOT_ALLOWED",
+ { method },
);
}
@@ -54,12 +54,11 @@ Deno.serve(async (req) => {
new Date().toISOString()
}][${functionName}]: Missing Authorization header`,
);
- return new Response(
- JSON.stringify({ error: "Authorization header missing" }),
- {
- headers: { ...corsHeaders, "Content-Type": "application/json" },
- status: 401,
- },
+ throw new HttpError(
+ 401,
+ "No autorizado.",
+ "UNAUTHORIZED",
+ { reason: "missing_authorization_header" },
);
}
@@ -70,19 +69,28 @@ Deno.serve(async (req) => {
new Date().toISOString()
}][${functionName}]: Unsupported content type: ${contentType}`,
);
- return new Response(
- JSON.stringify({ error: "Unsupported content type" }),
- {
- headers: { ...corsHeaders, "Content-Type": "application/json" },
- status: 400,
- },
+ throw new HttpError(
+ 415,
+ "Content-Type no soportado.",
+ "UNSUPPORTED_MEDIA_TYPE",
+ { contentType },
);
}
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
- throw new Error("Supabase environment variables are not set");
+ throw new HttpError(
+ 500,
+ "Configuraci├│n del servidor incompleta.",
+ "MISSING_ENV",
+ {
+ missing: [
+ !SUPABASE_URL ? "SUPABASE_URL" : null,
+ !SUPABASE_ANON_KEY ? "SUPABASE_ANON_KEY" : null,
+ ].filter(Boolean),
+ },
+ );
}
// If needed for RLS-protected reads, create an anon client with user's JWT
@@ -97,7 +105,12 @@ Deno.serve(async (req) => {
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!SERVICE_ROLE_KEY) {
- throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set");
+ throw new HttpError(
+ 500,
+ "Configuraci├│n del servidor incompleta.",
+ "MISSING_ENV",
+ { missing: ["SUPABASE_SERVICE_ROLE_KEY"] },
+ );
}
const supabaseService = createClient<Database>(
@@ -112,12 +125,15 @@ Deno.serve(async (req) => {
`[${new Date().toISOString()}][${functionName}]: Validation errors:`,
validation.errors,
);
- return new Response(
- JSON.stringify({ errors: validation.errors }),
- {
- headers: { ...corsHeaders, "Content-Type": "application/json" },
- status: 400,
- },
+ const message = validation.errors
+ .map((e, i) => `(${i + 1}) ${e}`)
+ .join("\n");
+
+ throw new HttpError(
+ 422,
+ message,
+ "VALIDATION_ERROR",
+ { errors: validation.errors },
);
}
@@ -130,8 +146,23 @@ Deno.serve(async (req) => {
.eq("id", payload.datosBasicos.estructuraPlanId)
.single();
if (estructuraPlanError) {
- throw new Error(
- "Error fetching estructuraPlan: " + estructuraPlanError.message,
+ const maybeCode = (estructuraPlanError as { code?: string }).code;
+ if (maybeCode === "PGRST116") {
+ throw new HttpError(
+ 404,
+ "No se encontr├│ la estructura del plan.",
+ "NOT_FOUND",
+ {
+ table: "estructuras_plan",
+ id: payload.datosBasicos.estructuraPlanId,
+ },
+ );
+ }
+ throw new HttpError(
+ 500,
+ "No se pudo obtener la estructura del plan.",
+ "SUPABASE_QUERY_FAILED",
+ estructuraPlanError,
);
}
@@ -172,25 +203,64 @@ Deno.serve(async (req) => {
// Use shared OpenAI service directly (no HTTP invoke)
const svc = OpenAIService.fromEnv();
if (!(svc instanceof OpenAIService)) {
- throw new Error(`OpenAI service misconfiguration: ${svc.message}`);
+ throw new HttpError(
+ 500,
+ "Configuraci├│n del servidor incompleta.",
+ "OPENAI_MISCONFIGURED",
+ svc,
+ );
}
+ // INICIO DE CÓDIGO PARA DEBBUGGING
+ // console.log(
+ // `[${
+ // new Date().toISOString()
+ // }][${functionName}]: Request processed successfully`,
+ // );
+ // const { data: plan_debug } = await supabaseService
+ // .from("planes_estudio")
+ // .select("*")
+ // .eq("id", "7ce657b1-1abf-4972-858d-5fffe1d51499")
+ // .maybeSingle();
+ // return sendSuccess(plan_debug);
+ // FIN DE CÓDIGO PARA DEBBUGGING
+
const aiResult = await svc.createStructuredResponse(
aiStructuredPayload,
payload.archivosAdjuntos,
);
if (!aiResult.ok) {
- throw new Error(
- `OpenAI call failed [${aiResult.code}]: ${aiResult.message}`,
+ const status = aiResult.code === "MissingEnv" ? 500 : 502;
+ throw new HttpError(
+ status,
+ "No se pudo generar el plan con IA.",
+ "OPENAI_REQUEST_FAILED",
+ aiResult,
);
}
// Prefer parsed output; fallback to parse outputText
- const aiOutput = aiResult.output ??
- (aiResult.outputText ? JSON.parse(aiResult.outputText) : null);
+ let aiOutput = aiResult.output ?? null;
+ if (aiOutput == null && aiResult.outputText) {
+ try {
+ aiOutput = JSON.parse(aiResult.outputText);
+ } catch {
+ throw new HttpError(
+ 502,
+ "La respuesta de la IA no es JSON válido.",
+ "OPENAI_INVALID_JSON",
+ { outputText: aiResult.outputText },
+ );
+ }
+ }
const aiOutputJson: Json = aiOutput as unknown as Json;
if (!aiOutput) {
- throw new Error("OpenAI response did not contain structured output");
+ throw new HttpError(
+ 502,
+ "La respuesta de la IA no contiene salida estructurada.",
+ "OPENAI_MISSING_STRUCTURED_OUTPUT",
+ { outputText: aiResult.outputText ?? null },
+ );
}
// TODO: Insertar interacciones con IA y quizas forzar a que la informacion de datosBasicos sea la misma que la recibida
@@ -209,7 +279,20 @@ Deno.serve(async (req) => {
.eq("id", payload.datosBasicos.carreraId)
.maybeSingle();
if (carreraError) {
- throw new Error("Error fetching carrera: " + carreraError.message);
+ throw new HttpError(
+ 500,
+ "No se pudo obtener la carrera.",
+ "SUPABASE_QUERY_FAILED",
+ carreraError,
+ );
+ }
+ if (!carrera) {
+ throw new HttpError(
+ 404,
+ "No se encontr├│ la carrera.",
+ "NOT_FOUND",
+ { table: "carreras", id: payload.datosBasicos.carreraId },
+ );
}
const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] =
@@ -257,42 +340,62 @@ Deno.serve(async (req) => {
// TODO: interaccion con IA y cambio de estado
if (planError) {
- throw new Error("Error inserting plan: " + planError.message);
+ const maybeCode = (planError as { code?: string }).code;
+ // Common cases:
+ // - foreign key / constraint violations -> 409
+ // - others -> 500
+ const status = maybeCode ? 409 : 500;
+ throw new HttpError(
+ status,
+ "No se pudo guardar el plan de estudios.",
+ "SUPABASE_INSERT_FAILED",
+ { ...planError, code: maybeCode },
+ );
}
// TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado
- const jsonResponse = {
- ok: true,
- plan,
- };
console.log(
`[${
new Date().toISOString()
}][${functionName}]: Request processed successfully`,
);
- return new Response(
- JSON.stringify(jsonResponse),
- {
- headers: { ...corsHeaders, "Content-Type": "application/json" },
- status: 200,
- },
- );
+ return sendSuccess(plan);
} catch (error) {
- // Log full error server-side for diagnostics
- if (error instanceof Error) {
+ if (error instanceof HttpError) {
console.error(
- `[${
- new Date().toISOString()
- }][${functionName}]: Request handler error:`,
- error.message,
+ `[${new Date().toISOString()}][${functionName}] ÔÜá´©Å Handled Error:`,
+ {
+ message: error.message,
+ code: error.code,
+ internalDetails: error.internalDetails || "N/A",
+ },
);
+
+ // RESPONSE: Solo enviamos el mensaje limpio y el c├│digo
+ return sendError(error.status, error.message, error.code);
}
- const message = error instanceof Error ? error.message : String(error);
- return new Response(JSON.stringify({ error: message }), {
- headers: { ...corsHeaders, "Content-Type": "application/json" },
- status: 400,
- });
+
+ // CASO B: Error Inesperado (Crash, Bug, Syntax Error, etc.)
+ // El usuario NO debe ver esto.
+ const unexpectedError = error instanceof Error
+ ? error
+ : new Error(String(error));
+
+ // LOG: Full stack trace y mensaje real
+ console.error(
+ `[${
+ new Date().toISOString()
+ }][${functionName}] ­ƒÆÑ CRITICAL UNHANDLED ERROR:`,
+ unexpectedError.stack || unexpectedError.message, // Esto es lo que necesitas para debuguear
+ );
+
+ // RESPONSE: Mensaje gen├®rico y seguro
+ return sendError(
+ 500,
+ "Ocurri├│ un error inesperado en el servidor.",
+ "INTERNAL_SERVER_ERROR",
+ );
}
});
@@ -324,10 +427,7 @@ const DatosBasicosSchema: z.ZodType<AIGeneratePlanInput["datosBasicos"]> = z
});
const IAConfigSchema: z.ZodType<AIGeneratePlanInput["iaConfig"]> = z.object({
- descripcionEnfoqueAcademico: z.string().min(
- 10,
- "La descripci├│n es muy corta",
- ),
+ descripcionEnfoqueAcademico: z.string(),
instruccionesAdicionalesIA: z.string().optional(),
archivosReferencia: z.array(z.string().uuid()).optional(),
repositoriosIds: z.array(z.string().uuid()).optional(),
diff --git a/supabase/functions/tests/ai-structured-test.ts b/supabase/functions/tests/ai-structured-test.ts
index 5a52e1e..0145f7d 100644
--- a/supabase/functions/tests/ai-structured-test.ts
+++ b/supabase/functions/tests/ai-structured-test.ts
@@ -25,9 +25,6 @@ function mustEnv() {
if (!SUPABASE_ANON_KEY) throw new Error("SUPABASE_ANON_KEY is required");
}
-async function getAuthedClient(): Promise<
- { client: SupabaseClient; accessToken: string }
-> {
async function getAuthedClient(): Promise<
{ client: SupabaseClient; accessToken: string }
> {