diff --git a/diff.txt b/diff.txt index 864c396..b2704fc 100644 Binary files a/diff.txt and b/diff.txt differ diff --git a/supabase/functions/_shared/utils.ts b/supabase/functions/_shared/utils.ts new file mode 100644 index 0000000..7516709 --- /dev/null +++ b/supabase/functions/_shared/utils.ts @@ -0,0 +1,49 @@ +import { corsHeaders } from "./cors.ts"; + +// --- CLASE DE ERROR --- +export class HttpError extends Error { + public readonly status: number; + public readonly code: string; + public readonly internalDetails: unknown; + + constructor( + status: number, + message: string, + code = "API_ERROR", + internalDetails?: unknown, + ) { + super(message); + this.name = "HttpError"; + this.status = status; + this.code = code; + this.internalDetails = internalDetails; + } +} + +// --- HELPER DE ÉXITO --- +export function sendSuccess(data: T, status = 200): Response { + return new Response( + JSON.stringify(data), + { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); +} + +// --- HELPER DE ERROR --- +export function sendError( + status: number, + message: string, + code: string, +): Response { + return new Response( + JSON.stringify({ + error: { message, code }, + }), + { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); +} 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 => { 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( @@ -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 = z }); const IAConfigSchema: z.ZodType = 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 } > {