290 lines
8.7 KiB
TypeScript
290 lines
8.7 KiB
TypeScript
// 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";
|
|
|
|
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
|
|
|
|
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 respuestaSubida: string[] = subirAStorage(
|
|
supabaseService,
|
|
payload.archivosAdjuntos,
|
|
"ai-storage",
|
|
"tmp",
|
|
);
|
|
|
|
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: 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,
|
|
);
|
|
}
|
|
|
|
return new Response(
|
|
JSON.stringify({ message: "Multipart/form-data received" }),
|
|
{
|
|
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<AIGeneratePlanInput["datosBasicos"]> = 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<AIGeneratePlanInput["iaConfig"]> = 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 };
|
|
}
|
|
|
|
async function subirAStorage(supabase: supa) {
|
|
}
|