wip
This commit is contained in:
@@ -5,35 +5,52 @@
|
||||
// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
|
||||
console.log("Hello from Functions!");
|
||||
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 contentType = (req.headers.get("content-type") || "").toLowerCase();
|
||||
|
||||
if (contentType.startsWith("multipart/form-data")) {
|
||||
const formData = await req.formData();
|
||||
console.log("Received multipart/form-data:", formData);
|
||||
// 1. Usa .getAll() para sacar TODOS los valores de esa llave
|
||||
const archivos = formData.getAll("archivosAdjuntos");
|
||||
|
||||
// 2. Imprime la longitud y el arreglo explícito
|
||||
console.log(`Total archivos recibidos: ${archivos.length}`);
|
||||
console.log("Lista de archivos:", archivos);
|
||||
const method = req.method;
|
||||
if (method !== "POST") {
|
||||
console.error(
|
||||
`[${Date.now()}][${functionName}]: Invalid method: ${method}`,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Multipart/form-data received" }),
|
||||
JSON.stringify({ error: "Method not allowed" }),
|
||||
{
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
status: 405,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
console.error("Unsupported content type:", contentType);
|
||||
}
|
||||
|
||||
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" }),
|
||||
{
|
||||
@@ -42,10 +59,93 @@ Deno.serve(async (req) => {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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("Request handler error:", error.message);
|
||||
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 }), {
|
||||
@@ -54,3 +154,136 @@ Deno.serve(async (req) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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) {
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user