Compare commits

...

4 Commits

Author SHA1 Message Date
Guillermo.Arrieta 0d00ce1dc1 Primera versión de la función de ai-generate-subject 2026-02-05 12:38:41 -06:00
Guillermo.Arrieta 80a52911e3 fix(functions): clasificar y estandarizar errores en ai-generate-plan
- Corregido el problema donde la función devolvía siempre 400; ahora se lanzan HttpError con status adecuados (405, 401, 415, 422, 404, 409, 502, 500) según el caso.
- Refactor: usar sendError/sendSuccess y HttpError desde _shared/utils.ts para respuestas consistentes y logging seguro.
- Validación: agrupar errores de parse/validación en un único mensaje numerado (cada error en nueva línea).
- OpenAI: manejo explícito de fallos (MissingEnv, JSON inválido, salida estructurada ausente) y mapeo a status apropiados.
- Supabase: distinguir errores 404/409/500 en consultas e inserts; incluir detalles en internalDetails para diagnóstico server-side.
- Eliminada dependencia de invocación HTTP a ai-structured; usar servicio compartido directo.
- Ajustes menores en mensajes y configuración (deno imports/deno.json) y tests marcados como ignorados donde corresponde.
2026-02-05 12:00:31 -06:00
Guillermo.Arrieta 342c542872 inicio de funcion de generación de asignatura 2026-02-04 16:35:28 -06:00
Guillermo.Arrieta 425c8611f7 chore(db): actualizar esquema/backups (auditoría de planes, hashes y conversation_id)
- Agrega fn_log_cambios_planes_estudio y trigger para registrar cambios en cambios_plan, usando response_id (reemplaza interaccion_ia_id).
- Extiende planes_estudio con conversation_id (unique) y plan_hash; agrega asignatura_hash en asignaturas; corrige unaccent_immutable y añade extensión http.
- Actualiza seeds/backups (data_backup.sql / scheme_backup.sql) y ajusta script de diff (sql_para_local_como_remoto.sql), elimina el diff viejo y actualiza archivos auxiliares (deno.lock, CLI temp).
2026-02-04 15:21:43 -06:00
21 changed files with 1602 additions and 172 deletions
Generated
+38 -13
View File
@@ -89,33 +89,57 @@
"npm": { "npm": {
"@deno/darwin-arm64@2.6.4": { "@deno/darwin-arm64@2.6.4": {
"integrity": "sha512-5pocuV2IVWYUzomXze+bDSO86uX1WPxUn82BS7BrLI3ix8Eq7l8DhvYgz064w7rwCM4CiLLSc6MAQpAI+0gUag==", "integrity": "sha512-5pocuV2IVWYUzomXze+bDSO86uX1WPxUn82BS7BrLI3ix8Eq7l8DhvYgz064w7rwCM4CiLLSc6MAQpAI+0gUag==",
"os": ["darwin"], "os": [
"cpu": ["arm64"] "darwin"
],
"cpu": [
"arm64"
]
}, },
"@deno/darwin-x64@2.6.4": { "@deno/darwin-x64@2.6.4": {
"integrity": "sha512-tX7Fz47HcoXTYR4wN2WzXjqJtEPTftzjEknCYkZa8D4BzvpN7Zo1ra3Jd6bWPN9+T6mgTL6YMPMMSigXddIUcA==", "integrity": "sha512-tX7Fz47HcoXTYR4wN2WzXjqJtEPTftzjEknCYkZa8D4BzvpN7Zo1ra3Jd6bWPN9+T6mgTL6YMPMMSigXddIUcA==",
"os": ["darwin"], "os": [
"cpu": ["x64"] "darwin"
],
"cpu": [
"x64"
]
}, },
"@deno/linux-arm64-glibc@2.6.4": { "@deno/linux-arm64-glibc@2.6.4": {
"integrity": "sha512-wY4hjjVLq5idClWj6e1de9IUZ0QZ1rxo16o0dwCJpMi+w1lolxtYxGF4y7pkcxAdZOpMGsXZuYMPq1Z2n51esA==", "integrity": "sha512-wY4hjjVLq5idClWj6e1de9IUZ0QZ1rxo16o0dwCJpMi+w1lolxtYxGF4y7pkcxAdZOpMGsXZuYMPq1Z2n51esA==",
"os": ["linux"], "os": [
"cpu": ["arm64"] "linux"
],
"cpu": [
"arm64"
]
}, },
"@deno/linux-x64-glibc@2.6.4": { "@deno/linux-x64-glibc@2.6.4": {
"integrity": "sha512-H9b0WJw+eWyC6GcMGCXxC/3lz2kVveyWyUrzGyQ8hJmtDJpWiDnCU2iPqEpEcwXHgAUFFyjnRaFwedwwv5ApdA==", "integrity": "sha512-H9b0WJw+eWyC6GcMGCXxC/3lz2kVveyWyUrzGyQ8hJmtDJpWiDnCU2iPqEpEcwXHgAUFFyjnRaFwedwwv5ApdA==",
"os": ["linux"], "os": [
"cpu": ["x64"] "linux"
],
"cpu": [
"x64"
]
}, },
"@deno/win32-arm64@2.6.4": { "@deno/win32-arm64@2.6.4": {
"integrity": "sha512-fMH1dx54ORbCTpMorGjDshwonIu6KfgfPC+Pq+14jeq/8604EIFXiA484p3Ov+ViZycndNHEPe8ZKPNd8xLjJA==", "integrity": "sha512-fMH1dx54ORbCTpMorGjDshwonIu6KfgfPC+Pq+14jeq/8604EIFXiA484p3Ov+ViZycndNHEPe8ZKPNd8xLjJA==",
"os": ["win32"], "os": [
"cpu": ["arm64"] "win32"
],
"cpu": [
"arm64"
]
}, },
"@deno/win32-x64@2.6.4": { "@deno/win32-x64@2.6.4": {
"integrity": "sha512-hAxqNrBLHCINtqDgKcRiu8QtMbtdnHOVW3OFQjzA3gZEcpnGUK3JiF5ql++83SjuQH8ViZKGcdqkioLR7Bf14A==", "integrity": "sha512-hAxqNrBLHCINtqDgKcRiu8QtMbtdnHOVW3OFQjzA3gZEcpnGUK3JiF5ql++83SjuQH8ViZKGcdqkioLR7Bf14A==",
"os": ["win32"], "os": [
"cpu": ["x64"] "win32"
],
"cpu": [
"x64"
]
}, },
"@isaacs/fs-minipass@4.0.1": { "@isaacs/fs-minipass@4.0.1": {
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
@@ -352,7 +376,8 @@
"dependencies": [ "dependencies": [
"jsr:@david/dax@~0.44.2", "jsr:@david/dax@~0.44.2",
"jsr:@openai/openai@^6.16.0", "jsr:@openai/openai@^6.16.0",
"jsr:@zod/zod@^4.3.5" "jsr:@zod/zod@^4.3.5",
"jsr:@david/dax@~0.44.2"
], ],
"packageJson": { "packageJson": {
"dependencies": [ "dependencies": [
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@
*/ */
import { $ } from "@david/dax"; import { $ } from "@david/dax";
const OUT_FILE = "supabase/diffs/sql_para_remoto_como_local.sql"; const OUT_FILE = "supabase/diffs/sql_para_local_como_remoto.sql";
console.log("🔍 Comparando base de datos local vs. remota..."); console.log("🔍 Comparando base de datos local vs. remota...");
+1 -1
View File
@@ -1 +1 @@
v2.72.7 v2.75.0
+11
View File
@@ -26,3 +26,14 @@ entrypoint = "./functions/create-chat-conversation/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns. # Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function: # For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/create-chat-conversation/*.html" ] # static_files = [ "./functions/create-chat-conversation/*.html" ]
[functions.ai-generate-subject]
enabled = true
verify_jwt = true
import_map = "./functions/ai-generate-subject/deno.json"
# Uncomment to specify a custom file path to the entrypoint.
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
entrypoint = "./functions/ai-generate-subject/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/ai-generate-subject/*.html" ]
+167 -12
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,205 @@
create extension if not exists "http" with schema "extensions";
alter table "public"."asignaturas" drop constraint "asignaturas_facultad_propietaria_id_fkey";
alter table "public"."cambios_plan" drop constraint "cambios_plan_plan_estudio_id_fkey";
alter type "public"."tipo_cambio" rename to "tipo_cambio__old_version_to_be_dropped";
create type "public"."tipo_cambio" as enum ('ACTUALIZACION_CAMPO', 'ACTUALIZACION_MAPA', 'TRANSICION_ESTADO', 'OTRO', 'CREACION', 'ACTUALIZACION');
alter table "public"."cambios_asignatura" alter column tipo type "public"."tipo_cambio" using tipo::text::"public"."tipo_cambio";
alter table "public"."cambios_plan" alter column tipo type "public"."tipo_cambio" using tipo::text::"public"."tipo_cambio";
drop type "public"."tipo_cambio__old_version_to_be_dropped";
alter table "public"."asignaturas" drop column "facultad_propietaria_id";
alter table "public"."asignaturas" add column "asignatura_hash" text generated always as (encode(SUBSTRING(extensions.digest((id)::text, 'sha512'::text) FROM 1 FOR 12), 'hex'::text)) stored;
alter table "public"."cambios_plan" drop column "interaccion_ia_id";
alter table "public"."cambios_plan" add column "response_id" text;
alter table "public"."planes_estudio" add column "conversation_id" text;
alter table "public"."planes_estudio" add column "plan_hash" text generated always as (encode(SUBSTRING(extensions.digest((id)::text, 'sha512'::text) FROM 1 FOR 12), 'hex'::text)) stored;
CREATE UNIQUE INDEX planes_estudio_conversation_id_key ON public.planes_estudio USING btree (conversation_id);
alter table "public"."planes_estudio" add constraint "planes_estudio_conversation_id_key" UNIQUE using index "planes_estudio_conversation_id_key";
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.fn_log_cambios_planes_estudio()
RETURNS trigger
LANGUAGE plpgsql
AS $function$declare
k text;
old_val jsonb;
new_val jsonb;
v_response_id text;
begin
v_response_id := nullif(new.meta_origen->>'response_id','');
-- INSERT -> CREACION
if tg_op = 'INSERT' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
new.id,
new.creado_por,
'CREACION'::public.tipo_cambio,
null,
null,
to_jsonb(new),
null
);
return new;
end if;
-- DELETE (opcional): si no lo quieres, bórralo
if tg_op = 'DELETE' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
old.id,
old.actualizado_por,
'OTRO'::public.tipo_cambio,
'DELETE',
to_jsonb(old),
null,
null
);
return old;
end if;
-- UPDATE ----------------------------------------------------------
-- 1) Transición de estado
if (new.estado_actual_id is distinct from old.estado_actual_id) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'TRANSICION_ESTADO'::public.tipo_cambio,
'estado_actual_id',
to_jsonb(old.estado_actual_id),
to_jsonb(new.estado_actual_id),
null
);
end if;
-- 2) Cambios en JSONB "datos" (diff top-level por llave)
if (new.datos is distinct from old.datos) then
for k in
select distinct key
from (
select jsonb_object_keys(coalesce(old.datos, '{}'::jsonb)) as key
union all
select jsonb_object_keys(coalesce(new.datos, '{}'::jsonb)) as key
) t
loop
old_val := coalesce(old.datos, '{}'::jsonb) -> k;
new_val := coalesce(new.datos, '{}'::jsonb) -> k;
if (old_val is distinct from new_val) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'ACTUALIZACION_CAMPO'::public.tipo_cambio,
k,
old_val,
new_val,
v_response_id
);
end if;
end loop;
end if;
-- 3) Cambios en columnas "normales" (uno por columna)
if (new.nombre is distinct from old.nombre) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nombre', to_jsonb(old.nombre), to_jsonb(new.nombre), null);
end if;
if (new.nivel is distinct from old.nivel) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nivel', to_jsonb(old.nivel), to_jsonb(new.nivel), null);
end if;
if (new.tipo_ciclo is distinct from old.tipo_ciclo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_ciclo', to_jsonb(old.tipo_ciclo), to_jsonb(new.tipo_ciclo), null);
end if;
if (new.numero_ciclos is distinct from old.numero_ciclos) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'numero_ciclos', to_jsonb(old.numero_ciclos), to_jsonb(new.numero_ciclos), null);
end if;
if (new.activo is distinct from old.activo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'activo', to_jsonb(old.activo), to_jsonb(new.activo), null);
end if;
if (new.carrera_id is distinct from old.carrera_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'carrera_id', to_jsonb(old.carrera_id), to_jsonb(new.carrera_id), null);
end if;
if (new.estructura_id is distinct from old.estructura_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'estructura_id', to_jsonb(old.estructura_id), to_jsonb(new.estructura_id), null);
end if;
if (new.tipo_origen is distinct from old.tipo_origen) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_origen', to_jsonb(old.tipo_origen), to_jsonb(new.tipo_origen), null);
end if;
if (new.conversation_id is distinct from old.conversation_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'conversation_id', to_jsonb(old.conversation_id), to_jsonb(new.conversation_id), null);
end if;
-- 🔥 consumirlo para que NO se guarde en planes_estudio
if v_response_id is not null then
new.meta_origen := new.meta_origen - 'response_id';
end if;
return new;
end;$function$
;
CREATE TRIGGER "agregar-conversation_id-asignaturas" AFTER INSERT ON public.asignaturas FOR EACH ROW EXECUTE FUNCTION supabase_functions.http_request('https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/create-chat-conversation', 'POST', '{"Content-type":"application/json","Authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTM3ODYzMiwiZXhwIjoyMDU2OTU0NjMyfQ.s-GHuwnYbIYoMZN9dFbAKgxNyAtQllRCRPLy-GIRaro"}', '{}', '5000');
CREATE TRIGGER "agregar-conversation_id-planes_estudio" AFTER INSERT ON public.planes_estudio FOR EACH ROW EXECUTE FUNCTION supabase_functions.http_request('https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/create-chat-conversation', 'POST', '{"Content-type":"application/json","Authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTM3ODYzMiwiZXhwIjoyMDU2OTU0NjMyfQ.s-GHuwnYbIYoMZN9dFbAKgxNyAtQllRCRPLy-GIRaro"}', '{}', '5000');
CREATE TRIGGER trg_planes_estudio_log_cambios AFTER INSERT OR DELETE OR UPDATE ON public.planes_estudio FOR EACH ROW EXECUTE FUNCTION public.fn_log_cambios_planes_estudio();
@@ -1,56 +0,0 @@
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.set_actualizado_en()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
begin
new.actualizado_en = now();
return new;
end;
$function$
;
CREATE OR REPLACE FUNCTION public.unaccent_immutable(text)
RETURNS text
LANGUAGE sql
IMMUTABLE PARALLEL SAFE STRICT
AS $function$
SELECT public.unaccent('public.unaccent', $1);
$function$
;
CREATE OR REPLACE FUNCTION public.validar_numero_ciclo_asignatura()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
declare
v_numero_ciclos int;
begin
if new.numero_ciclo is null then
return new;
end if;
select pe.numero_ciclos into v_numero_ciclos
from planes_estudio pe
where pe.id = new.plan_estudio_id;
if v_numero_ciclos is null then
raise exception 'plan_estudio_id inválido %, plan no encontrado', new.plan_estudio_id;
end if;
if new.numero_ciclo < 1 then
raise exception 'numero_ciclo debe ser >= 1 (recibido %)', new.numero_ciclo;
end if;
if new.numero_ciclo > v_numero_ciclos then
raise exception 'numero_ciclo % excede planes_estudio.numero_ciclos % para plan_estudio_id %',
new.numero_ciclo, v_numero_ciclos, new.plan_estudio_id;
end if;
return new;
end;
$function$
;
+31 -8
View File
@@ -91,6 +91,30 @@ export class OpenAIService {
const uploadedToStorage = await this.uploadFilesToStorage(files ?? []); const uploadedToStorage = await this.uploadFilesToStorage(files ?? []);
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []); const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
const newOptions = { ...options };
// Attach file references to the request as an extra user message
if (openaiFileIds.length > 0) {
const fileParts: OpenAITypes.OpenAI.Responses.ResponseInputFile[] =
openaiFileIds.map((id) => ({
type: "input_file",
file_id: id,
}));
const arr = Array.isArray(options.input) ? options.input : [];
arr.push({
role: "user",
content: [
...fileParts,
{
type: "input_text",
text: "Usa estos archivos como referencia",
},
],
});
newOptions.input = arr;
}
// Narrow to non-streaming response // Narrow to non-streaming response
const openaiRaw = (await this.openai.responses.create( const openaiRaw = (await this.openai.responses.create(
newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming, newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming,
@@ -98,12 +122,11 @@ export class OpenAIService {
const { model, id: responseId } = openaiRaw; const { model, id: responseId } = openaiRaw;
const usage = openaiRaw?.usage ?? null; const usage = openaiRaw?.usage ?? null;
const conversationId = const conversationId = (
( openaiRaw as OpenAITypes.OpenAI.Responses.Response & {
openaiRaw as OpenAITypes.OpenAI.Responses.Response & { conversation_id?: string | null;
conversation_id?: string | null; }
} ).conversation_id ?? null;
).conversation_id ?? null;
// Try to read structured JSON output // Try to read structured JSON output
let output: TOutput | undefined = undefined; let output: TOutput | undefined = undefined;
@@ -148,8 +171,8 @@ export class OpenAIService {
const code = message.includes("Supabase upload failed") const code = message.includes("Supabase upload failed")
? "StorageUploadFailed" ? "StorageUploadFailed"
: message.includes("OpenAI file upload failed") : message.includes("OpenAI file upload failed")
? "OpenAIFileUploadFailed" ? "OpenAIFileUploadFailed"
: "OpenAIRequestFailed"; : "OpenAIRequestFailed";
return { ok: false, code, message, cause: err }; return { ok: false, code, message, cause: err };
} }
+49
View File
@@ -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"
} }
} }
+163 -63
View File
@@ -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,
); );
} }
@@ -172,25 +203,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
@@ -209,7 +279,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"] =
@@ -257,42 +340,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",
);
} }
}); });
@@ -324,10 +427,7 @@ const DatosBasicosSchema: z.ZodType<AIGeneratePlanInput["datosBasicos"]> = z
}); });
const IAConfigSchema: z.ZodType<AIGeneratePlanInput["iaConfig"]> = z.object({ const IAConfigSchema: z.ZodType<AIGeneratePlanInput["iaConfig"]> = z.object({
descripcionEnfoqueAcademico: z.string().min( descripcionEnfoqueAcademico: z.string(),
10,
"La descripción es muy corta",
),
instruccionesAdicionalesIA: 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(),
@@ -0,0 +1,3 @@
# Configuration for private npm package dependencies
# For more information on using private registries with Edge Functions, see:
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
@@ -0,0 +1,3 @@
{
"imports": {}
}
@@ -0,0 +1,107 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { corsHeaders } from "../_shared/cors.ts";
import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts";
import { createClient } from "npm:@supabase/supabase-js@2";
import type { Database, Json } from "../_shared/database.types.ts";
console.log("Hello from Functions!");
Deno.serve(async (req) => {
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 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,
);
// INICIO DE CODIGO PARA DEBBUGGING
console.log(
`[${
new Date().toISOString()
}][${functionName}]: Request processed successfully`,
);
const { data: asignatura_debug, error: asignatura_error } =
await supabaseService
.from("asignaturas")
.select("*")
.eq("id", "9d4dda6a-488f-428a-8a07-38081592a641")
.single();
return sendSuccess(asignatura_debug);
// FIN DE CODIGO PARA DEBBUGGING
} 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",
);
}
});
@@ -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 }
> { > {
@@ -0,0 +1,21 @@
-- alter table "public"."asignaturas" drop constraint "asignaturas_facultad_propietaria_id_fkey";
-- alter table "public"."asignaturas" drop constraint "asignaturas_horas_semana_check";
-- alter table "public"."asignaturas" drop column "facultad_propietaria_id";
alter table "public"."asignaturas" drop column "horas_semana";
alter table "public"."asignaturas" add column "horas_academicas" integer;
alter table "public"."asignaturas" add column "horas_independientes" integer;
alter table "public"."asignaturas" add constraint "asignaturas_horas_academicas_check" CHECK (((horas_academicas IS NULL) OR (horas_academicas >= 0))) not valid;
alter table "public"."asignaturas" validate constraint "asignaturas_horas_academicas_check";
alter table "public"."asignaturas" add constraint "asignaturas_horas_independientes_check" CHECK (((horas_independientes IS NULL) OR (horas_independientes >= 0))) not valid;
alter table "public"."asignaturas" validate constraint "asignaturas_horas_independientes_check";
@@ -0,0 +1,205 @@
create extension if not exists "http" with schema "extensions";
alter table "public"."asignaturas" drop constraint if exists "asignaturas_facultad_propietaria_id_fkey";
alter table "public"."cambios_plan" drop constraint "cambios_plan_plan_estudio_id_fkey";
alter type "public"."tipo_cambio" rename to "tipo_cambio__old_version_to_be_dropped";
create type "public"."tipo_cambio" as enum ('ACTUALIZACION_CAMPO', 'ACTUALIZACION_MAPA', 'TRANSICION_ESTADO', 'OTRO', 'CREACION', 'ACTUALIZACION');
alter table "public"."cambios_asignatura" alter column tipo type "public"."tipo_cambio" using tipo::text::"public"."tipo_cambio";
alter table "public"."cambios_plan" alter column tipo type "public"."tipo_cambio" using tipo::text::"public"."tipo_cambio";
drop type "public"."tipo_cambio__old_version_to_be_dropped";
alter table "public"."asignaturas" drop column if exists "facultad_propietaria_id";
alter table "public"."asignaturas" add column "asignatura_hash" text generated always as (encode(SUBSTRING(extensions.digest((id)::text, 'sha512'::text) FROM 1 FOR 12), 'hex'::text)) stored;
alter table "public"."cambios_plan" drop column "interaccion_ia_id";
alter table "public"."cambios_plan" add column "response_id" text;
alter table "public"."planes_estudio" add column "conversation_id" text;
alter table "public"."planes_estudio" add column "plan_hash" text generated always as (encode(SUBSTRING(extensions.digest((id)::text, 'sha512'::text) FROM 1 FOR 12), 'hex'::text)) stored;
CREATE UNIQUE INDEX planes_estudio_conversation_id_key ON public.planes_estudio USING btree (conversation_id);
alter table "public"."planes_estudio" add constraint "planes_estudio_conversation_id_key" UNIQUE using index "planes_estudio_conversation_id_key";
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.fn_log_cambios_planes_estudio()
RETURNS trigger
LANGUAGE plpgsql
AS $function$declare
k text;
old_val jsonb;
new_val jsonb;
v_response_id text;
begin
v_response_id := nullif(new.meta_origen->>'response_id','');
-- INSERT -> CREACION
if tg_op = 'INSERT' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
new.id,
new.creado_por,
'CREACION'::public.tipo_cambio,
null,
null,
to_jsonb(new),
null
);
return new;
end if;
-- DELETE (opcional): si no lo quieres, bórralo
if tg_op = 'DELETE' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
old.id,
old.actualizado_por,
'OTRO'::public.tipo_cambio,
'DELETE',
to_jsonb(old),
null,
null
);
return old;
end if;
-- UPDATE ----------------------------------------------------------
-- 1) Transición de estado
if (new.estado_actual_id is distinct from old.estado_actual_id) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'TRANSICION_ESTADO'::public.tipo_cambio,
'estado_actual_id',
to_jsonb(old.estado_actual_id),
to_jsonb(new.estado_actual_id),
null
);
end if;
-- 2) Cambios en JSONB "datos" (diff top-level por llave)
if (new.datos is distinct from old.datos) then
for k in
select distinct key
from (
select jsonb_object_keys(coalesce(old.datos, '{}'::jsonb)) as key
union all
select jsonb_object_keys(coalesce(new.datos, '{}'::jsonb)) as key
) t
loop
old_val := coalesce(old.datos, '{}'::jsonb) -> k;
new_val := coalesce(new.datos, '{}'::jsonb) -> k;
if (old_val is distinct from new_val) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'ACTUALIZACION_CAMPO'::public.tipo_cambio,
k,
old_val,
new_val,
v_response_id
);
end if;
end loop;
end if;
-- 3) Cambios en columnas "normales" (uno por columna)
if (new.nombre is distinct from old.nombre) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nombre', to_jsonb(old.nombre), to_jsonb(new.nombre), null);
end if;
if (new.nivel is distinct from old.nivel) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nivel', to_jsonb(old.nivel), to_jsonb(new.nivel), null);
end if;
if (new.tipo_ciclo is distinct from old.tipo_ciclo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_ciclo', to_jsonb(old.tipo_ciclo), to_jsonb(new.tipo_ciclo), null);
end if;
if (new.numero_ciclos is distinct from old.numero_ciclos) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'numero_ciclos', to_jsonb(old.numero_ciclos), to_jsonb(new.numero_ciclos), null);
end if;
if (new.activo is distinct from old.activo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'activo', to_jsonb(old.activo), to_jsonb(new.activo), null);
end if;
if (new.carrera_id is distinct from old.carrera_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'carrera_id', to_jsonb(old.carrera_id), to_jsonb(new.carrera_id), null);
end if;
if (new.estructura_id is distinct from old.estructura_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'estructura_id', to_jsonb(old.estructura_id), to_jsonb(new.estructura_id), null);
end if;
if (new.tipo_origen is distinct from old.tipo_origen) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_origen', to_jsonb(old.tipo_origen), to_jsonb(new.tipo_origen), null);
end if;
if (new.conversation_id is distinct from old.conversation_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'conversation_id', to_jsonb(old.conversation_id), to_jsonb(new.conversation_id), null);
end if;
-- 🔥 consumirlo para que NO se guarde en planes_estudio
if v_response_id is not null then
new.meta_origen := new.meta_origen - 'response_id';
end if;
return new;
end;$function$
;
CREATE TRIGGER "agregar-conversation_id-asignaturas" AFTER INSERT ON public.asignaturas FOR EACH ROW EXECUTE FUNCTION supabase_functions.http_request('https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/create-chat-conversation', 'POST', '{"Content-type":"application/json","Authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTM3ODYzMiwiZXhwIjoyMDU2OTU0NjMyfQ.s-GHuwnYbIYoMZN9dFbAKgxNyAtQllRCRPLy-GIRaro"}', '{}', '5000');
CREATE TRIGGER "agregar-conversation_id-planes_estudio" AFTER INSERT ON public.planes_estudio FOR EACH ROW EXECUTE FUNCTION supabase_functions.http_request('https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/create-chat-conversation', 'POST', '{"Content-type":"application/json","Authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTM3ODYzMiwiZXhwIjoyMDU2OTU0NjMyfQ.s-GHuwnYbIYoMZN9dFbAKgxNyAtQllRCRPLy-GIRaro"}', '{}', '5000');
CREATE TRIGGER trg_planes_estudio_log_cambios AFTER INSERT OR DELETE OR UPDATE ON public.planes_estudio FOR EACH ROW EXECUTE FUNCTION public.fn_log_cambios_planes_estudio();
+260 -14
View File
@@ -30,6 +30,13 @@ COMMENT ON SCHEMA "public" IS 'standard public schema';
CREATE EXTENSION IF NOT EXISTS "http" WITH SCHEMA "extensions";
CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql"; CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql";
@@ -159,7 +166,8 @@ CREATE TYPE "public"."tipo_cambio" AS ENUM (
'ACTUALIZACION_MAPA', 'ACTUALIZACION_MAPA',
'TRANSICION_ESTADO', 'TRANSICION_ESTADO',
'OTRO', 'OTRO',
'CREACION' 'CREACION',
'ACTUALIZACION'
); );
@@ -230,6 +238,172 @@ CREATE TYPE "public"."tipo_origen" AS ENUM (
ALTER TYPE "public"."tipo_origen" OWNER TO "postgres"; ALTER TYPE "public"."tipo_origen" OWNER TO "postgres";
CREATE OR REPLACE FUNCTION "public"."fn_log_cambios_planes_estudio"() RETURNS "trigger"
LANGUAGE "plpgsql"
AS $$declare
k text;
old_val jsonb;
new_val jsonb;
v_response_id text;
begin
v_response_id := nullif(new.meta_origen->>'response_id','');
-- INSERT -> CREACION
if tg_op = 'INSERT' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
new.id,
new.creado_por,
'CREACION'::public.tipo_cambio,
null,
null,
to_jsonb(new),
null
);
return new;
end if;
-- DELETE (opcional): si no lo quieres, bórralo
if tg_op = 'DELETE' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
old.id,
old.actualizado_por,
'OTRO'::public.tipo_cambio,
'DELETE',
to_jsonb(old),
null,
null
);
return old;
end if;
-- UPDATE ----------------------------------------------------------
-- 1) Transición de estado
if (new.estado_actual_id is distinct from old.estado_actual_id) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'TRANSICION_ESTADO'::public.tipo_cambio,
'estado_actual_id',
to_jsonb(old.estado_actual_id),
to_jsonb(new.estado_actual_id),
null
);
end if;
-- 2) Cambios en JSONB "datos" (diff top-level por llave)
if (new.datos is distinct from old.datos) then
for k in
select distinct key
from (
select jsonb_object_keys(coalesce(old.datos, '{}'::jsonb)) as key
union all
select jsonb_object_keys(coalesce(new.datos, '{}'::jsonb)) as key
) t
loop
old_val := coalesce(old.datos, '{}'::jsonb) -> k;
new_val := coalesce(new.datos, '{}'::jsonb) -> k;
if (old_val is distinct from new_val) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'ACTUALIZACION_CAMPO'::public.tipo_cambio,
k,
old_val,
new_val,
v_response_id
);
end if;
end loop;
end if;
-- 3) Cambios en columnas "normales" (uno por columna)
if (new.nombre is distinct from old.nombre) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nombre', to_jsonb(old.nombre), to_jsonb(new.nombre), null);
end if;
if (new.nivel is distinct from old.nivel) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nivel', to_jsonb(old.nivel), to_jsonb(new.nivel), null);
end if;
if (new.tipo_ciclo is distinct from old.tipo_ciclo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_ciclo', to_jsonb(old.tipo_ciclo), to_jsonb(new.tipo_ciclo), null);
end if;
if (new.numero_ciclos is distinct from old.numero_ciclos) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'numero_ciclos', to_jsonb(old.numero_ciclos), to_jsonb(new.numero_ciclos), null);
end if;
if (new.activo is distinct from old.activo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'activo', to_jsonb(old.activo), to_jsonb(new.activo), null);
end if;
if (new.carrera_id is distinct from old.carrera_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'carrera_id', to_jsonb(old.carrera_id), to_jsonb(new.carrera_id), null);
end if;
if (new.estructura_id is distinct from old.estructura_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'estructura_id', to_jsonb(old.estructura_id), to_jsonb(new.estructura_id), null);
end if;
if (new.tipo_origen is distinct from old.tipo_origen) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_origen', to_jsonb(old.tipo_origen), to_jsonb(new.tipo_origen), null);
end if;
if (new.conversation_id is distinct from old.conversation_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'conversation_id', to_jsonb(old.conversation_id), to_jsonb(new.conversation_id), null);
end if;
-- 🔥 consumirlo para que NO se guarde en planes_estudio
if v_response_id is not null then
new.meta_origen := new.meta_origen - 'response_id';
end if;
return new;
end;$$;
ALTER FUNCTION "public"."fn_log_cambios_planes_estudio"() OWNER TO "postgres";
CREATE OR REPLACE FUNCTION "public"."set_actualizado_en"() RETURNS "trigger" CREATE OR REPLACE FUNCTION "public"."set_actualizado_en"() RETURNS "trigger"
LANGUAGE "plpgsql" LANGUAGE "plpgsql"
AS $$ AS $$
@@ -246,7 +420,7 @@ ALTER FUNCTION "public"."set_actualizado_en"() OWNER TO "postgres";
CREATE OR REPLACE FUNCTION "public"."unaccent_immutable"("text") RETURNS "text" CREATE OR REPLACE FUNCTION "public"."unaccent_immutable"("text") RETURNS "text"
LANGUAGE "sql" IMMUTABLE STRICT PARALLEL SAFE LANGUAGE "sql" IMMUTABLE STRICT PARALLEL SAFE
AS $_$ AS $_$
SELECT unaccent('unaccent', $1); SELECT public.unaccent('public.unaccent', $1);
$_$; $_$;
@@ -313,7 +487,6 @@ CREATE TABLE IF NOT EXISTS "public"."asignaturas" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"plan_estudio_id" "uuid" NOT NULL, "plan_estudio_id" "uuid" NOT NULL,
"estructura_id" "uuid", "estructura_id" "uuid",
"facultad_propietaria_id" "uuid",
"codigo" "text", "codigo" "text",
"nombre" "text" NOT NULL, "nombre" "text" NOT NULL,
"tipo" "public"."tipo_asignatura" DEFAULT 'OBLIGATORIA'::"public"."tipo_asignatura" NOT NULL, "tipo" "public"."tipo_asignatura" DEFAULT 'OBLIGATORIA'::"public"."tipo_asignatura" NOT NULL,
@@ -330,6 +503,7 @@ CREATE TABLE IF NOT EXISTS "public"."asignaturas" (
"actualizado_por" "uuid", "actualizado_por" "uuid",
"creado_en" timestamp with time zone DEFAULT "now"() NOT NULL, "creado_en" timestamp with time zone DEFAULT "now"() NOT NULL,
"actualizado_en" timestamp with time zone DEFAULT "now"() NOT NULL, "actualizado_en" timestamp with time zone DEFAULT "now"() NOT NULL,
"asignatura_hash" "text" GENERATED ALWAYS AS ("encode"(SUBSTRING("extensions"."digest"(("id")::"text", 'sha512'::"text") FROM 1 FOR 12), 'hex'::"text")) STORED,
CONSTRAINT "asignaturas_ciclo_chk" CHECK ((("numero_ciclo" IS NULL) OR ("numero_ciclo" > 0))), CONSTRAINT "asignaturas_ciclo_chk" CHECK ((("numero_ciclo" IS NULL) OR ("numero_ciclo" > 0))),
CONSTRAINT "asignaturas_creditos_check" CHECK (("creditos" >= (0)::numeric)), CONSTRAINT "asignaturas_creditos_check" CHECK (("creditos" >= (0)::numeric)),
CONSTRAINT "asignaturas_horas_semana_check" CHECK ((("horas_semana" IS NULL) OR ("horas_semana" >= 0))), CONSTRAINT "asignaturas_horas_semana_check" CHECK ((("horas_semana" IS NULL) OR ("horas_semana" >= 0))),
@@ -382,13 +556,17 @@ CREATE TABLE IF NOT EXISTS "public"."cambios_plan" (
"campo" "text", "campo" "text",
"valor_anterior" "jsonb", "valor_anterior" "jsonb",
"valor_nuevo" "jsonb", "valor_nuevo" "jsonb",
"interaccion_ia_id" "uuid" "response_id" "text"
); );
ALTER TABLE "public"."cambios_plan" OWNER TO "postgres"; ALTER TABLE "public"."cambios_plan" OWNER TO "postgres";
COMMENT ON COLUMN "public"."cambios_plan"."response_id" IS 'El ID de respuesta de OpenAI';
CREATE TABLE IF NOT EXISTS "public"."carreras" ( CREATE TABLE IF NOT EXISTS "public"."carreras" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"facultad_id" "uuid" NOT NULL, "facultad_id" "uuid" NOT NULL,
@@ -525,6 +703,8 @@ CREATE TABLE IF NOT EXISTS "public"."planes_estudio" (
"creado_en" timestamp with time zone DEFAULT "now"() NOT NULL, "creado_en" timestamp with time zone DEFAULT "now"() NOT NULL,
"actualizado_en" timestamp with time zone DEFAULT "now"() NOT NULL, "actualizado_en" timestamp with time zone DEFAULT "now"() NOT NULL,
"nombre_search" "text" GENERATED ALWAYS AS ("lower"("public"."unaccent_immutable"("nombre"))) STORED, "nombre_search" "text" GENERATED ALWAYS AS ("lower"("public"."unaccent_immutable"("nombre"))) STORED,
"plan_hash" "text" GENERATED ALWAYS AS ("encode"(SUBSTRING("extensions"."digest"(("id")::"text", 'sha512'::"text") FROM 1 FOR 12), 'hex'::"text")) STORED,
"conversation_id" "text",
CONSTRAINT "planes_estudio_numero_ciclos_check" CHECK (("numero_ciclos" > 0)) CONSTRAINT "planes_estudio_numero_ciclos_check" CHECK (("numero_ciclos" > 0))
); );
@@ -532,6 +712,10 @@ CREATE TABLE IF NOT EXISTS "public"."planes_estudio" (
ALTER TABLE "public"."planes_estudio" OWNER TO "postgres"; ALTER TABLE "public"."planes_estudio" OWNER TO "postgres";
COMMENT ON COLUMN "public"."planes_estudio"."conversation_id" IS 'ID de conversación de OpenAI';
CREATE OR REPLACE VIEW "public"."plantilla_plan" AS CREATE OR REPLACE VIEW "public"."plantilla_plan" AS
SELECT "plan"."id" AS "plan_estudio_id", SELECT "plan"."id" AS "plan_estudio_id",
"struct"."id" AS "estructura_id", "struct"."id" AS "estructura_id",
@@ -714,6 +898,11 @@ ALTER TABLE ONLY "public"."notificaciones"
ALTER TABLE ONLY "public"."planes_estudio"
ADD CONSTRAINT "planes_estudio_conversation_id_key" UNIQUE ("conversation_id");
ALTER TABLE ONLY "public"."planes_estudio" ALTER TABLE ONLY "public"."planes_estudio"
ADD CONSTRAINT "planes_estudio_pkey" PRIMARY KEY ("id"); ADD CONSTRAINT "planes_estudio_pkey" PRIMARY KEY ("id");
@@ -826,6 +1015,10 @@ CREATE OR REPLACE TRIGGER "trg_planes_estudio_actualizado_en" BEFORE UPDATE ON "
CREATE OR REPLACE TRIGGER "trg_planes_estudio_log_cambios" AFTER INSERT OR DELETE OR UPDATE ON "public"."planes_estudio" FOR EACH ROW EXECUTE FUNCTION "public"."fn_log_cambios_planes_estudio"();
CREATE OR REPLACE TRIGGER "trg_usuarios_app_actualizado_en" BEFORE UPDATE ON "public"."usuarios_app" FOR EACH ROW EXECUTE FUNCTION "public"."set_actualizado_en"(); CREATE OR REPLACE TRIGGER "trg_usuarios_app_actualizado_en" BEFORE UPDATE ON "public"."usuarios_app" FOR EACH ROW EXECUTE FUNCTION "public"."set_actualizado_en"();
@@ -854,11 +1047,6 @@ ALTER TABLE ONLY "public"."asignaturas"
ALTER TABLE ONLY "public"."asignaturas"
ADD CONSTRAINT "asignaturas_facultad_propietaria_id_fkey" FOREIGN KEY ("facultad_propietaria_id") REFERENCES "public"."facultades"("id") ON DELETE SET NULL;
ALTER TABLE ONLY "public"."asignaturas" ALTER TABLE ONLY "public"."asignaturas"
ADD CONSTRAINT "asignaturas_linea_plan_fk_compuesta" FOREIGN KEY ("linea_plan_id", "plan_estudio_id") REFERENCES "public"."lineas_plan"("id", "plan_estudio_id") ON DELETE SET NULL; ADD CONSTRAINT "asignaturas_linea_plan_fk_compuesta" FOREIGN KEY ("linea_plan_id", "plan_estudio_id") REFERENCES "public"."lineas_plan"("id", "plan_estudio_id") ON DELETE SET NULL;
@@ -894,11 +1082,6 @@ ALTER TABLE ONLY "public"."cambios_plan"
ALTER TABLE ONLY "public"."cambios_plan"
ADD CONSTRAINT "cambios_plan_plan_estudio_id_fkey" FOREIGN KEY ("plan_estudio_id") REFERENCES "public"."planes_estudio"("id") ON DELETE CASCADE;
ALTER TABLE ONLY "public"."carreras" ALTER TABLE ONLY "public"."carreras"
ADD CONSTRAINT "carreras_facultad_id_fkey" FOREIGN KEY ("facultad_id") REFERENCES "public"."facultades"("id") ON DELETE RESTRICT; ADD CONSTRAINT "carreras_facultad_id_fkey" FOREIGN KEY ("facultad_id") REFERENCES "public"."facultades"("id") ON DELETE RESTRICT;
@@ -1220,6 +1403,69 @@ GRANT USAGE ON SCHEMA "public" TO "service_role";
GRANT ALL ON FUNCTION "public"."fn_log_cambios_planes_estudio"() TO "anon";
GRANT ALL ON FUNCTION "public"."fn_log_cambios_planes_estudio"() TO "authenticated";
GRANT ALL ON FUNCTION "public"."fn_log_cambios_planes_estudio"() TO "service_role";
GRANT ALL ON FUNCTION "public"."set_actualizado_en"() TO "anon"; GRANT ALL ON FUNCTION "public"."set_actualizado_en"() TO "anon";
GRANT ALL ON FUNCTION "public"."set_actualizado_en"() TO "authenticated"; GRANT ALL ON FUNCTION "public"."set_actualizado_en"() TO "authenticated";
GRANT ALL ON FUNCTION "public"."set_actualizado_en"() TO "service_role"; GRANT ALL ON FUNCTION "public"."set_actualizado_en"() TO "service_role";
+205
View File
@@ -0,0 +1,205 @@
create extension if not exists "http" with schema "extensions";
alter table "public"."asignaturas" drop constraint if exists "asignaturas_facultad_propietaria_id_fkey";
alter table "public"."cambios_plan" drop constraint "cambios_plan_plan_estudio_id_fkey";
alter type "public"."tipo_cambio" rename to "tipo_cambio__old_version_to_be_dropped";
create type "public"."tipo_cambio" as enum ('ACTUALIZACION_CAMPO', 'ACTUALIZACION_MAPA', 'TRANSICION_ESTADO', 'OTRO', 'CREACION', 'ACTUALIZACION');
alter table "public"."cambios_asignatura" alter column tipo type "public"."tipo_cambio" using tipo::text::"public"."tipo_cambio";
alter table "public"."cambios_plan" alter column tipo type "public"."tipo_cambio" using tipo::text::"public"."tipo_cambio";
drop type "public"."tipo_cambio__old_version_to_be_dropped";
alter table "public"."asignaturas" drop column if exists "facultad_propietaria_id";
alter table "public"."asignaturas" add column "asignatura_hash" text generated always as (encode(SUBSTRING(extensions.digest((id)::text, 'sha512'::text) FROM 1 FOR 12), 'hex'::text)) stored;
alter table "public"."cambios_plan" drop column "interaccion_ia_id";
alter table "public"."cambios_plan" add column "response_id" text;
alter table "public"."planes_estudio" add column "conversation_id" text;
alter table "public"."planes_estudio" add column "plan_hash" text generated always as (encode(SUBSTRING(extensions.digest((id)::text, 'sha512'::text) FROM 1 FOR 12), 'hex'::text)) stored;
CREATE UNIQUE INDEX planes_estudio_conversation_id_key ON public.planes_estudio USING btree (conversation_id);
alter table "public"."planes_estudio" add constraint "planes_estudio_conversation_id_key" UNIQUE using index "planes_estudio_conversation_id_key";
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.fn_log_cambios_planes_estudio()
RETURNS trigger
LANGUAGE plpgsql
AS $function$declare
k text;
old_val jsonb;
new_val jsonb;
v_response_id text;
begin
v_response_id := nullif(new.meta_origen->>'response_id','');
-- INSERT -> CREACION
if tg_op = 'INSERT' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
new.id,
new.creado_por,
'CREACION'::public.tipo_cambio,
null,
null,
to_jsonb(new),
null
);
return new;
end if;
-- DELETE (opcional): si no lo quieres, bórralo
if tg_op = 'DELETE' then
insert into public.cambios_plan (
plan_estudio_id,
cambiado_por,
tipo,
campo,
valor_anterior,
valor_nuevo,
response_id
)
values (
old.id,
old.actualizado_por,
'OTRO'::public.tipo_cambio,
'DELETE',
to_jsonb(old),
null,
null
);
return old;
end if;
-- UPDATE ----------------------------------------------------------
-- 1) Transición de estado
if (new.estado_actual_id is distinct from old.estado_actual_id) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'TRANSICION_ESTADO'::public.tipo_cambio,
'estado_actual_id',
to_jsonb(old.estado_actual_id),
to_jsonb(new.estado_actual_id),
null
);
end if;
-- 2) Cambios en JSONB "datos" (diff top-level por llave)
if (new.datos is distinct from old.datos) then
for k in
select distinct key
from (
select jsonb_object_keys(coalesce(old.datos, '{}'::jsonb)) as key
union all
select jsonb_object_keys(coalesce(new.datos, '{}'::jsonb)) as key
) t
loop
old_val := coalesce(old.datos, '{}'::jsonb) -> k;
new_val := coalesce(new.datos, '{}'::jsonb) -> k;
if (old_val is distinct from new_val) then
insert into public.cambios_plan (
plan_estudio_id, cambiado_por, tipo, campo, valor_anterior, valor_nuevo, response_id
)
values (
new.id,
new.actualizado_por,
'ACTUALIZACION_CAMPO'::public.tipo_cambio,
k,
old_val,
new_val,
v_response_id
);
end if;
end loop;
end if;
-- 3) Cambios en columnas "normales" (uno por columna)
if (new.nombre is distinct from old.nombre) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nombre', to_jsonb(old.nombre), to_jsonb(new.nombre), null);
end if;
if (new.nivel is distinct from old.nivel) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'nivel', to_jsonb(old.nivel), to_jsonb(new.nivel), null);
end if;
if (new.tipo_ciclo is distinct from old.tipo_ciclo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_ciclo', to_jsonb(old.tipo_ciclo), to_jsonb(new.tipo_ciclo), null);
end if;
if (new.numero_ciclos is distinct from old.numero_ciclos) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'numero_ciclos', to_jsonb(old.numero_ciclos), to_jsonb(new.numero_ciclos), null);
end if;
if (new.activo is distinct from old.activo) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'activo', to_jsonb(old.activo), to_jsonb(new.activo), null);
end if;
if (new.carrera_id is distinct from old.carrera_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'carrera_id', to_jsonb(old.carrera_id), to_jsonb(new.carrera_id), null);
end if;
if (new.estructura_id is distinct from old.estructura_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'estructura_id', to_jsonb(old.estructura_id), to_jsonb(new.estructura_id), null);
end if;
if (new.tipo_origen is distinct from old.tipo_origen) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'tipo_origen', to_jsonb(old.tipo_origen), to_jsonb(new.tipo_origen), null);
end if;
if (new.conversation_id is distinct from old.conversation_id) then
insert into public.cambios_plan values (gen_random_uuid(), new.id, new.actualizado_por, now(),
'ACTUALIZACION'::public.tipo_cambio, 'conversation_id', to_jsonb(old.conversation_id), to_jsonb(new.conversation_id), null);
end if;
-- 🔥 consumirlo para que NO se guarde en planes_estudio
if v_response_id is not null then
new.meta_origen := new.meta_origen - 'response_id';
end if;
return new;
end;$function$
;
CREATE TRIGGER "agregar-conversation_id-asignaturas" AFTER INSERT ON public.asignaturas FOR EACH ROW EXECUTE FUNCTION supabase_functions.http_request('https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/create-chat-conversation', 'POST', '{"Content-type":"application/json","Authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTM3ODYzMiwiZXhwIjoyMDU2OTU0NjMyfQ.s-GHuwnYbIYoMZN9dFbAKgxNyAtQllRCRPLy-GIRaro"}', '{}', '5000');
CREATE TRIGGER "agregar-conversation_id-planes_estudio" AFTER INSERT ON public.planes_estudio FOR EACH ROW EXECUTE FUNCTION supabase_functions.http_request('https://exdkssurzmjnnhgtiama.supabase.co/functions/v1/create-chat-conversation', 'POST', '{"Content-type":"application/json","Authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTM3ODYzMiwiZXhwIjoyMDU2OTU0NjMyfQ.s-GHuwnYbIYoMZN9dFbAKgxNyAtQllRCRPLy-GIRaro"}', '{}', '5000');
CREATE TRIGGER trg_planes_estudio_log_cambios AFTER INSERT OR DELETE OR UPDATE ON public.planes_estudio FOR EACH ROW EXECUTE FUNCTION public.fn_log_cambios_planes_estudio();
@@ -0,0 +1,128 @@
INSERT INTO public.facultades (nombre, nombre_corto, color, icono) VALUES
--('Otros', 'OTRO', '#64748B', 'MoreHorizontal'),
('Coordinación de Desarrollo Humano Profesional', 'CDHP', '#F472B6', 'HeartHandshake'),
('Centro de Idiomas', 'CI', '#2DD4BF', 'Languages'),
--('Campus Virtual La Salle', 'CVLS', '#8B5CF6', 'Monitor'),
('Escuela de Altos Estudios en Salud', 'EAES', '#F43F5E', 'Stethoscope'),
--('Escuela Preparatoria Unidad Santa Teresa', 'EPST', '#EAB308', 'Backpack'),
--('Escuela Preparatoria Unidad Condesa', 'EPUC', '#F59E0B', 'BookOpen'),
('Facultad Mexicana de Arquitectura, Diseño y Comunicación', 'FAMADYC', '#EC4899', 'DraftingCompass'),
--('Facultad de Negocios', 'NEG', '#3B82F6', 'Briefcase'),
('Facultad de Derecho', 'DER', '#64748B', 'Scale'),
('Facultad de Humanidades y Ciencias Sociales', 'HUM', '#6366F1', 'Users'),
--('Facultad de Ingeniería', 'ING', '#F97316', 'Cpu'),
('Facultad Mexicana de Medicina', 'MED', '#10B981', 'HeartPulse'),
('Facultad de Ciencias Químicas', 'FCQ', '#84CC16', 'FlaskConical'),
('Escuela de Ciencias Religiosas', 'ECR', '#0EA5E9', 'BookHeart'),
('Escuela de Ciencias de la Educación', 'EDU', '#FB7185', 'GraduationCap');
--('Dirección Campus Virtual', 'DCV', '#A855F7', 'Globe');
INSERT INTO public.carreras (facultad_id, nombre, nombre_corto) VALUES
-- Coordinación de Desarrollo Humano Profesional
((SELECT id FROM public.facultades WHERE nombre = 'Coordinación de Desarrollo Humano Profesional' LIMIT 1), 'Área Curricular Común 2013', 'ACC'),
((SELECT id FROM public.facultades WHERE nombre = 'Coordinación de Desarrollo Humano Profesional' LIMIT 1), 'Área Curricular Común 2021', 'ACC'),
-- Centro de Idiomas
((SELECT id FROM public.facultades WHERE nombre = 'Centro de Idiomas' LIMIT 1), 'Área Curricular Común (Centro de Idiomas) 2013', 'ACCI'),
((SELECT id FROM public.facultades WHERE nombre = 'Centro de Idiomas' LIMIT 1), 'Área Curricular Común (Centro de Idiomas) 2021', 'ACCI'),
-- Escuela de Altos Estudios en Salud
((SELECT id FROM public.facultades WHERE nombre = 'Escuela de Altos Estudios en Salud' LIMIT 1), 'Licenciatura en Enfermería', 'LENF'),
((SELECT id FROM public.facultades WHERE nombre = 'Escuela de Altos Estudios en Salud' LIMIT 1), 'Licenciatura en Fisioterapia y Promoción para la Salud', 'LFPS'),
((SELECT id FROM public.facultades WHERE nombre = 'Escuela de Altos Estudios en Salud' LIMIT 1), 'Licenciatura en Ciencias en el Deporte', 'LCD'),
((SELECT id FROM public.facultades WHERE nombre = 'Escuela de Altos Estudios en Salud' LIMIT 1), 'Licenciatura en Medicina Dental', 'LMD'),
((SELECT id FROM public.facultades WHERE nombre = 'Escuela de Altos Estudios en Salud' LIMIT 1), 'Maestría en Nutrición Clínica', 'MNC'),
((SELECT id FROM public.facultades WHERE nombre = 'Escuela de Altos Estudios en Salud' LIMIT 1), 'Licenciatura en Fisioterapia', 'LFIS'),
-- Facultad Mexicana de Arquitectura, Diseño y Comunicación
((SELECT id FROM public.facultades WHERE nombre = 'Facultad Mexicana de Arquitectura, Diseño y Comunicación' LIMIT 1), 'Licenciatura en Arquitectura', 'ARQ'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad Mexicana de Arquitectura, Diseño y Comunicación' LIMIT 1), 'Licenciatura en Ciencias de la Comunicación', 'LCC'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad Mexicana de Arquitectura, Diseño y Comunicación' LIMIT 1), 'Licenciatura en Diseño Gráfico y Digital', 'LDGD'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad Mexicana de Arquitectura, Diseño y Comunicación' LIMIT 1), 'Licenciatura en Diseño de Ambientes Interiores y Exteriores', 'LDAI'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad Mexicana de Arquitectura, Diseño y Comunicación' LIMIT 1), 'Especialidad en Gestión y Administración de Proyectos', 'EGAP'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad Mexicana de Arquitectura, Diseño y Comunicación' LIMIT 1), 'Maestría en Estrategia e Innovación en Marcas', 'MEIM'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad Mexicana de Arquitectura, Diseño y Comunicación' LIMIT 1), 'Maestría en Dirección Estratégica de Comunicación', 'MDEC'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad Mexicana de Arquitectura, Diseño y Comunicación' LIMIT 1), 'Licenciatura en Comunicación', 'LCOM'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad Mexicana de Arquitectura, Diseño y Comunicación' LIMIT 1), 'Licenciatura en Diseño de Productos', 'LDP'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad Mexicana de Arquitectura, Diseño y Comunicación' LIMIT 1), 'Especialidad en Dirección de Proyectos', 'EDP'),
-- Facultad de Derecho
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Derecho' LIMIT 1), 'Licenciatura en Derecho', 'LDER'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Derecho' LIMIT 1), 'Licenciatura en Relaciones Internacionales', 'LRI'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Derecho' LIMIT 1), 'Maestría en Derecho Financiero', 'MDF'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Derecho' LIMIT 1), 'Especialidad en Derecho de Empresa', 'EDE'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Derecho' LIMIT 1), 'Maestría en Gobernanza y Estrategia Internacional', 'MGEI'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Derecho' LIMIT 1), 'Doctorado en Ciencias Jurídicas', 'DCJ'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Derecho' LIMIT 1), 'Maestría en Derecho Civil', 'MDC'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Derecho' LIMIT 1), 'Maestría en Derecho de Empresa', 'MDE'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Derecho' LIMIT 1), 'Maestría en Justicia Penal', 'MJP'),
-- Facultad de Humanidades y Ciencias Sociales
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Humanidades y Ciencias Sociales' LIMIT 1), 'Licenciatura en Educación Preescolar', 'LEP'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Humanidades y Ciencias Sociales' LIMIT 1), 'Licenciatura en Ciencias Religiosas', 'LCR'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Humanidades y Ciencias Sociales' LIMIT 1), 'Licenciatura en Filosofía', 'LFIL'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Humanidades y Ciencias Sociales' LIMIT 1), 'Maestría en Filosofía Social', 'MFS'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Humanidades y Ciencias Sociales' LIMIT 1), 'Área de Gestión Educativa', 'AGE'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Humanidades y Ciencias Sociales' LIMIT 1), 'Área de Intervención Docente', 'AID'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Humanidades y Ciencias Sociales' LIMIT 1), 'Especialidad en Gestión de los Aprendizajes', 'EGA'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Humanidades y Ciencias Sociales' LIMIT 1), 'Doctorado en Educación', 'DOCE'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Humanidades y Ciencias Sociales' LIMIT 1), 'Licenciatura en Psicología', 'LPS'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Humanidades y Ciencias Sociales' LIMIT 1), 'Licenciatura en Educación Primaria', 'LEPR'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Humanidades y Ciencias Sociales' LIMIT 1), 'Licenciatura en Ciencias de la Educación', 'LCE'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Humanidades y Ciencias Sociales' LIMIT 1), 'Licenciatura en Pedagogía', 'LPED'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Humanidades y Ciencias Sociales' LIMIT 1), 'Licenciatura en Teología', 'LTEO'),
-- Facultad Mexicana de Medicina
((SELECT id FROM public.facultades WHERE nombre = 'Facultad Mexicana de Medicina' LIMIT 1), 'Licenciatura en Médico Cirujano', 'LMC'),
-- Facultad de Ciencias Químicas
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ciencias Químicas' LIMIT 1), 'Licenciatura en Ingeniería Química', 'LIQ'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ciencias Químicas' LIMIT 1), 'Licenciatura en Químico Farmacéutico Biólogo', 'QFB'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ciencias Químicas' LIMIT 1), 'Licenciatura en Química de Alimentos', 'LQA'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ciencias Químicas' LIMIT 1), 'Licenciatura en Ingeniería Ambiental', 'LIA'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ciencias Químicas' LIMIT 1), 'Maestría en Ciencia de los Alimentos y Nutrición Humana', 'MCAN'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ciencias Químicas' LIMIT 1), 'Maestría en Farmacología Clínica', 'MFC'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ciencias Químicas' LIMIT 1), 'Maestría en Calidad y Estadística Aplicada', 'MCEA'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ciencias Químicas' LIMIT 1), 'Maestría en Ingeniería de Proyectos', 'MIP'),
-- Facultad de Negocios
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Licenciatura en Contaduría y Finanzas', 'LCF'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Licenciatura en Mercadotecnia', 'LMER'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Licenciatura en Comercio y Negocios Internacionales', 'LCNI'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Licenciatura en Administración', 'LADM'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Licenciatura en Tecnologías de Información', 'LTI'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Maestría en Dirección de Organizaciones de la Salud', 'MDOS'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Licenciatura en Actuaría', 'LACT'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Especialidad en Mercadotecnia y Publicidad', 'EMP'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Especialidad en Finanzas Corporativas y Bursátiles', 'EFCB'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Especialidad en Logística y Cadena de Suministro', 'ELCS'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Especialidad en Administración de Organizaciones de la Salud', 'EAOS'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Maestría en Ciencias Actuariales', 'MCA'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Doctorado en Administración', 'DADM'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Maestría en Administración de Organizaciones de la Salud', 'MAOS'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Maestría en Administración de Negocios Internacionales', 'MANI'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Maestría en Tecnologías de Información en la Dirección de Negocios', 'MTID'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Maestría en Administración', 'MADM'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Maestría en Ingeniería Económica y Financiera', 'MIEF'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Maestría en Administración de Negocios Internacionales (MIEX)', 'MIEX'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Maestría en Gestión Estratégica del Capital Humano', 'MGEC'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Licenciatura en Administración e Innovación de Negocios', 'LAIN'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Licenciatura en Ingeniería Económica y Financiera', 'LIEF'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Negocios' LIMIT 1), 'Licenciatura en Transformación Digital para los Negocios', 'LTDN'),
-- Facultad de Ingeniería
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Licenciatura en Ingeniería Electrónica', 'LIE'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Licenciatura en Ingeniería Civil', 'LIC'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Licenciatura en Ingeniería Mecánica y en Sistemas Energéticos', 'LIME'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Licenciatura en Ingeniería Electrónica para Sistemas Inteligentes', 'LIES'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Licenciatura en Ingeniería Mecatrónica', 'LIM'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Licenciatura en Ingeniería Industrial', 'LII'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Licenciatura en Ingeniería Cibernética y Sistemas Computacionales', 'LICS'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Licenciatura en Ingeniería Biomédica', 'LIB'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Maestría en Gerencia de Proyectos Inmobiliarios', 'MGPI'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Maestría en Dirección Industrial', 'MDI'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Especialidad en Dirección Industrial', 'EDI'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Maestría en Gestión de Proyectos y de Empresas Constructoras', 'MGPC'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Especialidad en Energías Renovables', 'EER'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Maestría en Ciberseguridad', 'MCIB'),
((SELECT id FROM public.facultades WHERE nombre = 'Facultad de Ingeniería' LIMIT 1), 'Especialidad en Inteligencia de Datos', 'EID');