// Follow this setup guide to integrate the Deno language server with your editor: // https://deno.land/manual/getting_started/setup_your_environment // This enables autocomplete, go to definition, etc. // 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 { createClient } from "npm:@supabase/supabase-js@2"; import type { AIGeneratePlanInput } from "./types.ts"; import { z } from "zod"; import { systemPrompt } from "./prompts.ts"; Deno.serve(async (req) => { if (req.method === "OPTIONS") { return new Response(null, { status: 204, headers: corsHeaders }); } const url = new URL(req.url); const functionName = url.pathname.split("/").pop(); try { const method = req.method; if (method !== "POST") { console.error( `[${Date.now()}][${functionName}]: Invalid method: ${method}`, ); return new Response( JSON.stringify({ error: "Method not allowed" }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 405, }, ); } const authHeaderRaw = req.headers.get("Authorization") ?? req.headers.get("authorization"); if (!authHeaderRaw) { console.error( `[${Date.now()}][${functionName}]: Missing Authorization header`, ); return new Response( JSON.stringify({ error: "Authorization header missing" }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 401, }, ); } const contentType = (req.headers.get("content-type") || "").toLowerCase(); if (!contentType.startsWith("multipart/form-data")) { console.error( `[${Date.now()}][${functionName}]: Unsupported content type: ${contentType}`, ); return new Response( JSON.stringify({ error: "Unsupported content type" }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 400, }, ); } 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"); } const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { global: { headers: { Authorization: authHeaderRaw, }, }, }); // TODO: iniciar sesion con el usuario e implementar RLS 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"); } const supabaseService = createClient( SUPABASE_URL, SERVICE_ROLE_KEY, ); const formData = await req.formData(); const validation = parseAndValidate(formData); if (!validation.success) { console.error( `[${Date.now()}][${functionName}]: Validation errors:`, validation.errors, ); return new Response( JSON.stringify({ errors: validation.errors }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 400, }, ); } const payload: AIGeneratePlanInput = validation.data; const { data: estructuraPlan, error: estructuraPlanError } = await supabaseService .from("estructuras_plan") .select("id,nombre,tipo,template_id,definicion") .eq("id", payload.datosBasicos.estructuraPlanId) .single(); if (estructuraPlanError) { throw new Error( "Error fetching estructuraPlan: " + estructuraPlanError.message, ); } const userPrompt = `Genera un borrador completo del PLAN DE ESTUDIOS con base en lo siguiente: - Descripción del enfoque: ${payload.iaConfig.descripcionEnfoque} - Notas adicionales: ${payload.iaConfig.notasAdicionales ?? "Ninguna"}`; const aiStructuredPayload = { response: { input: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, ], }, estructuraPlan, references: { openaiFileIds: payload.iaConfig.archivosReferencia, vectorStoreIds: payload.iaConfig.repositoriosIds, }, usarMCP: Boolean(payload.iaConfig?.usarMCP), }; const aiStructuredFormData = new FormData(); aiStructuredFormData.append("payload", JSON.stringify(aiStructuredPayload)); for (const file of payload.archivosAdjuntos ?? []) { aiStructuredFormData.append("archivos", file); } const { data: aiJson, error } = await supabaseService.functions.invoke( "ai-structured", { method: "POST", body: aiStructuredFormData, }, ); if (error) { throw new Error( "ai-structured invocation failed: " + error.message, ); } if (!aiJson || !aiJson.ok) { throw new Error( "ai-structured returned an error or no data", ); } // TODO: Insertar interacciones con IA y quizas forzar a que la informacion de datosBasicos sea la misma que la recibida const { data: estado } = await supabaseService .from("estados_plan") .select("id,clave,orden") .ilike("clave", "BORRADOR%") .order("orden", { ascending: true }) .limit(1) .maybeSingle(); const { data: carrera, error: carreraError } = await supabaseService .from("carreras") .select("id,nombre,facultad_id,facultades(id,nombre,nombre_corto)") .eq("id", payload.datosBasicos.carreraId) .maybeSingle(); if (carreraError) { throw new Error("Error fetching carrera: " + carreraError.message); } const { data: plan, error: planError } = await supabaseService .from("planes_estudio") .insert({ carrera_id: carrera?.id, estructura_id: estructuraPlan?.id, nombre: payload.datosBasicos.nombrePlan, nivel: payload.datosBasicos.nivel, tipo_ciclo: payload.datosBasicos.tipoCiclo, numero_ciclos: payload.datosBasicos.numCiclos, datos: aiJson.output, estado_actual_id: estado?.id, activo: true, tipo_origen: "IA", meta_origen: { generado_por: "ai_generate_plan", ai_structured: { cid: aiJson?.cid ?? null, responseId: aiJson?.responseId ?? null, conversationId: aiJson?.conversationId ?? null, model: aiJson?.model, usage: aiJson?.usage ?? null, }, referencias: { archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null, repositoriosIds: payload.iaConfig?.repositoriosIds ?? null, // openaiFileIds, // vectorStoreIds, }, iaConfig: { descripcionEnfoque: payload.iaConfig?.descripcionEnfoque ?? null, notasAdicionales: payload.iaConfig?.notasAdicionales ?? null, usarMCP: Boolean(payload.iaConfig?.usarMCP), }, }, // creado_por: user.id, // actualizado_por: user.id, }) .select( "id,nombre,nivel,tipo_ciclo,numero_ciclos,carrera_id,estructura_id,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,datos", ) .single(); if (planError) { throw new Error("Error inserting plan: " + planError.message); } // TODO: update a interaccion_ia y e insert a cambios_plancon id de plan generado const jsonResponse = { ok: true, plan, }; return new Response( JSON.stringify(jsonResponse), { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 200, }, ); } catch (error) { // Log full error server-side for diagnostics if (error instanceof Error) { console.error( `[${Date.now()}][${functionName}]: Request handler error:`, error.message, ); } const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 400, }); } }); // --- VALIDACIÓN ESTRICTA DE DATOS BÁSICOS --- const DatosBasicosSchema: z.ZodType = z .object({ nombrePlan: z.string().min(1, "El nombre es requerido"), carreraId: z.string().uuid("carreraId debe ser un UUID"), facultadId: z.string().uuid("facultadId debe ser un UUID").optional(), nivel: z.string().min(1, "Nivel es requerido"), tipoCiclo: z.enum(["Semestre", "Cuatrimestre", "Trimestre", "Otro"]), numCiclos: z.number().int().positive(), estructuraPlanId: z.string().uuid("estructuraPlanId debe ser un UUID"), }); const IAConfigSchema: z.ZodType = z.object({ descripcionEnfoque: z.string().min(10, "La descripción es muy corta"), notasAdicionales: z.string().optional(), archivosReferencia: z.array(z.string().uuid()).optional(), repositoriosIds: z.array(z.string().uuid()).optional(), usarMCP: z.boolean().optional(), }); function parseAndValidate( formData: FormData, ): { success: true; data: AIGeneratePlanInput } | { success: false; errors: string[]; } { const errors: string[] = []; // --- A. Extracción de Strings --- const rawBasicos = formData.get("datosBasicos"); const rawIA = formData.get("iaConfig"); if (!rawBasicos) { return { success: false, errors: ["Falta el campo 'datosBasicos' en el FormData"], }; } if (!rawIA) { return { success: false, errors: ["Falta el campo 'iaConfig' en el FormData"], }; } // --- B. Parsing de JSON (Usamos 'unknown' en lugar de 'any') --- let jsonBasicos: unknown; let jsonIA: unknown; try { jsonBasicos = JSON.parse(rawBasicos as string); } catch (_error) { return { success: false, errors: ["El JSON de 'datosBasicos' tiene un formato inválido"], }; } try { jsonIA = JSON.parse(rawIA as string); } catch (_error) { return { success: false, errors: ["El JSON de 'iaConfig' tiene un formato inválido"], }; } // --- C. Validación con Zod --- const parsedBasicos = DatosBasicosSchema.safeParse(jsonBasicos); const parsedIA = IAConfigSchema.safeParse(jsonIA); // Recolectamos errores de Zod si existen if (!parsedBasicos.success) { parsedBasicos.error.errors.forEach((issue) => { errors.push( `Datos Básicos (${issue.path.join(". ")}): ${issue.message}`, ); }); } if (!parsedIA.success) { parsedIA.error.errors.forEach((issue) => { errors.push(`IA Config (${issue.path.join(". ")}): ${issue.message}`); }); } // --- D. Validación Manual de Archivos --- const filesRaw = formData.getAll("archivosAdjuntos"); const filesClean: File[] = []; filesRaw.forEach((entry, index) => { if (entry instanceof File) { filesClean.push(entry); } else { errors.push( `El adjunto en la posición ${index} no es un archivo válido.`, ); } }); // --- E. Retorno de Errores --- if (errors.length > 0) { console.error("❌ Errores de validación:", errors); return { success: false, errors }; } // --- F. Retorno de Éxito (Narrowing de Tipos) --- // TypeScript necesita esta verificación explícita. // Aunque lógicamente sabemos que si errors.length === 0, entonces success es true, // TS no conecta el array de errores con el resultado de Zod automáticamente. if (!parsedBasicos.success || !parsedIA.success) { // Este caso teóricamente es inalcanzable si la lógica de arriba es correcta, // pero satisface al compilador para acceder a .data abajo. return { success: false, errors: ["Error interno de estado inconsistente."], }; } // Ahora sí, TS sabe que parsedBasicos.data existe y es del tipo correcto const finalInput: AIGeneratePlanInput = { datosBasicos: parsedBasicos.data, iaConfig: parsedIA.data, archivosAdjuntos: filesClean, }; return { success: true, data: finalInput }; }