2ff587d639
- Created a new enum type "estado_asignatura" with values 'borrador', 'revisada', 'aprobada', and 'generando'. - Added a new column "estado" to the "asignaturas" table, defaulting to 'generando'. - Implemented a trigger function to log changes in the "planes_estudio" table, capturing inserts, updates, and deletes. - Updated the logging mechanism to track changes in various fields, including JSONB data. - Removed the "asignaturas" table from the supabase_realtime publication. - Added a script for generating TypeScript types from the local Supabase database.
779 lines
25 KiB
TypeScript
779 lines
25 KiB
TypeScript
import "@supabase/functions-js/edge-runtime.d.ts";
|
|
import { corsHeaders } from "../_shared/cors.ts";
|
|
import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts";
|
|
import { createClient } from "@supabase/supabase-js";
|
|
import type { Database, Json } from "../_shared/database.types.ts";
|
|
import { z } from "zod";
|
|
import {
|
|
OpenAIService,
|
|
type StructuredResponseOptions,
|
|
} from "../_shared/openai-service.ts";
|
|
|
|
/**
|
|
* JSON input signature (when `Content-Type: application/json`)
|
|
*
|
|
* Required:
|
|
* - `id` (uuid) - corresponde a la columna `id` de `asignaturas`
|
|
*
|
|
* Optional patch fields (any subset is allowed):
|
|
* - `nombre` (string)
|
|
* - `codigo` (string | null)
|
|
* - `tipo` (string | null) // must match DB enum values
|
|
* - `creditos` (number)
|
|
* - `horas_academicas` (number | null)
|
|
* - `horas_independientes` (number | null)
|
|
* - `numero_ciclo` (number | null)
|
|
* - `estructura_id` (uuid | null)
|
|
* - `plan_estudio_id` (uuid)
|
|
* - `linea_plan_id` (uuid | null)
|
|
* - `orden_celda` (number | null)
|
|
*
|
|
* IA config (optional):
|
|
* - `descripcionEnfoqueAcademico` (string)
|
|
*
|
|
* Notes:
|
|
* - This JSON flow does NOT accept `instruccionesAdicionalesIA`.
|
|
* - Missing optional fields are ignored (the existing DB values remain).
|
|
*/
|
|
|
|
const JsonUpdateSchema = z
|
|
.object({
|
|
id: z.string().uuid("id debe ser un UUID"),
|
|
|
|
// patch fields (all optional) — nombres coinciden con columnas DB
|
|
nombre: z.string().min(1).optional(),
|
|
codigo: z.union([z.string().min(1), z.null()]).optional(),
|
|
tipo: z.union([z.string().min(1), z.null()]).optional(),
|
|
creditos: z.number().positive().optional(),
|
|
horas_academicas: z.number().int().nonnegative().nullable().optional(),
|
|
horas_independientes: z.number().int().nonnegative().nullable().optional(),
|
|
numero_ciclo: z.number().int().positive().nullable().optional(),
|
|
estructura_id: z.string().uuid().nullable().optional(),
|
|
plan_estudio_id: z.string().uuid().optional(),
|
|
linea_plan_id: z.string().uuid().nullable().optional(),
|
|
orden_celda: z.number().int().nonnegative().nullable().optional(),
|
|
|
|
// IA config (no instruccionesAdicionalesIA)
|
|
descripcionEnfoqueAcademico: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
type EdgeAIGenerateSubjectJsonUpdateInput = z.infer<typeof JsonUpdateSchema>;
|
|
|
|
function formatZodIssues(issues: z.ZodIssue[]): string {
|
|
return issues
|
|
.map((issue, i) => {
|
|
const path = issue.path.length ? issue.path.join(".") : "(root)";
|
|
return `${i + 1}. ${path}: ${issue.message}`;
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
Deno.serve(async (req: Request): Promise<Response> => {
|
|
const url = new URL(req.url);
|
|
const functionName = url.pathname.split("/").pop();
|
|
console.log(
|
|
`[${new Date().toISOString()}][${functionName}]: Request received`,
|
|
);
|
|
|
|
if (req.method === "OPTIONS") {
|
|
return new Response(null, { status: 204, headers: corsHeaders });
|
|
}
|
|
|
|
try {
|
|
const method = req.method;
|
|
if (method !== "POST") {
|
|
console.error(
|
|
`[${
|
|
new Date().toISOString()
|
|
}][${functionName}]: Invalid method: ${method}`,
|
|
);
|
|
throw new HttpError(
|
|
405,
|
|
"Método no permitido.",
|
|
"METHOD_NOT_ALLOWED",
|
|
{ method },
|
|
);
|
|
}
|
|
|
|
const authHeaderRaw = req.headers.get("Authorization") ??
|
|
req.headers.get("authorization");
|
|
if (!authHeaderRaw) {
|
|
console.error(
|
|
`[${
|
|
new Date().toISOString()
|
|
}][${functionName}]: Missing Authorization header`,
|
|
);
|
|
throw new HttpError(
|
|
401,
|
|
"No autorizado.",
|
|
"UNAUTHORIZED",
|
|
{ reason: "missing_authorization_header" },
|
|
);
|
|
}
|
|
|
|
const contentType = (req.headers.get("content-type") || "").toLowerCase();
|
|
const isMultipart = contentType.startsWith("multipart/form-data");
|
|
const isJson = contentType.includes("application/json");
|
|
if (!isMultipart && !isJson) {
|
|
console.error(
|
|
`[${
|
|
new Date().toISOString()
|
|
}][${functionName}]: Unsupported content type: ${contentType}`,
|
|
);
|
|
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 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
|
|
// Currently not used; kept here for future expansion.
|
|
// const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
// global: {
|
|
// headers: {
|
|
// Authorization: authHeaderRaw,
|
|
// },
|
|
// },
|
|
// });
|
|
|
|
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
|
if (!SERVICE_ROLE_KEY) {
|
|
throw new HttpError(
|
|
500,
|
|
"Configuración del servidor incompleta.",
|
|
"MISSING_ENV",
|
|
{ missing: ["SUPABASE_SERVICE_ROLE_KEY"] },
|
|
);
|
|
}
|
|
|
|
const supabaseService = createClient<Database>(
|
|
SUPABASE_URL,
|
|
SERVICE_ROLE_KEY,
|
|
);
|
|
|
|
// -----------------------------
|
|
// JSON update flow
|
|
// -----------------------------
|
|
if (isJson) {
|
|
let rawBody: unknown;
|
|
try {
|
|
rawBody = await req.json();
|
|
} catch (e) {
|
|
throw new HttpError(
|
|
400,
|
|
"Body JSON inválido.",
|
|
"INVALID_JSON",
|
|
{ cause: e },
|
|
);
|
|
}
|
|
|
|
const parsedBody = JsonUpdateSchema.safeParse(rawBody);
|
|
if (!parsedBody.success) {
|
|
throw new HttpError(
|
|
422,
|
|
formatZodIssues(parsedBody.error.issues),
|
|
"VALIDATION_ERROR",
|
|
parsedBody.error,
|
|
);
|
|
}
|
|
|
|
const payload: EdgeAIGenerateSubjectJsonUpdateInput = parsedBody.data;
|
|
|
|
const { data: existingAsignatura, error: existingAsignaturaError } =
|
|
await supabaseService
|
|
.from("asignaturas")
|
|
.select(
|
|
"id,plan_estudio_id,estructura_id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,numero_ciclo,linea_plan_id,orden_celda",
|
|
)
|
|
.eq("id", payload.id)
|
|
.single();
|
|
|
|
if (existingAsignaturaError) {
|
|
const maybeCode = (existingAsignaturaError as { code?: string }).code;
|
|
if (maybeCode === "PGRST116") {
|
|
throw new HttpError(
|
|
404,
|
|
"No se encontró la asignatura.",
|
|
"NOT_FOUND",
|
|
{ table: "asignaturas", id: payload.id },
|
|
);
|
|
}
|
|
throw new HttpError(
|
|
500,
|
|
"No se pudo obtener la asignatura.",
|
|
"SUPABASE_QUERY_FAILED",
|
|
existingAsignaturaError,
|
|
);
|
|
}
|
|
|
|
const resolved = {
|
|
plan_estudio_id: payload.plan_estudio_id ??
|
|
existingAsignatura.plan_estudio_id,
|
|
estructura_id: payload.estructura_id ??
|
|
existingAsignatura.estructura_id,
|
|
nombre: payload.nombre ?? existingAsignatura.nombre,
|
|
codigo: payload.codigo ?? existingAsignatura.codigo,
|
|
tipo: payload.tipo ?? existingAsignatura.tipo,
|
|
creditos: payload.creditos ?? existingAsignatura.creditos,
|
|
horas_academicas: payload.horas_academicas ??
|
|
existingAsignatura.horas_academicas,
|
|
horas_independientes: payload.horas_independientes ??
|
|
existingAsignatura.horas_independientes,
|
|
numero_ciclo: payload.numero_ciclo ?? existingAsignatura.numero_ciclo,
|
|
linea_plan_id: payload.linea_plan_id ??
|
|
existingAsignatura.linea_plan_id,
|
|
orden_celda: payload.orden_celda ?? existingAsignatura.orden_celda,
|
|
};
|
|
|
|
if (!resolved.estructura_id) {
|
|
throw new HttpError(
|
|
422,
|
|
"estructura_id es requerido (no está presente ni en el JSON ni en la asignatura existente).",
|
|
"VALIDATION_ERROR",
|
|
{
|
|
id: payload.id,
|
|
existing_estructura_id: existingAsignatura.estructura_id,
|
|
},
|
|
);
|
|
}
|
|
|
|
const { data: estructura, error: estructuraError } = await supabaseService
|
|
.from("estructuras_asignatura")
|
|
.select("id,nombre,definicion,version")
|
|
.eq("id", resolved.estructura_id)
|
|
.single();
|
|
if (estructuraError) {
|
|
const maybeCode = (estructuraError as { code?: string }).code;
|
|
if (maybeCode === "PGRST116") {
|
|
throw new HttpError(
|
|
404,
|
|
"No se encontró la estructura de la asignatura.",
|
|
"NOT_FOUND",
|
|
{ table: "estructuras_asignatura", id: resolved.estructura_id },
|
|
);
|
|
}
|
|
throw new HttpError(
|
|
500,
|
|
"No se pudo obtener la estructura de la asignatura.",
|
|
"SUPABASE_QUERY_FAILED",
|
|
estructuraError,
|
|
);
|
|
}
|
|
|
|
const systemPrompt =
|
|
"Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el JSON Schema proporcionado.";
|
|
|
|
const userPrompt =
|
|
`Genera un borrador completo completo de una ASIGNATURA con base en lo siguiente:\n` +
|
|
`- Nombre de la asignatura: ${resolved.nombre}\n` +
|
|
`- Código (clave de la asignatura): ${
|
|
resolved.codigo ?? "(no especificado)"
|
|
}\n` +
|
|
`- Tipo: ${resolved.tipo ?? "(no especificado)"}\n` +
|
|
`- Número de créditos: ${resolved.creditos}\n` +
|
|
`- Horas académicas: ${
|
|
resolved.horas_academicas ?? "(no especificado)"
|
|
}\n` +
|
|
`- Horas independientes: ${
|
|
resolved.horas_independientes ?? "(no especificado)"
|
|
}\n` +
|
|
`- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${
|
|
payload.descripcionEnfoqueAcademico ?? "(ninguna)"
|
|
}`;
|
|
|
|
const schemaDef: Record<string, unknown> =
|
|
typeof estructura?.definicion === "object" &&
|
|
estructura?.definicion !== null
|
|
? (estructura.definicion as Record<string, unknown>)
|
|
: {};
|
|
|
|
const aiStructuredPayload: StructuredResponseOptions = {
|
|
model: "gpt-5-nano",
|
|
input: [
|
|
{ role: "system", content: systemPrompt },
|
|
{ role: "user", content: userPrompt },
|
|
],
|
|
text: {
|
|
format: {
|
|
type: "json_schema",
|
|
name: "asignatura_contenido_tematico",
|
|
schema: schemaDef,
|
|
strict: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
const svc = OpenAIService.fromEnv();
|
|
if (!(svc instanceof OpenAIService)) {
|
|
throw new HttpError(
|
|
500,
|
|
"Configuración del servidor incompleta.",
|
|
"OPENAI_MISCONFIGURED",
|
|
svc,
|
|
);
|
|
}
|
|
|
|
const aiResult = await svc.createStructuredResponse(aiStructuredPayload);
|
|
if (!aiResult.ok) {
|
|
const status = aiResult.code === "MissingEnv" ? 500 : 502;
|
|
throw new HttpError(
|
|
status,
|
|
"No se pudo generar la asignatura con IA.",
|
|
"OPENAI_REQUEST_FAILED",
|
|
aiResult,
|
|
);
|
|
}
|
|
|
|
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 },
|
|
);
|
|
}
|
|
}
|
|
if (!aiOutput) {
|
|
throw new HttpError(
|
|
502,
|
|
"La respuesta de la IA no contiene salida estructurada.",
|
|
"OPENAI_MISSING_STRUCTURED_OUTPUT",
|
|
{ outputText: aiResult.outputText ?? null },
|
|
);
|
|
}
|
|
|
|
const aiOutputJson: Json = aiOutput as unknown as Json;
|
|
|
|
const updatePatch: Database["public"]["Tables"]["asignaturas"]["Update"] =
|
|
{
|
|
datos: aiOutputJson,
|
|
tipo_origen: "IA",
|
|
estado: "borrador",
|
|
};
|
|
|
|
// Apply only provided JSON fields (do not overwrite if missing)
|
|
if (payload.plan_estudio_id !== undefined) {
|
|
updatePatch.plan_estudio_id = payload.plan_estudio_id;
|
|
}
|
|
if (payload.estructura_id !== undefined) {
|
|
updatePatch.estructura_id = payload.estructura_id;
|
|
}
|
|
if (payload.nombre !== undefined) {
|
|
updatePatch.nombre = payload.nombre;
|
|
}
|
|
if (payload.codigo !== undefined) {
|
|
updatePatch.codigo = payload.codigo;
|
|
}
|
|
if (payload.tipo !== undefined) {
|
|
updatePatch.tipo = payload.tipo as Database["public"]["Tables"][
|
|
"asignaturas"
|
|
]["Update"]["tipo"];
|
|
}
|
|
if (payload.creditos !== undefined) {
|
|
updatePatch.creditos = payload.creditos;
|
|
}
|
|
if (payload.horas_academicas !== undefined) {
|
|
updatePatch.horas_academicas = payload.horas_academicas;
|
|
}
|
|
if (payload.horas_independientes !== undefined) {
|
|
updatePatch.horas_independientes = payload.horas_independientes;
|
|
}
|
|
if (payload.numero_ciclo !== undefined) {
|
|
updatePatch.numero_ciclo = payload.numero_ciclo;
|
|
}
|
|
if (payload.linea_plan_id !== undefined) {
|
|
updatePatch.linea_plan_id = payload.linea_plan_id;
|
|
}
|
|
if (payload.orden_celda !== undefined) {
|
|
updatePatch.orden_celda = payload.orden_celda;
|
|
}
|
|
|
|
updatePatch.meta_origen = {
|
|
generado_por: "ai_generate_subject_update_json",
|
|
ai: {
|
|
responseId: aiResult.responseId ?? null,
|
|
conversationId: aiResult.conversationId ?? null,
|
|
model: aiResult.model,
|
|
usage: aiResult.usage ?? null,
|
|
},
|
|
iaConfig: {
|
|
descripcionEnfoqueAcademico: payload.descripcionEnfoqueAcademico ??
|
|
null,
|
|
},
|
|
} as unknown as Json;
|
|
|
|
const { data: asignatura, error: asignaturaError } = await supabaseService
|
|
.from("asignaturas")
|
|
.update(updatePatch)
|
|
.eq("id", payload.id)
|
|
.select(
|
|
"id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,estructura_id,plan_estudio_id,contenido_tematico,meta_origen,datos,creado_en,actualizado_en,numero_ciclo,linea_plan_id,orden_celda",
|
|
)
|
|
.single();
|
|
|
|
if (asignaturaError) {
|
|
const maybeCode = (asignaturaError as { code?: string }).code;
|
|
if (maybeCode === "PGRST116") {
|
|
throw new HttpError(
|
|
404,
|
|
"No se encontró la asignatura.",
|
|
"NOT_FOUND",
|
|
{ table: "asignaturas", id: payload.id },
|
|
);
|
|
}
|
|
throw new HttpError(
|
|
500,
|
|
"No se pudo actualizar la asignatura.",
|
|
"SUPABASE_UPDATE_FAILED",
|
|
asignaturaError,
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`[${
|
|
new Date().toISOString()
|
|
}][${functionName}]: JSON update processed successfully`,
|
|
);
|
|
return sendSuccess(asignatura);
|
|
}
|
|
|
|
// -----------------------------
|
|
// Multipart create flow (existing)
|
|
// -----------------------------
|
|
const formData = await req.formData();
|
|
const validation = parseAndValidate(formData);
|
|
if (!validation.success) {
|
|
console.error(
|
|
`[${new Date().toISOString()}][${functionName}]: Validation errors:`,
|
|
validation.errors,
|
|
);
|
|
const message = validation.errors
|
|
.map((e, i) => `${i + 1}. ${e}`)
|
|
.join("\n");
|
|
throw new HttpError(
|
|
422,
|
|
message,
|
|
"VALIDATION_ERROR",
|
|
{ errors: validation.errors },
|
|
);
|
|
}
|
|
|
|
const payload = validation.data;
|
|
|
|
const { data: estructura, error: estructuraError } = await supabaseService
|
|
.from("estructuras_asignatura")
|
|
.select("id,nombre,definicion,version")
|
|
.eq("id", payload.datosBasicos.estructuraId)
|
|
.single();
|
|
if (estructuraError) {
|
|
const maybeCode = (estructuraError as { code?: string }).code;
|
|
if (maybeCode === "PGRST116") {
|
|
throw new HttpError(
|
|
404,
|
|
"No se encontró la estructura de la asignatura.",
|
|
"NOT_FOUND",
|
|
{
|
|
table: "estructuras_asignatura",
|
|
id: payload.datosBasicos.estructuraId,
|
|
},
|
|
);
|
|
}
|
|
throw new HttpError(
|
|
500,
|
|
"No se pudo obtener la estructura de la asignatura.",
|
|
"SUPABASE_QUERY_FAILED",
|
|
estructuraError,
|
|
);
|
|
}
|
|
|
|
const systemPrompt =
|
|
"Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el JSON Schema proporcionado.";
|
|
|
|
const userPrompt =
|
|
`Genera un borrador completo completo de una ASIGNATURA con base en lo siguiente:\n` +
|
|
`- Nombre de la asignatura: ${payload.datosBasicos.nombre}\n` +
|
|
`- Código (clave de la asignatura): ${
|
|
payload.datosBasicos.codigo ?? "(no especificado)"
|
|
}\n` +
|
|
`- Tipo: ${payload.datosBasicos.tipo ?? "(no especificado)"}\n` +
|
|
`- Número de créditos: ${payload.datosBasicos.creditos}\n` +
|
|
`- Horas académicas: ${
|
|
payload.datosBasicos.horasAcademicas ?? "(no especificado)"
|
|
}\n` +
|
|
`- Horas independientes: ${
|
|
payload.datosBasicos.horasIndependientes ?? "(no especificado)"
|
|
}\n` +
|
|
`- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${
|
|
payload.iaConfig?.descripcionEnfoqueAcademico ?? "(ninguna)"
|
|
}\n` +
|
|
`- Instrucciones adicionales (sobre el formato de la respuesta generada): ${
|
|
payload.iaConfig?.instruccionesAdicionalesIA ?? "(ninguna)"
|
|
}`;
|
|
|
|
const schemaDef: Record<string, unknown> =
|
|
typeof estructura?.definicion === "object" &&
|
|
estructura?.definicion !== null
|
|
? (estructura.definicion as Record<string, unknown>)
|
|
: {};
|
|
|
|
const aiStructuredPayload: StructuredResponseOptions = {
|
|
model: "gpt-5-nano",
|
|
input: [
|
|
{ role: "system", content: systemPrompt },
|
|
{ role: "user", content: userPrompt },
|
|
],
|
|
text: {
|
|
format: {
|
|
type: "json_schema",
|
|
name: "asignatura_contenido_tematico",
|
|
schema: schemaDef,
|
|
strict: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
const svc = OpenAIService.fromEnv();
|
|
if (!(svc instanceof OpenAIService)) {
|
|
throw new HttpError(
|
|
500,
|
|
"Configuración del servidor incompleta.",
|
|
"OPENAI_MISCONFIGURED",
|
|
svc,
|
|
);
|
|
}
|
|
|
|
const aiResult = await svc.createStructuredResponse(
|
|
aiStructuredPayload,
|
|
payload.archivosAdjuntos,
|
|
);
|
|
if (!aiResult.ok) {
|
|
const status = aiResult.code === "MissingEnv" ? 500 : 502;
|
|
throw new HttpError(
|
|
status,
|
|
"No se pudo generar la asignatura con IA.",
|
|
"OPENAI_REQUEST_FAILED",
|
|
aiResult,
|
|
);
|
|
}
|
|
|
|
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 },
|
|
);
|
|
}
|
|
}
|
|
if (!aiOutput) {
|
|
throw new HttpError(
|
|
502,
|
|
"La respuesta de la IA no contiene salida estructurada.",
|
|
"OPENAI_MISSING_STRUCTURED_OUTPUT",
|
|
{ outputText: aiResult.outputText ?? null },
|
|
);
|
|
}
|
|
|
|
const aiOutputJson: Json = aiOutput as unknown as Json;
|
|
|
|
const asignaturaInsert:
|
|
Database["public"]["Tables"]["asignaturas"]["Insert"] = {
|
|
plan_estudio_id: payload.plan_estudio_id,
|
|
estructura_id: payload.datosBasicos.estructuraId,
|
|
nombre: payload.datosBasicos.nombre,
|
|
codigo: payload.datosBasicos.codigo ?? null,
|
|
creditos: payload.datosBasicos.creditos,
|
|
horas_academicas: payload.datosBasicos.horasAcademicas ?? null,
|
|
horas_independientes: payload.datosBasicos.horasIndependientes ?? null,
|
|
tipo: (payload.datosBasicos.tipo ?? undefined) as Database["public"][
|
|
"Tables"
|
|
]["asignaturas"]["Insert"]["tipo"],
|
|
tipo_origen: "IA",
|
|
datos: aiOutputJson,
|
|
meta_origen: {
|
|
generado_por: "ai_generate_subject",
|
|
ai: {
|
|
responseId: aiResult.responseId ?? null,
|
|
conversationId: aiResult.conversationId ?? null,
|
|
model: aiResult.model,
|
|
usage: aiResult.usage ?? null,
|
|
},
|
|
referencias: {
|
|
archivosReferenciaIds: payload.iaConfig?.archivosReferencia ?? null,
|
|
repositoriosReferencia: payload.iaConfig?.repositoriosReferencia ??
|
|
null,
|
|
},
|
|
iaConfig: {
|
|
descripcionEnfoqueAcademico:
|
|
payload.iaConfig?.descripcionEnfoqueAcademico ?? null,
|
|
instruccionesAdicionalesIA:
|
|
payload.iaConfig?.instruccionesAdicionalesIA ?? null,
|
|
},
|
|
} as unknown as Json,
|
|
};
|
|
|
|
const { data: asignatura, error: asignaturaError } = await supabaseService
|
|
.from("asignaturas")
|
|
.insert(asignaturaInsert)
|
|
.select(
|
|
"id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,estructura_id,plan_estudio_id,contenido_tematico,meta_origen,creado_en,actualizado_en",
|
|
)
|
|
.single();
|
|
|
|
if (asignaturaError) {
|
|
const maybeCode = (asignaturaError as { code?: string }).code;
|
|
const status = maybeCode ? 409 : 500;
|
|
throw new HttpError(
|
|
status,
|
|
"No se pudo guardar la asignatura.",
|
|
"SUPABASE_INSERT_FAILED",
|
|
{ ...asignaturaError, code: maybeCode },
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`[${
|
|
new Date().toISOString()
|
|
}][${functionName}]: Request processed successfully`,
|
|
);
|
|
return sendSuccess(asignatura);
|
|
} catch (error) {
|
|
if (error instanceof HttpError) {
|
|
console.error(
|
|
`[${new Date().toISOString()}][${functionName}] ⚠️ Handled Error:`,
|
|
{
|
|
message: error.message,
|
|
code: error.code,
|
|
internalDetails: error.internalDetails || "N/A",
|
|
},
|
|
);
|
|
|
|
return sendError(error.status, error.message, error.code);
|
|
}
|
|
|
|
const unexpectedError = error instanceof Error
|
|
? error
|
|
: new Error(String(error));
|
|
|
|
console.error(
|
|
`[${
|
|
new Date().toISOString()
|
|
}][${functionName}] 💥 CRITICAL UNHANDLED ERROR:`,
|
|
unexpectedError.stack || unexpectedError.message,
|
|
);
|
|
|
|
return sendError(
|
|
500,
|
|
"Ocurrió un error inesperado en el servidor.",
|
|
"INTERNAL_SERVER_ERROR",
|
|
);
|
|
}
|
|
});
|
|
|
|
const jsonFromString = <T extends z.ZodTypeAny>(schema: T) =>
|
|
z.string().transform((str, ctx) => {
|
|
try {
|
|
return JSON.parse(str);
|
|
} catch {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "El formato no es un JSON válido",
|
|
});
|
|
return z.NEVER;
|
|
}
|
|
}).pipe(schema);
|
|
|
|
const jsonFromStringOptional = <T extends z.ZodTypeAny>(schema: T) =>
|
|
z.string().optional().default("{}").transform((str, ctx) => {
|
|
try {
|
|
return JSON.parse(str);
|
|
} catch {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "El formato no es un JSON válido",
|
|
});
|
|
return z.NEVER;
|
|
}
|
|
}).pipe(schema);
|
|
|
|
const DatosBasicosSchema = z.object({
|
|
nombre: z.string().min(1, "El nombre es requerido"),
|
|
codigo: z.string().min(1).optional().nullable(),
|
|
tipo: z.union([z.string().min(1), z.null()]).optional().transform((v) =>
|
|
v ?? null
|
|
),
|
|
creditos: z.number({ invalid_type_error: "Créditos debe ser numérico" })
|
|
.positive("Créditos debe ser >= 0"),
|
|
horasAcademicas: z.number().int().nonnegative().optional().nullable(),
|
|
horasIndependientes: z.number().int().nonnegative().optional().nullable(),
|
|
estructuraId: z.string().uuid("estructuraId debe ser un UUID"),
|
|
});
|
|
|
|
const IAConfigSchema = z.object({
|
|
descripcionEnfoqueAcademico: z.string().optional(),
|
|
instruccionesAdicionalesIA: z.string().optional(),
|
|
archivosReferencia: z.array(z.string().uuid()).optional(),
|
|
repositoriosReferencia: z.array(z.string().uuid()).optional(),
|
|
}).partial();
|
|
|
|
const SolicitudSchema = z.object({
|
|
plan_estudio_id: z.string().uuid("plan_estudio_id debe ser un UUID"),
|
|
datosBasicos: jsonFromString(DatosBasicosSchema),
|
|
iaConfig: jsonFromStringOptional(IAConfigSchema),
|
|
archivosAdjuntos: z.array(z.instanceof(File)).optional().default([]),
|
|
});
|
|
|
|
type EdgeAIGenerateSubjectInput = z.infer<typeof SolicitudSchema>;
|
|
|
|
function parseAndValidate(
|
|
formData: FormData,
|
|
): { success: true; data: EdgeAIGenerateSubjectInput } | {
|
|
success: false;
|
|
errors: string[];
|
|
} {
|
|
const rawInput = {
|
|
plan_estudio_id: formData.get("plan_estudio_id"),
|
|
datosBasicos: formData.get("datosBasicos"),
|
|
iaConfig: formData.get("iaConfig"),
|
|
archivosAdjuntos: formData.getAll("archivosAdjuntos"),
|
|
};
|
|
|
|
const result = SolicitudSchema.safeParse(rawInput);
|
|
if (!result.success) {
|
|
const errors = result.error.errors.map((issue) => {
|
|
const path = issue.path.length ? `${issue.path.join(".")}: ` : "";
|
|
return `${path}${issue.message}`;
|
|
});
|
|
return { success: false, errors };
|
|
}
|
|
|
|
return { success: true, data: result.data };
|
|
}
|