issue/6-generacin-de-materias #16
@@ -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<T>(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" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"imports": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Setup type definitions for built-in Supabase Runtime APIs
|
// Setup type definitions for built-in Supabase Runtime APIs
|
||||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
import { corsHeaders } from "../_shared/cors.ts";
|
import { corsHeaders } from "../_shared/cors.ts";
|
||||||
|
import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts";
|
||||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||||
import type { Database, Json } from "../_shared/database.types.ts";
|
import type { Database, Json } from "../_shared/database.types.ts";
|
||||||
import type { AIGeneratePlanInput } from "./types.ts";
|
import type { AIGeneratePlanInput } from "./types.ts";
|
||||||
@@ -18,7 +19,7 @@ type NivelType =
|
|||||||
type TipoCicloType =
|
type TipoCicloType =
|
||||||
Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"];
|
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 url = new URL(req.url);
|
||||||
const functionName = url.pathname.split("/").pop();
|
const functionName = url.pathname.split("/").pop();
|
||||||
console.log(
|
console.log(
|
||||||
@@ -37,12 +38,11 @@ Deno.serve(async (req) => {
|
|||||||
new Date().toISOString()
|
new Date().toISOString()
|
||||||
}][${functionName}]: Invalid method: ${method}`,
|
}][${functionName}]: Invalid method: ${method}`,
|
||||||
);
|
);
|
||||||
return new Response(
|
throw new HttpError(
|
||||||
JSON.stringify({ error: "Method not allowed" }),
|
405,
|
||||||
{
|
"Método no permitido.",
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
"METHOD_NOT_ALLOWED",
|
||||||
status: 405,
|
{ method },
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,12 +54,11 @@ Deno.serve(async (req) => {
|
|||||||
new Date().toISOString()
|
new Date().toISOString()
|
||||||
}][${functionName}]: Missing Authorization header`,
|
}][${functionName}]: Missing Authorization header`,
|
||||||
);
|
);
|
||||||
return new Response(
|
throw new HttpError(
|
||||||
JSON.stringify({ error: "Authorization header missing" }),
|
401,
|
||||||
{
|
"No autorizado.",
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
"UNAUTHORIZED",
|
||||||
status: 401,
|
{ reason: "missing_authorization_header" },
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,19 +69,28 @@ Deno.serve(async (req) => {
|
|||||||
new Date().toISOString()
|
new Date().toISOString()
|
||||||
}][${functionName}]: Unsupported content type: ${contentType}`,
|
}][${functionName}]: Unsupported content type: ${contentType}`,
|
||||||
);
|
);
|
||||||
return new Response(
|
throw new HttpError(
|
||||||
JSON.stringify({ error: "Unsupported content type" }),
|
415,
|
||||||
{
|
"Content-Type no soportado.",
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
"UNSUPPORTED_MEDIA_TYPE",
|
||||||
status: 400,
|
{ contentType },
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
|
||||||
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
|
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
|
||||||
if (!SUPABASE_URL || !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
|
// 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");
|
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||||
if (!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>(
|
const supabaseService = createClient<Database>(
|
||||||
@@ -112,12 +125,15 @@ Deno.serve(async (req) => {
|
|||||||
`[${new Date().toISOString()}][${functionName}]: Validation errors:`,
|
`[${new Date().toISOString()}][${functionName}]: Validation errors:`,
|
||||||
validation.errors,
|
validation.errors,
|
||||||
);
|
);
|
||||||
return new Response(
|
const message = validation.errors
|
||||||
JSON.stringify({ errors: validation.errors }),
|
.map((e, i) => `(${i + 1}) ${e}`)
|
||||||
{
|
.join("\n");
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
status: 400,
|
throw new HttpError(
|
||||||
},
|
422,
|
||||||
|
message,
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
{ errors: validation.errors },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,8 +146,23 @@ Deno.serve(async (req) => {
|
|||||||
.eq("id", payload.datosBasicos.estructuraPlanId)
|
.eq("id", payload.datosBasicos.estructuraPlanId)
|
||||||
.single();
|
.single();
|
||||||
if (estructuraPlanError) {
|
if (estructuraPlanError) {
|
||||||
throw new Error(
|
const maybeCode = (estructuraPlanError as { code?: string }).code;
|
||||||
"Error fetching estructuraPlan: " + estructuraPlanError.message,
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,25 +196,64 @@ Deno.serve(async (req) => {
|
|||||||
// Use shared OpenAI service directly (no HTTP invoke)
|
// Use shared OpenAI service directly (no HTTP invoke)
|
||||||
const svc = OpenAIService.fromEnv();
|
const svc = OpenAIService.fromEnv();
|
||||||
if (!(svc instanceof OpenAIService)) {
|
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(
|
const aiResult = await svc.createStructuredResponse(
|
||||||
aiStructuredPayload,
|
aiStructuredPayload,
|
||||||
payload.archivosAdjuntos,
|
payload.archivosAdjuntos,
|
||||||
);
|
);
|
||||||
if (!aiResult.ok) {
|
if (!aiResult.ok) {
|
||||||
throw new Error(
|
const status = aiResult.code === "MissingEnv" ? 500 : 502;
|
||||||
`OpenAI call failed [${aiResult.code}]: ${aiResult.message}`,
|
throw new HttpError(
|
||||||
|
status,
|
||||||
|
"No se pudo generar el plan con IA.",
|
||||||
|
"OPENAI_REQUEST_FAILED",
|
||||||
|
aiResult,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer parsed output; fallback to parse outputText
|
// Prefer parsed output; fallback to parse outputText
|
||||||
const aiOutput = aiResult.output ??
|
let aiOutput = aiResult.output ?? null;
|
||||||
(aiResult.outputText ? JSON.parse(aiResult.outputText) : 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;
|
const aiOutputJson: Json = aiOutput as unknown as Json;
|
||||||
if (!aiOutput) {
|
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
|
// TODO: Insertar interacciones con IA y quizas forzar a que la informacion de datosBasicos sea la misma que la recibida
|
||||||
@@ -202,7 +272,20 @@ Deno.serve(async (req) => {
|
|||||||
.eq("id", payload.datosBasicos.carreraId)
|
.eq("id", payload.datosBasicos.carreraId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
if (carreraError) {
|
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"] =
|
const planInsert: Database["public"]["Tables"]["planes_estudio"]["Insert"] =
|
||||||
@@ -248,42 +331,62 @@ Deno.serve(async (req) => {
|
|||||||
// TODO: interaccion con IA y cambio de estado
|
// TODO: interaccion con IA y cambio de estado
|
||||||
|
|
||||||
if (planError) {
|
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
|
// TODO: update a interaccion_ia y e insert a cambios_plan con id de plan generado
|
||||||
const jsonResponse = {
|
|
||||||
ok: true,
|
|
||||||
plan,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[${
|
`[${
|
||||||
new Date().toISOString()
|
new Date().toISOString()
|
||||||
}][${functionName}]: Request processed successfully`,
|
}][${functionName}]: Request processed successfully`,
|
||||||
);
|
);
|
||||||
return new Response(
|
return sendSuccess(plan);
|
||||||
JSON.stringify(jsonResponse),
|
|
||||||
{
|
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
status: 200,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log full error server-side for diagnostics
|
if (error instanceof HttpError) {
|
||||||
if (error instanceof Error) {
|
|
||||||
console.error(
|
console.error(
|
||||||
`[${
|
`[${new Date().toISOString()}][${functionName}] ⚠️ Handled Error:`,
|
||||||
new Date().toISOString()
|
{
|
||||||
}][${functionName}]: Request handler error:`,
|
message: 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 }), {
|
// CASO B: Error Inesperado (Crash, Bug, Syntax Error, etc.)
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
// El usuario NO debe ver esto.
|
||||||
status: 400,
|
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",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -315,8 +418,8 @@ const DatosBasicosSchema: z.ZodType<AIGeneratePlanInput["datosBasicos"]> = z
|
|||||||
});
|
});
|
||||||
|
|
||||||
const IAConfigSchema: z.ZodType<AIGeneratePlanInput["iaConfig"]> = z.object({
|
const IAConfigSchema: z.ZodType<AIGeneratePlanInput["iaConfig"]> = z.object({
|
||||||
descripcionEnfoque: z.string().min(10, "La descripción es muy corta"),
|
descripcionEnfoqueAcademico: z.string(),
|
||||||
notasAdicionales: z.string().optional(),
|
instruccionesAdicionalesIA: z.string().optional(),
|
||||||
archivosReferencia: z.array(z.string().uuid()).optional(),
|
archivosReferencia: z.array(z.string().uuid()).optional(),
|
||||||
repositoriosIds: z.array(z.string().uuid()).optional(),
|
repositoriosIds: z.array(z.string().uuid()).optional(),
|
||||||
usarMCP: z.boolean().optional(),
|
usarMCP: z.boolean().optional(),
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ function mustEnv() {
|
|||||||
if (!SUPABASE_ANON_KEY) throw new Error("SUPABASE_ANON_KEY is required");
|
if (!SUPABASE_ANON_KEY) throw new Error("SUPABASE_ANON_KEY is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAuthedClient(): Promise<
|
|
||||||
{ client: SupabaseClient; accessToken: string }
|
|
||||||
> {
|
|
||||||
async function getAuthedClient(): Promise<
|
async function getAuthedClient(): Promise<
|
||||||
{ client: SupabaseClient; accessToken: string }
|
{ client: SupabaseClient; accessToken: string }
|
||||||
> {
|
> {
|
||||||
|
|||||||
Reference in New Issue
Block a user